From 5f000540612ad18784817dc75a94437238c29613 Mon Sep 17 00:00:00 2001 From: Bmowville Date: Sat, 6 Jun 2026 14:51:48 -0400 Subject: [PATCH] Add challenges 038 through 040 --- README.md | 5 +- .../038_inventory_stockout_risk/README.md | 37 ++++++++ .../038_inventory_stockout_risk/expected.json | 59 +++++++++++++ .../038_inventory_stockout_risk/schema.sql | 88 +++++++++++++++++++ .../038_inventory_stockout_risk/solution.sql | 77 ++++++++++++++++ .../039_trial_to_paid_conversion/README.md | 34 +++++++ .../expected.json | 79 +++++++++++++++++ .../039_trial_to_paid_conversion/schema.sql | 51 +++++++++++ .../039_trial_to_paid_conversion/solution.sql | 44 ++++++++++ .../040_revenue_leakage_audit/README.md | 37 ++++++++ .../040_revenue_leakage_audit/expected.json | 69 +++++++++++++++ .../040_revenue_leakage_audit/schema.sql | 87 ++++++++++++++++++ .../040_revenue_leakage_audit/solution.sql | 80 +++++++++++++++++ docs/challenge-finder.js | 3 + docs/challenge-roadmap.md | 11 ++- docs/index.html | 8 +- docs/learning-paths.md | 3 + 17 files changed, 764 insertions(+), 8 deletions(-) create mode 100644 challenges/038_inventory_stockout_risk/README.md create mode 100644 challenges/038_inventory_stockout_risk/expected.json create mode 100644 challenges/038_inventory_stockout_risk/schema.sql create mode 100644 challenges/038_inventory_stockout_risk/solution.sql create mode 100644 challenges/039_trial_to_paid_conversion/README.md create mode 100644 challenges/039_trial_to_paid_conversion/expected.json create mode 100644 challenges/039_trial_to_paid_conversion/schema.sql create mode 100644 challenges/039_trial_to_paid_conversion/solution.sql create mode 100644 challenges/040_revenue_leakage_audit/README.md create mode 100644 challenges/040_revenue_leakage_audit/expected.json create mode 100644 challenges/040_revenue_leakage_audit/schema.sql create mode 100644 challenges/040_revenue_leakage_audit/solution.sql diff --git a/README.md b/README.md index 0f06a65..635f395 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ Tip: Each challenge folder includes its own README with the question, expected o | Revenue analytics | 005, 007, 011, 013, 014, 030 | | Date spines and rolling windows | 008, 016, 028 | | Product and customer behavior | 012, 015, 017, 018, 021, 022, 023, 024, 025, 026 | -| Data engineering SQL patterns | 035, 036, 037 | +| Data engineering SQL patterns | 035, 036, 037, 038, 039, 040 | For ordered study plans, see [Learning paths](docs/learning-paths.md). @@ -72,6 +72,9 @@ For ordered study plans, see [Learning paths](docs/learning-paths.md). 35. Subscription renewals + missed renewals + winback (30 days) — [challenges/035_subscription_renewal_winback](challenges/035_subscription_renewal_winback) 36. SCD Type 2 customer dimension (history table) — [challenges/036_scd2_customer_dimension](challenges/036_scd2_customer_dimension) 37. Incremental fact upsert (staging → fact, insert + update) — [challenges/037_incremental_fact_upsert](challenges/037_incremental_fact_upsert) +38. Inventory stockout risk (velocity + lead time scoring) — [challenges/038_inventory_stockout_risk](challenges/038_inventory_stockout_risk) +39. Trial to paid conversion (cohorts + conversion windows) — [challenges/039_trial_to_paid_conversion](challenges/039_trial_to_paid_conversion) +40. Revenue leakage audit (invoice + payment reconciliation) — [challenges/040_revenue_leakage_audit](challenges/040_revenue_leakage_audit) ## How to use You can copy/paste the SQL into SQLite, Postgres, or any SQL runner with minor tweaks. diff --git a/challenges/038_inventory_stockout_risk/README.md b/challenges/038_inventory_stockout_risk/README.md new file mode 100644 index 0000000..77b616a --- /dev/null +++ b/challenges/038_inventory_stockout_risk/README.md @@ -0,0 +1,37 @@ +# Challenge 038: Inventory stockout risk + +Goal: flag products that may stock out based on recent sales velocity, current inventory, lead time, and the next open purchase order. + +Use an as-of date of `2024-05-15`. + +Rules: +- `net_available_units` = on hand units minus reserved units +- `recent_7d_units` = units sold from `2024-05-09` through `2024-05-15` +- `avg_daily_units` = `recent_7d_units / 7` +- `days_of_cover` = `net_available_units / avg_daily_units` +- Risk is `critical` when days of cover is at or below lead time, or net availability is at or below safety stock +- Risk is `watch` when days of cover is within 7 days after lead time +- Products with no recent demand should be labeled `no_recent_demand` + +## Files +- `schema.sql` creates product, inventory, sales, and purchase order tables +- `solution.sql` computes stockout risk per product + +## Run (SQLite) + +Windows CMD: +```bat +type challenges\038_inventory_stockout_risk\schema.sql challenges\038_inventory_stockout_risk\solution.sql | sqlite3 +``` + +## Output + +Columns: +- product_id +- sku +- net_available_units +- recent_7d_units +- avg_daily_units +- days_of_cover +- next_open_po_date +- risk_level \ No newline at end of file diff --git a/challenges/038_inventory_stockout_risk/expected.json b/challenges/038_inventory_stockout_risk/expected.json new file mode 100644 index 0000000..5d87b61 --- /dev/null +++ b/challenges/038_inventory_stockout_risk/expected.json @@ -0,0 +1,59 @@ +{ + "challenge": "038_inventory_stockout_risk", + "result_sets": [ + { + "columns": [ + "product_id", + "sku", + "net_available_units", + "recent_7d_units", + "avg_daily_units", + "days_of_cover", + "next_open_po_date", + "risk_level" + ], + "rows": [ + [ + 101, + "WIDGET-A", + 60, + 70, + 10.0, + 6.0, + "2024-05-22", + "critical" + ], + [ + 102, + "FILTER-B", + 120, + 42, + 6.0, + 20.0, + "2024-05-25", + "healthy" + ], + [ + 103, + "PUMP-C", + 50, + 21, + 3.0, + 16.67, + "2024-06-01", + "watch" + ], + [ + 104, + "SENSOR-D", + 8, + 0, + 0.0, + null, + "2024-05-25", + "no_recent_demand" + ] + ] + } + ] +} diff --git a/challenges/038_inventory_stockout_risk/schema.sql b/challenges/038_inventory_stockout_risk/schema.sql new file mode 100644 index 0000000..3477f01 --- /dev/null +++ b/challenges/038_inventory_stockout_risk/schema.sql @@ -0,0 +1,88 @@ +-- Challenge 038: Inventory stockout risk +-- +-- Scenario: +-- A warehouse team wants to find products that may stock out before replenishment. +-- Combine recent sales velocity, current available inventory, supplier lead time, +-- safety stock, and the next open purchase order. +-- +-- Use as-of date: 2024-05-15 +-- Recent sales window: 2024-05-09 through 2024-05-15 +-- +-- Output per product: +-- product_id | sku | net_available_units | recent_7d_units | avg_daily_units | days_of_cover | next_open_po_date | risk_level + +DROP TABLE IF EXISTS purchase_orders; +DROP TABLE IF EXISTS sales; +DROP TABLE IF EXISTS inventory; +DROP TABLE IF EXISTS products; + +CREATE TABLE products ( + product_id INTEGER PRIMARY KEY, + sku TEXT NOT NULL, + category TEXT NOT NULL, + lead_time_days INTEGER NOT NULL, + safety_stock_units INTEGER NOT NULL +); + +CREATE TABLE inventory ( + product_id INTEGER PRIMARY KEY, + on_hand_units INTEGER NOT NULL, + reserved_units INTEGER NOT NULL +); + +CREATE TABLE sales ( + sale_id INTEGER PRIMARY KEY, + product_id INTEGER NOT NULL, + sold_date TEXT NOT NULL, + units_sold INTEGER NOT NULL +); + +CREATE TABLE purchase_orders ( + po_id INTEGER PRIMARY KEY, + product_id INTEGER NOT NULL, + due_date TEXT NOT NULL, + units_ordered INTEGER NOT NULL, + status TEXT NOT NULL +); + +INSERT INTO products (product_id, sku, category, lead_time_days, safety_stock_units) VALUES + (101, 'WIDGET-A', 'widgets', 10, 20), + (102, 'FILTER-B', 'filters', 6, 10), + (103, 'PUMP-C', 'pumps', 14, 15), + (104, 'SENSOR-D', 'sensors', 7, 5); + +INSERT INTO inventory (product_id, on_hand_units, reserved_units) VALUES + (101, 72, 12), + (102, 130, 10), + (103, 65, 15), + (104, 8, 0); + +INSERT INTO sales (sale_id, product_id, sold_date, units_sold) VALUES + (1, 101, '2024-05-02', 30), + (2, 101, '2024-05-09', 8), + (3, 101, '2024-05-10', 10), + (4, 101, '2024-05-11', 12), + (5, 101, '2024-05-12', 9), + (6, 101, '2024-05-13', 11), + (7, 101, '2024-05-14', 10), + (8, 101, '2024-05-15', 10), + (9, 102, '2024-05-09', 4), + (10, 102, '2024-05-10', 8), + (11, 102, '2024-05-12', 6), + (12, 102, '2024-05-14', 10), + (13, 102, '2024-05-15', 14), + (14, 103, '2024-05-09', 2), + (15, 103, '2024-05-10', 3), + (16, 103, '2024-05-11', 4), + (17, 103, '2024-05-12', 3), + (18, 103, '2024-05-14', 5), + (19, 103, '2024-05-15', 4), + (20, 104, '2024-05-07', 5); + +INSERT INTO purchase_orders (po_id, product_id, due_date, units_ordered, status) VALUES + (1, 101, '2024-05-22', 80, 'open'), + (2, 101, '2024-06-05', 120, 'open'), + (3, 102, '2024-05-25', 60, 'open'), + (4, 102, '2024-05-18', 30, 'closed'), + (5, 103, '2024-06-01', 40, 'open'), + (6, 104, '2024-05-25', 20, 'open'); \ No newline at end of file diff --git a/challenges/038_inventory_stockout_risk/solution.sql b/challenges/038_inventory_stockout_risk/solution.sql new file mode 100644 index 0000000..7f2d157 --- /dev/null +++ b/challenges/038_inventory_stockout_risk/solution.sql @@ -0,0 +1,77 @@ +-- Output columns: +-- product_id | sku | net_available_units | recent_7d_units | avg_daily_units | days_of_cover | next_open_po_date | risk_level + +WITH params AS ( + SELECT date('2024-05-15') AS as_of_date +), +recent_sales AS ( + SELECT + p.product_id, + COALESCE(SUM( + CASE + WHEN date(s.sold_date) BETWEEN date(params.as_of_date, '-6 day') AND params.as_of_date + THEN s.units_sold + ELSE 0 + END + ), 0) AS recent_7d_units + FROM products p + CROSS JOIN params + LEFT JOIN sales s ON s.product_id = p.product_id + GROUP BY p.product_id +), +next_purchase_order AS ( + SELECT + product_id, + MIN(date(due_date)) AS next_open_po_date + FROM purchase_orders + WHERE status = 'open' + GROUP BY product_id +), +metrics AS ( + SELECT + p.product_id, + p.sku, + p.lead_time_days, + p.safety_stock_units, + i.on_hand_units - i.reserved_units AS net_available_units, + rs.recent_7d_units, + ROUND(rs.recent_7d_units / 7.0, 2) AS avg_daily_units, + CASE + WHEN rs.recent_7d_units = 0 THEN NULL + ELSE ROUND((i.on_hand_units - i.reserved_units) * 7.0 / rs.recent_7d_units, 2) + END AS days_of_cover, + npo.next_open_po_date + FROM products p + JOIN inventory i ON i.product_id = p.product_id + JOIN recent_sales rs ON rs.product_id = p.product_id + LEFT JOIN next_purchase_order npo ON npo.product_id = p.product_id +), +scored AS ( + SELECT + product_id, + sku, + net_available_units, + recent_7d_units, + avg_daily_units, + days_of_cover, + next_open_po_date, + CASE + WHEN recent_7d_units = 0 THEN 'no_recent_demand' + WHEN net_available_units <= safety_stock_units THEN 'critical' + WHEN days_of_cover <= lead_time_days THEN 'critical' + WHEN days_of_cover <= lead_time_days + 7 THEN 'watch' + ELSE 'healthy' + END AS risk_level + FROM metrics +) +SELECT + product_id, + sku, + net_available_units, + recent_7d_units, + avg_daily_units, + days_of_cover, + next_open_po_date, + risk_level +FROM scored +ORDER BY product_id; \ No newline at end of file diff --git a/challenges/039_trial_to_paid_conversion/README.md b/challenges/039_trial_to_paid_conversion/README.md new file mode 100644 index 0000000..0adb4e0 --- /dev/null +++ b/challenges/039_trial_to_paid_conversion/README.md @@ -0,0 +1,34 @@ +# Challenge 039: Trial to paid conversion + +Goal: measure trial conversion by signup cohort and trial plan using 14-day and 30-day conversion windows. + +For each signup cohort month and trial plan, calculate: +- number of trials +- users who converted to paid within 14 days +- users who converted to paid within 30 days +- 30-day conversion rate +- average days to paid conversion within 30 days +- new monthly recurring revenue from 30-day conversions + +## Files +- `schema.sql` creates trial signup and paid subscription tables +- `solution.sql` finds each user's first paid subscription and rolls conversion metrics up by cohort + +## Run (SQLite) + +Windows CMD: +```bat +type challenges\039_trial_to_paid_conversion\schema.sql challenges\039_trial_to_paid_conversion\solution.sql | sqlite3 +``` + +## Output + +Columns: +- cohort_month +- trial_plan +- trials +- converted_14d +- converted_30d +- conversion_30d_pct +- avg_days_to_paid_30d +- new_mrr_30d \ No newline at end of file diff --git a/challenges/039_trial_to_paid_conversion/expected.json b/challenges/039_trial_to_paid_conversion/expected.json new file mode 100644 index 0000000..bad3317 --- /dev/null +++ b/challenges/039_trial_to_paid_conversion/expected.json @@ -0,0 +1,79 @@ +{ + "challenge": "039_trial_to_paid_conversion", + "result_sets": [ + { + "columns": [ + "cohort_month", + "trial_plan", + "trials", + "converted_14d", + "converted_30d", + "conversion_30d_pct", + "avg_days_to_paid_30d", + "new_mrr_30d" + ], + "rows": [ + [ + "2024-01", + "basic", + 2, + 1, + 1, + 50.0, + 7.0, + 29.0 + ], + [ + "2024-01", + "pro", + 2, + 1, + 1, + 50.0, + 12.0, + 99.0 + ], + [ + "2024-02", + "basic", + 2, + 0, + 1, + 50.0, + 16.0, + 29.0 + ], + [ + "2024-02", + "pro", + 2, + 1, + 2, + 100.0, + 18.5, + 198.0 + ], + [ + "2024-03", + "basic", + 1, + 1, + 1, + 100.0, + 3.0, + 29.0 + ], + [ + "2024-03", + "pro", + 1, + 0, + 0, + 0.0, + null, + 0 + ] + ] + } + ] +} diff --git a/challenges/039_trial_to_paid_conversion/schema.sql b/challenges/039_trial_to_paid_conversion/schema.sql new file mode 100644 index 0000000..02abfd0 --- /dev/null +++ b/challenges/039_trial_to_paid_conversion/schema.sql @@ -0,0 +1,51 @@ +-- Challenge 039: Trial to paid conversion +-- +-- Scenario: +-- A SaaS team wants cohort conversion metrics by trial plan. +-- Use the first active paid subscription after signup for each user. +-- +-- Output by signup cohort month and trial plan: +-- cohort_month | trial_plan | trials | converted_14d | converted_30d | conversion_30d_pct | avg_days_to_paid_30d | new_mrr_30d + +DROP TABLE IF EXISTS subscriptions; +DROP TABLE IF EXISTS trial_signups; + +CREATE TABLE trial_signups ( + user_id INTEGER PRIMARY KEY, + signup_date TEXT NOT NULL, + trial_plan TEXT NOT NULL, + acquisition_channel TEXT NOT NULL +); + +CREATE TABLE subscriptions ( + subscription_id INTEGER PRIMARY KEY, + user_id INTEGER NOT NULL, + started_date TEXT NOT NULL, + paid_plan TEXT NOT NULL, + monthly_amount REAL NOT NULL, + status TEXT NOT NULL +); + +INSERT INTO trial_signups (user_id, signup_date, trial_plan, acquisition_channel) VALUES + (1, '2024-01-03', 'basic', 'search'), + (2, '2024-01-08', 'basic', 'partner'), + (3, '2024-01-20', 'pro', 'search'), + (4, '2024-01-28', 'pro', 'organic'), + (5, '2024-02-02', 'basic', 'organic'), + (6, '2024-02-09', 'basic', 'search'), + (7, '2024-02-11', 'pro', 'partner'), + (8, '2024-02-18', 'pro', 'search'), + (9, '2024-03-01', 'basic', 'organic'), + (10, '2024-03-05', 'pro', 'search'); + +INSERT INTO subscriptions (subscription_id, user_id, started_date, paid_plan, monthly_amount, status) VALUES + (101, 1, '2024-01-10', 'basic_paid', 29.00, 'active'), + (102, 2, '2024-02-12', 'basic_paid', 29.00, 'active'), + (103, 3, '2024-02-01', 'pro_paid', 99.00, 'active'), + (104, 5, '2024-02-18', 'basic_paid', 29.00, 'active'), + (105, 6, '2024-03-15', 'basic_paid', 29.00, 'active'), + (106, 7, '2024-02-20', 'pro_paid', 99.00, 'active'), + (107, 8, '2024-03-17', 'pro_paid', 99.00, 'active'), + (108, 9, '2024-03-04', 'basic_paid', 29.00, 'active'), + (109, 10, '2024-04-10', 'pro_paid', 99.00, 'active'), + (110, 7, '2024-03-20', 'pro_paid', 99.00, 'active'); \ No newline at end of file diff --git a/challenges/039_trial_to_paid_conversion/solution.sql b/challenges/039_trial_to_paid_conversion/solution.sql new file mode 100644 index 0000000..7ad5254 --- /dev/null +++ b/challenges/039_trial_to_paid_conversion/solution.sql @@ -0,0 +1,44 @@ +-- Output columns: +-- cohort_month | trial_plan | trials | converted_14d | converted_30d | conversion_30d_pct | avg_days_to_paid_30d | new_mrr_30d + +WITH first_paid AS ( + SELECT + user_id, + date(started_date) AS started_date, + paid_plan, + monthly_amount, + ROW_NUMBER() OVER ( + PARTITION BY user_id + ORDER BY date(started_date), subscription_id + ) AS rn + FROM subscriptions + WHERE status = 'active' +), +trial_outcomes AS ( + SELECT + substr(ts.signup_date, 1, 7) AS cohort_month, + ts.trial_plan, + ts.user_id, + CASE + WHEN fp.started_date IS NULL THEN NULL + WHEN fp.started_date < date(ts.signup_date) THEN NULL + ELSE CAST(julianday(fp.started_date) - julianday(ts.signup_date) AS INTEGER) + END AS days_to_paid, + fp.monthly_amount + FROM trial_signups ts + LEFT JOIN first_paid fp + ON fp.user_id = ts.user_id + AND fp.rn = 1 +) +SELECT + cohort_month, + trial_plan, + COUNT(*) AS trials, + SUM(CASE WHEN days_to_paid BETWEEN 0 AND 14 THEN 1 ELSE 0 END) AS converted_14d, + SUM(CASE WHEN days_to_paid BETWEEN 0 AND 30 THEN 1 ELSE 0 END) AS converted_30d, + ROUND(100.0 * SUM(CASE WHEN days_to_paid BETWEEN 0 AND 30 THEN 1 ELSE 0 END) / COUNT(*), 2) AS conversion_30d_pct, + ROUND(AVG(CASE WHEN days_to_paid BETWEEN 0 AND 30 THEN days_to_paid END), 2) AS avg_days_to_paid_30d, + COALESCE(SUM(CASE WHEN days_to_paid BETWEEN 0 AND 30 THEN monthly_amount ELSE 0 END), 0) AS new_mrr_30d +FROM trial_outcomes +GROUP BY cohort_month, trial_plan +ORDER BY cohort_month, trial_plan; \ No newline at end of file diff --git a/challenges/040_revenue_leakage_audit/README.md b/challenges/040_revenue_leakage_audit/README.md new file mode 100644 index 0000000..dc00e52 --- /dev/null +++ b/challenges/040_revenue_leakage_audit/README.md @@ -0,0 +1,37 @@ +# Challenge 040: Revenue leakage audit + +Goal: reconcile completed orders against payments and refunds, then label revenue issues by priority. + +This is the hardest challenge in the current set. It combines order totals, discounts, tax, successful payments, duplicate payment references, completed refunds, and variance classification. + +Issue priority: +1. `missing_payment` +2. `duplicate_payment` +3. `underpaid` +4. `overpaid` +5. `ok` + +Only non-`ok` orders should appear in the final output. + +## Files +- `schema.sql` creates order, item, discount, payment, and refund tables +- `solution.sql` calculates expected order amounts and compares them with collected cash + +## Run (SQLite) + +Windows CMD: +```bat +type challenges\040_revenue_leakage_audit\schema.sql challenges\040_revenue_leakage_audit\solution.sql | sqlite3 +``` + +## Output + +Columns: +- order_id +- expected_amount +- successful_payments +- duplicate_successful_payments +- completed_refunds +- net_collected +- variance +- issue_type \ No newline at end of file diff --git a/challenges/040_revenue_leakage_audit/expected.json b/challenges/040_revenue_leakage_audit/expected.json new file mode 100644 index 0000000..1b050b0 --- /dev/null +++ b/challenges/040_revenue_leakage_audit/expected.json @@ -0,0 +1,69 @@ +{ + "challenge": "040_revenue_leakage_audit", + "result_sets": [ + { + "columns": [ + "order_id", + "expected_amount", + "successful_payments", + "duplicate_successful_payments", + "completed_refunds", + "net_collected", + "variance", + "issue_type" + ], + "rows": [ + [ + 502, + 86.4, + 0, + 0, + 0, + 0.0, + -86.4, + "missing_payment" + ], + [ + 503, + 110.0, + 1, + 0, + 0, + 90.0, + -20.0, + "underpaid" + ], + [ + 504, + 64.8, + 2, + 1, + 0, + 129.6, + 64.8, + "duplicate_payment" + ], + [ + 505, + 216.0, + 1, + 0, + 50.0, + 166.0, + -50.0, + "underpaid" + ], + [ + 506, + 32.4, + 1, + 0, + 0, + 40.0, + 7.6, + "overpaid" + ] + ] + } + ] +} diff --git a/challenges/040_revenue_leakage_audit/schema.sql b/challenges/040_revenue_leakage_audit/schema.sql new file mode 100644 index 0000000..15018bf --- /dev/null +++ b/challenges/040_revenue_leakage_audit/schema.sql @@ -0,0 +1,87 @@ +-- Challenge 040: Revenue leakage audit +-- +-- Scenario: +-- A finance/data team needs to reconcile completed orders against payments and refunds. +-- Expected order amount comes from item totals minus discounts, plus tax. +-- Net collected comes from successful payments minus completed refunds. +-- +-- Output only orders with an issue: +-- order_id | expected_amount | successful_payments | duplicate_successful_payments | completed_refunds | net_collected | variance | issue_type + +DROP TABLE IF EXISTS refunds; +DROP TABLE IF EXISTS payments; +DROP TABLE IF EXISTS order_discounts; +DROP TABLE IF EXISTS order_items; +DROP TABLE IF EXISTS orders; + +CREATE TABLE orders ( + order_id INTEGER PRIMARY KEY, + customer_id INTEGER NOT NULL, + order_date TEXT NOT NULL, + status TEXT NOT NULL, + tax_rate REAL NOT NULL +); + +CREATE TABLE order_items ( + item_id INTEGER PRIMARY KEY, + order_id INTEGER NOT NULL, + sku TEXT NOT NULL, + quantity INTEGER NOT NULL, + unit_price REAL NOT NULL +); + +CREATE TABLE order_discounts ( + discount_id INTEGER PRIMARY KEY, + order_id INTEGER NOT NULL, + discount_amount REAL NOT NULL, + reason TEXT NOT NULL +); + +CREATE TABLE payments ( + payment_id INTEGER PRIMARY KEY, + order_id INTEGER NOT NULL, + payment_ref TEXT NOT NULL, + paid_at TEXT NOT NULL, + amount REAL NOT NULL, + status TEXT NOT NULL +); + +CREATE TABLE refunds ( + refund_id INTEGER PRIMARY KEY, + order_id INTEGER NOT NULL, + refund_amount REAL NOT NULL, + status TEXT NOT NULL +); + +INSERT INTO orders (order_id, customer_id, order_date, status, tax_rate) VALUES + (501, 1001, '2024-04-01', 'completed', 0.08), + (502, 1002, '2024-04-02', 'completed', 0.08), + (503, 1003, '2024-04-03', 'completed', 0.10), + (504, 1004, '2024-04-04', 'completed', 0.08), + (505, 1005, '2024-04-05', 'completed', 0.08), + (506, 1006, '2024-04-06', 'completed', 0.08); + +INSERT INTO order_items (item_id, order_id, sku, quantity, unit_price) VALUES + (1, 501, 'KIT-A', 2, 50.00), + (2, 502, 'KIT-B', 1, 80.00), + (3, 503, 'KIT-C', 3, 40.00), + (4, 504, 'KIT-D', 1, 60.00), + (5, 505, 'KIT-E', 1, 200.00), + (6, 506, 'KIT-F', 1, 30.00); + +INSERT INTO order_discounts (discount_id, order_id, discount_amount, reason) VALUES + (1, 501, 10.00, 'promo'), + (2, 503, 20.00, 'retention_credit'); + +INSERT INTO payments (payment_id, order_id, payment_ref, paid_at, amount, status) VALUES + (1, 501, 'pay-501', '2024-04-01 10:15:00', 97.20, 'succeeded'), + (2, 502, 'pay-502-failed', '2024-04-02 11:00:00', 86.40, 'failed'), + (3, 503, 'pay-503', '2024-04-03 12:05:00', 90.00, 'succeeded'), + (4, 504, 'pay-504', '2024-04-04 09:30:00', 64.80, 'succeeded'), + (5, 504, 'pay-504', '2024-04-04 09:31:00', 64.80, 'succeeded'), + (6, 505, 'pay-505', '2024-04-05 14:20:00', 216.00, 'succeeded'), + (7, 506, 'pay-506', '2024-04-06 16:45:00', 40.00, 'succeeded'); + +INSERT INTO refunds (refund_id, order_id, refund_amount, status) VALUES + (1, 505, 50.00, 'completed'), + (2, 501, 10.00, 'voided'); \ No newline at end of file diff --git a/challenges/040_revenue_leakage_audit/solution.sql b/challenges/040_revenue_leakage_audit/solution.sql new file mode 100644 index 0000000..e988cb6 --- /dev/null +++ b/challenges/040_revenue_leakage_audit/solution.sql @@ -0,0 +1,80 @@ +-- Output columns: +-- order_id | expected_amount | successful_payments | duplicate_successful_payments | completed_refunds | net_collected | variance | issue_type + +WITH item_totals AS ( + SELECT + order_id, + SUM(quantity * unit_price) AS item_total + FROM order_items + GROUP BY order_id +), +discount_totals AS ( + SELECT + order_id, + SUM(discount_amount) AS discount_total + FROM order_discounts + GROUP BY order_id +), +payment_totals AS ( + SELECT + order_id, + COUNT(*) AS successful_payments, + COUNT(*) - COUNT(DISTINCT payment_ref) AS duplicate_successful_payments, + SUM(amount) AS successful_payment_amount + FROM payments + WHERE status = 'succeeded' + GROUP BY order_id +), +refund_totals AS ( + SELECT + order_id, + SUM(refund_amount) AS completed_refunds + FROM refunds + WHERE status = 'completed' + GROUP BY order_id +), +audit_base AS ( + SELECT + o.order_id, + ROUND((it.item_total - COALESCE(dt.discount_total, 0)) * (1 + o.tax_rate), 2) AS expected_amount, + COALESCE(pt.successful_payments, 0) AS successful_payments, + COALESCE(pt.duplicate_successful_payments, 0) AS duplicate_successful_payments, + COALESCE(rt.completed_refunds, 0) AS completed_refunds, + ROUND(COALESCE(pt.successful_payment_amount, 0) - COALESCE(rt.completed_refunds, 0), 2) AS net_collected + FROM orders o + JOIN item_totals it ON it.order_id = o.order_id + LEFT JOIN discount_totals dt ON dt.order_id = o.order_id + LEFT JOIN payment_totals pt ON pt.order_id = o.order_id + LEFT JOIN refund_totals rt ON rt.order_id = o.order_id + WHERE o.status = 'completed' +), +classified AS ( + SELECT + order_id, + expected_amount, + successful_payments, + duplicate_successful_payments, + completed_refunds, + net_collected, + ROUND(net_collected - expected_amount, 2) AS variance, + CASE + WHEN successful_payments = 0 THEN 'missing_payment' + WHEN duplicate_successful_payments > 0 THEN 'duplicate_payment' + WHEN net_collected - expected_amount < -0.01 THEN 'underpaid' + WHEN net_collected - expected_amount > 0.01 THEN 'overpaid' + ELSE 'ok' + END AS issue_type + FROM audit_base +) +SELECT + order_id, + expected_amount, + successful_payments, + duplicate_successful_payments, + completed_refunds, + net_collected, + variance, + issue_type +FROM classified +WHERE issue_type <> 'ok' +ORDER BY order_id; \ No newline at end of file diff --git a/docs/challenge-finder.js b/docs/challenge-finder.js index c1b3a48..ca160a7 100644 --- a/docs/challenge-finder.js +++ b/docs/challenge-finder.js @@ -36,6 +36,9 @@ const CHALLENGES = [ { id: "035", title: "Subscription renewals and winback", folder: "035_subscription_renewal_winback", skill: "engineering", difficulty: "advanced", focus: "Renewal states, missed renewals, and winbacks." }, { id: "036", title: "SCD Type 2 customer dimension", folder: "036_scd2_customer_dimension", skill: "engineering", difficulty: "advanced", focus: "History table design and effective dating." }, { id: "037", title: "Incremental fact upsert", folder: "037_incremental_fact_upsert", skill: "engineering", difficulty: "advanced", focus: "Staging-to-fact insert and update patterns." }, + { id: "038", title: "Inventory stockout risk", folder: "038_inventory_stockout_risk", skill: "engineering", difficulty: "advanced", focus: "Sales velocity, lead time, and inventory risk scoring." }, + { id: "039", title: "Trial to paid conversion", folder: "039_trial_to_paid_conversion", skill: "engineering", difficulty: "advanced", focus: "Cohort conversion windows and new MRR rollups." }, + { id: "040", title: "Revenue leakage audit", folder: "040_revenue_leakage_audit", skill: "engineering", difficulty: "advanced", focus: "Invoice, payment, refund, and exception reconciliation." }, ]; const SKILL_LABELS = { diff --git a/docs/challenge-roadmap.md b/docs/challenge-roadmap.md index 4686293..e2ad12a 100644 --- a/docs/challenge-roadmap.md +++ b/docs/challenge-roadmap.md @@ -8,15 +8,20 @@ If you want to suggest one of these, open a [challenge request](https://github.c | Idea | Skill area | What the query should answer | | --- | --- | --- | -| Inventory stockout risk | Product operations | Which products are likely to stock out based on recent sales velocity and current inventory? | | First-touch attribution | Product analytics | Which acquisition channel should receive credit for a customer's first purchase? | -| Trial to paid conversion | SaaS metrics | What share of trial users convert by signup cohort and plan type? | -| Revenue leakage audit | Data quality | Which orders have missing, duplicated, or mismatched payment records? | | Rolling funnel drop-off | Funnel analysis | Where does conversion fall week over week across view, cart, checkout, and purchase events? | | Late-arriving facts | Data engineering SQL | Which staged facts arrived after the reporting cutoff and need a correction load? | | Slowly changing product prices | Data modeling | Which product price was effective at the time each order was placed? | | Support backlog aging | Operations analytics | Which support queues are breaching age targets by priority and owner? | +## Recently Added + +| Challenge | Skill area | Focus | +| --- | --- | --- | +| [038 Inventory stockout risk](../challenges/038_inventory_stockout_risk) | Product operations | Sales velocity, lead time, and inventory risk scoring | +| [039 Trial to paid conversion](../challenges/039_trial_to_paid_conversion) | SaaS metrics | Cohort conversion windows and new MRR rollups | +| [040 Revenue leakage audit](../challenges/040_revenue_leakage_audit) | Data quality | Invoice, payment, refund, and exception reconciliation | + ## Contribution Fit A strong new challenge usually has: diff --git a/docs/index.html b/docs/index.html index 55a9f92..a5e4e07 100644 --- a/docs/index.html +++ b/docs/index.html @@ -5,7 +5,7 @@ SQL Mini Challenges @@ -29,7 +29,7 @@

Runnable SQL practice

-

37 SQL analytics challenges with validated answers.

+

40 SQL analytics challenges with validated answers.

Practice cohorts, retention, window functions, revenue analysis, and data engineering SQL patterns with small datasets you can run locally.

@@ -58,7 +58,7 @@

37 SQL analytics challenges with validated answers.

- 37 + 40 challenge folders
@@ -131,7 +131,7 @@

Revenue and Growth

Data Engineering SQL

-

Renewal state logic, SCD Type 2 history, staging-to-fact updates, and incremental load patterns.

+

Renewal state logic, SCD Type 2 history, incremental loads, stockout risk, conversion windows, and payment reconciliation.

diff --git a/docs/learning-paths.md b/docs/learning-paths.md index 12c4da6..38f40ce 100644 --- a/docs/learning-paths.md +++ b/docs/learning-paths.md @@ -35,6 +35,9 @@ Use these paths to work through the challenges by skill area instead of numeric | 1 | [035 Subscription renewal winback](../challenges/035_subscription_renewal_winback) | Renewal state logic | | 2 | [036 SCD2 customer dimension](../challenges/036_scd2_customer_dimension) | History table design | | 3 | [037 Incremental fact upsert](../challenges/037_incremental_fact_upsert) | Staging-to-fact merge logic | +| 4 | [038 Inventory stockout risk](../challenges/038_inventory_stockout_risk) | Velocity and lead-time risk scoring | +| 5 | [039 Trial to paid conversion](../challenges/039_trial_to_paid_conversion) | Cohort conversion windows | +| 6 | [040 Revenue leakage audit](../challenges/040_revenue_leakage_audit) | Multi-table payment reconciliation | ## Suggested Review Routine 1. Read the challenge README and expected output first.