DB Olympics

Queries · q017 · Chief Financial Officer · week 1, night 2

Refund Rate by Merchant Vertical, 2025

“Do refund rates differ meaningfully across merchant verticals, and which verticals look like outliers?”

Manager · asked the questionTobias LindqvistChief Financial Officer. New this month, previously finance director at a retailer. Preparing the 2025 annual accounts and the 2026 budget.
Analyst · planned the reportKofi MensahHypothesis-driven: states what he expects and designs reports that would prove him wrong.
Engineer · wrote the SQLViktorOld-school warehouse engineer: explicit JOIN chains straight to the base tables, derived tables, GROUP BY with CASE pivots, few CTEs.

Fictional people at a fictional company, each played by a language-model agent. Who they are and what they are told

  1. Tobias Lindqvist writes to Kofi Mensah

    The manager, at the start of week 1

    Subject: Finding my bearings – first set of questions for the week

    Hi Kofi,

    Good to be working with you. I'm still building my mental map of the business, so I'd rather ask a few broad questions well than a lot of narrow ones badly. Given the slot budget, here's what I'd prioritize this week, roughly in order:

    1. How did we actually do in 2025, and where is the money coming from? I want to see our own revenue split between the subscription/plan fees and the payment-processing fees, over the year, and ideally how that's trending month to month. This is the backbone of the annual accounts narrative and will shape how I talk about 2025 to the board.
    2. How concentrated is our business in a few large merchants, and how healthy are they? I keep hearing "a handful of large merchants drive most of the volume" — I'd like to understand who they are, how much of our gross volume and revenue they represent, and whether that concentration is growing or shrinking. This matters for risk assessment going into the budget.
    3. What do refunds and returns look like across the merchant base? I want a sense of refund rates — overall and whether certain verticals or merchants are outliers — since this affects both our net revenue and how much cash we actually end up paying out.
    4. How exposed are we to currency movements? Merchants bill in their own currencies, and shoppers come from ~40 countries. I'd like a picture of how our volume and revenue break down by currency, so I can gauge our FX exposure for the 2026 budget and think about whether we need any hedging conversation.
    5. Are our B2B merchants paying us and their own customers on time? I know a few of our merchants are wholesalers running on B2B terms. I'd like to understand how their receivables look — how much is outstanding and how overdue it tends to get — since this could be a real cash-flow risk if it's worse than I'd expect.

    For this week, let's focus on #1 and #2 — those are the two I most need before I can speak credibly about 2025 and the 2026 budget. If there's room, #3 would be next; #4 and #5 can wait for a follow-up week if slots are tight.

    Let me know if any of these need me to narrow scope further before you brief the data engineers.

    Thanks, Tobias

  2. Kofi Mensah plans the night

    The analyst · week 1, night 2, request 2 of 3

    Before trusting the 2025 revenue narrative, I cross-footed the three Night 1 reports. Report 2's top-25 merchants alone sum to ~€3.32M gross volume and ~€79.9k fee revenue, and Report 3 independently confirms a 2025 total of ~€3.97M gross volume with top-10 fee share of 65.1%. But Report 1's monthly trend sums to only ~€1.99M gross volume and ~€53.2k fee revenue for all of 2025 — roughly half the true total, and even less than the top-25 merchants alone. That's a real inconsistency, not noise. The implied average fee rate is similar across reports (~2.5-2.7% vs ~2.35%), which points to a coverage problem rather than a rate/conversion-formula error: Report 1 is likely dropping a chunk of payouts, most plausibly through an inner join to exchange_rates that silently excludes payouts in currencies/dates without a matching FX row, rather than a global bug that would also show up in Reports 2/3 (which are internally consistent with each other). I want this diagnosed before I ask for a restated monthly trend, so I'm spending one slot on a currency-level reconciliation with an explicit LEFT JOIN and a count of unmatched rows.

    With the remaining two slots I'm moving to Tobias's #3 (refunds/returns), since #2 (concentration) already has a solid answer from Reports 2-3 and doesn't need more tonight. My hypothesis: B2B wholesale verticals (industrial supplies, office equipment) have lower refund rates than apparel/footwear and beauty (higher return propensity categories), and a handful of merchants will be clear outliers worth flagging to Tobias. I'm using payments.amount vs payments.amount_refunded as the cleanest refund-rate source (already cumulative per payment), split by vertical and then by merchant to find outliers.

    Refund Rate by Merchant Vertical, 2025
    Question
    Do refund rates differ meaningfully across merchant verticals, and which verticals look like outliers?
    One row per
    one tenant industry vertical
    Columns
    • vertical
    • merchant count
    • payment count
    • gross captured amount (EUR)
    • amount refunded (EUR)
    • refund rate (%) = refunded / captured
    • avg refund rate per merchant (%) (simple average across merchants in the vertical, for comparison to the volume-weighted rate)
  3. Viktor writes the SQL

    The engineer · 1 dry run on the empty schema, then the submission

    Dry run 1 · STATUS: COMPILES (dry run on the empty schema, no data read)
    SELECT
      vertical,
      merchant_count,
      payment_count,
      gross_captured_eur,
      amount_refunded_eur,
      ROUND(CASE WHEN gross_captured_eur = 0 THEN NULL ELSE amount_refunded_eur / gross_captured_eur * 100 END, 2) AS refund_rate_pct,
      ROUND(avg_refund_rate_pct, 2) AS avg_refund_rate_per_merchant_pct
    FROM (
      SELECT
        vertical,
        COUNT(*) AS merchant_count,
        SUM(payment_count) AS payment_count,
        SUM(gross_captured_eur) AS gross_captured_eur,
        SUM(amount_refunded_eur) AS amount_refunded_eur,
        AVG(tenant_refund_rate_pct) AS avg_refund_rate_pct
      FROM (
        SELECT
          COALESCE(t.industry, 'Unknown') AS vertical,
          t.id AS tenant_id,
          COUNT(*) AS payment_count,
          SUM(p.amount / er.rate) AS gross_captured_eur,
          SUM(p.amount_refunded / er.rate) AS amount_refunded_eur,
          CASE WHEN SUM(p.amount / er.rate) = 0 THEN 0
               ELSE SUM(p.amount_refunded / er.rate) / SUM(p.amount / er.rate) * 100
          END AS tenant_refund_rate_pct
        FROM payments p
        JOIN tenants t ON t.id = p.tenant_id
        JOIN exchange_rates er
          ON er.base_code = 'EUR'
         AND er.quote_code = p.currency_code
         AND er.as_of = DATE(p.captured_at)
        WHERE p.captured_at >= DATE '2025-01-01'
          AND p.captured_at < DATE '2026-01-01'
          AND p.status IN ('captured','refunded','partially_refunded')
        GROUP BY COALESCE(t.industry, 'Unknown'), t.id
      ) AS tenant_agg
      GROUP BY vertical
    ) AS vertical_agg
    ORDER BY refund_rate_pct DESC, vertical ASC

    “The query compiles and produces the requested columns at one row per vertical.”

    Submitted · the SQL of the last dry run, unchanged · Submitted to tonight's batch. Your work on this request is done.

    “Submitted. Summary of what the query does: - Grain: one row per tenant industry vertical (from tenants.industry, with nulls bucketed as "Unknown"). - Filter: payments with captured_at in 2025 and status in captured, refunded, or partially_refunded. - FX: each payment's amount and amount_refunded are converted to EUR using that day's EUR→currency_code rate from exchange_rates. - Metrics: merchant count, payment count, gross captured EUR, refunded EUR, the volume-weighted refund rate (sum refunded / sum captured), and the simple average of each merchant's own EUR-based refund rate within the vertical — letting the CFO spot verticals where a few large merchants skew the headline rate. - Ordered by weighted refund rate descending, tie-broken by vertical name. Noted in the submission: industry is free text rather than a fixed vertical taxonomy, so a few tenants may fall under "Unknown" if industry is null.”

  4. The report comes back overnight

    As Kofi Mensah saw it, from the small dataset the agents worked on

    vertical                  | merchant_count | payment_count | gross_captured_eur | amount_refunded_eur | refund_rate_pct | avg_refund_rate_per_merchant_pct
    --------------------------+----------------+---------------+--------------------+---------------------+-----------------+---------------------------------
    Apparel & Footwear        |             12 |          5829 | 1078095.0931260681 |  223899.98025102273 |           20.77 |                            20.17
    Consumer Electronics      |              3 |            45 | 32704.557149522407 |   4536.256832012029 |           13.87 |                            12.09
    Sports & Outdoor          |              5 |           542 | 208642.66525501793 |   27615.81190173126 |           13.24 |                            12.67
    Home & Living             |              9 |           826 | 130876.13832331166 |   9636.863087465277 |            7.36 |                             7.69
    Beauty                    |              8 |          1251 |  125299.2177342255 |    7778.69901928958 |            6.21 |                             4.19
    Office Supplies (B2B)     |              2 |           239 |   707746.699374597 |  30565.586683472724 |            4.32 |                             3.64
    Books & Stationery        |              4 |           115 | 5631.0311708235095 |   186.7947706139839 |            3.32 |                             2.27
    Pet Supplies              |              7 |          1095 |  55275.88436275425 |   877.5520952376191 |            1.59 |                             1.53
    Food & Coffee             |              5 |          1530 |  77195.58403267068 |   881.7447615475771 |            1.14 |                             0.48
    Industrial Supplies (B2B) |              1 |           154 |  386487.6248121912 |  1547.2102448206222 |             0.4 |                              0.4
    Jewelry & Accessories     |              1 |            14 | 3001.8742620116054 |                 0.0 |             0.0 |                              0.0
    (11 rows)
  5. What came of it

    The memo and the queries that followed; links found by title, so inferred

    Later queries that build on this report:

  6. It becomes benchmark query q017

    Changed only where the benchmark needs the same result on every system

    • spellingdate cast
    • numericsdocs/NUMERICS.md: rounding removed; results keep full precision
    • numericsdocs/NUMERICS.md: x / exchange rate cast to NUMERIC(18,6) (5×)
    • determinismthe standard leaves the place of NULLs in a sort to the implementation: NULLS LAST ascending, NULLS FIRST descending, as in the reference results (2×)
    The benchmark text, against the submitted SQL
    SELECT  vertical,  merchant_count,  payment_count,  gross_captured_eur,  amount_refunded_eur,  ROUND(CASE WHEN gross_captured_eur = 0 THEN NULL ELSE amount_refunded_eur / gross_captured_eur * 100 END, 2) AS refund_rate_pct,  ROUND(avg_refund_rate_pct, 2) AS avg_refund_rate_per_merchant_pct  CASE WHEN gross_captured_eur = 0 THEN NULL ELSE amount_refunded_eur / gross_captured_eur * 100 END AS refund_rate_pct,  avg_refund_rate_pct AS avg_refund_rate_per_merchant_pctFROM (  SELECT    vertical,    COUNT(*) AS merchant_count,    SUM(payment_count) AS payment_count,    SUM(gross_captured_eur) AS gross_captured_eur,    SUM(amount_refunded_eur) AS amount_refunded_eur,    AVG(tenant_refund_rate_pct) AS avg_refund_rate_pct  FROM (    SELECT      COALESCE(t.industry, 'Unknown') AS vertical,      t.id AS tenant_id,      COUNT(*) AS payment_count,      SUM(p.amount / er.rate) AS gross_captured_eur,      SUM(p.amount_refunded / er.rate) AS amount_refunded_eur,      CASE WHEN SUM(p.amount / er.rate) = 0 THEN 0           ELSE SUM(p.amount_refunded / er.rate) / SUM(p.amount / er.rate) * 100      SUM(CAST(p.amount / er.rate AS NUMERIC(18,6))) AS gross_captured_eur,      SUM(CAST(p.amount_refunded / er.rate AS NUMERIC(18,6))) AS amount_refunded_eur,      CASE WHEN SUM(CAST(p.amount / er.rate AS NUMERIC(18,6))) = 0 THEN 0           ELSE SUM(CAST(p.amount_refunded / er.rate AS NUMERIC(18,6))) / SUM(CAST(p.amount / er.rate AS NUMERIC(18,6))) * 100      END AS tenant_refund_rate_pct    FROM payments p    JOIN tenants t ON t.id = p.tenant_id    JOIN exchange_rates er      ON er.base_code = 'EUR'     AND er.quote_code = p.currency_code     AND er.as_of = DATE(p.captured_at)     AND er.as_of = CAST(p.captured_at AS DATE)    WHERE p.captured_at >= DATE '2025-01-01'      AND p.captured_at < DATE '2026-01-01'      AND p.status IN ('captured','refunded','partially_refunded')    GROUP BY COALESCE(t.industry, 'Unknown'), t.id  ) AS tenant_agg  GROUP BY vertical) AS vertical_aggORDER BY refund_rate_pct DESC, vertical ASCORDER BY refund_rate_pct DESC NULLS FIRST, vertical ASC NULLS LAST
    SELECT
      vertical,
      merchant_count,
      payment_count,
      gross_captured_eur,
      amount_refunded_eur,
      CASE WHEN gross_captured_eur = 0 THEN NULL ELSE amount_refunded_eur / gross_captured_eur * 100 END AS refund_rate_pct,
      avg_refund_rate_pct AS avg_refund_rate_per_merchant_pct
    FROM (
      SELECT
        vertical,
        COUNT(*) AS merchant_count,
        SUM(payment_count) AS payment_count,
        SUM(gross_captured_eur) AS gross_captured_eur,
        SUM(amount_refunded_eur) AS amount_refunded_eur,
        AVG(tenant_refund_rate_pct) AS avg_refund_rate_pct
      FROM (
        SELECT
          COALESCE(t.industry, 'Unknown') AS vertical,
          t.id AS tenant_id,
          COUNT(*) AS payment_count,
          SUM(CAST(p.amount / er.rate AS NUMERIC(18,6))) AS gross_captured_eur,
          SUM(CAST(p.amount_refunded / er.rate AS NUMERIC(18,6))) AS amount_refunded_eur,
          CASE WHEN SUM(CAST(p.amount / er.rate AS NUMERIC(18,6))) = 0 THEN 0
               ELSE SUM(CAST(p.amount_refunded / er.rate AS NUMERIC(18,6))) / SUM(CAST(p.amount / er.rate AS NUMERIC(18,6))) * 100
          END AS tenant_refund_rate_pct
        FROM payments p
        JOIN tenants t ON t.id = p.tenant_id
        JOIN exchange_rates er
          ON er.base_code = 'EUR'
         AND er.quote_code = p.currency_code
         AND er.as_of = CAST(p.captured_at AS DATE)
        WHERE p.captured_at >= DATE '2025-01-01'
          AND p.captured_at < DATE '2026-01-01'
          AND p.status IN ('captured','refunded','partially_refunded')
        GROUP BY COALESCE(t.industry, 'Unknown'), t.id
      ) AS tenant_agg
      GROUP BY vertical
    ) AS vertical_agg
    ORDER BY refund_rate_pct DESC NULLS FIRST, vertical ASC NULLS LAST
    10 ms100 ms1 s10 sHyper76.7 msDuckDB1.17 sGizmoSQL994 msclickhouse_brew141 msFirebolterrorDataFusion121 msClickHouseerror
    • Firebolt{ "errors": [ { "description": "Line 22, Column 25: Operations between decimals with different precision and scale is not supported at the moment. Explicitly cast one of the decimals to other decimal precision and scale." } ], "query": { "query_id": "5e0cfdfb-f230-45d9-9806-d323abe3d614", "query_lab
    • ClickHouseCode: 69. DB::Exception: Decimal result's scale is less than argument's one: while executing function divide on arguments __table3.amount Decimal(14, 4) Decimal64(size = 0), __table5.rate Decimal(12, 6) Decimal64(size = 0). (ARGUMENT_OUT_OF_BOUND)
  7. Everything, as recorded

    The agents' own records, as the role play wrote them; only the personal data of the person who ran it is redacted

    Viktor's whole session, 7 turns
    • The request, as Viktor received it
      Report request from Kofi Mensah (analyst for the Chief Financial Officer):
      
      Title: Refund Rate by Merchant Vertical, 2025
      Question: Do refund rates differ meaningfully across merchant verticals, and which verticals look like outliers?
      Grain: one tenant industry vertical
      Columns:
      - vertical
      - merchant count
      - payment count
      - gross captured amount (EUR)
      - amount refunded (EUR)
      - refund rate (%) = refunded / captured
      - avg refund rate per merchant (%) (simple average across merchants in the vertical, for comparison to the volume-weighted rate)
      Details: Source: payments joined to tenants (tenants.industry) for the vertical grouping. Filter to payments with captured_at in calendar year 2025 and status in a captured/refunded/partially_refunded state (i.e., payments that were actually captured, regardless of later refund). Convert payments.amount and payments.amount_refunded to EUR via exchange_rates (base_code='EUR', quote_code=payments.currency_code, as_of=date(captured_at)). Compute the volume-weighted refund rate per vertical (sum(amount_refunded)/sum(amount)) and also a simple average of each merchant's own refund rate within the vertical, to see whether a few large merchants are skewing the vertical number. Order by volume-weighted refund rate descending, tie-break vertical name ascending.
      
      Check your query with the warehouse's dry_run tool, then hand it in with submit.
    • Viktor calls dry_run
      SELECT
        vertical,
        merchant_count,
        payment_count,
        gross_captured_eur,
        amount_refunded_eur,
        ROUND(CASE WHEN gross_captured_eur = 0 THEN NULL ELSE amount_refunded_eur / gross_captured_eur * 100 END, 2) AS refund_rate_pct,
        ROUND(avg_refund_rate_pct, 2) AS avg_refund_rate_per_merchant_pct
      FROM (
        SELECT
          vertical,
          COUNT(*) AS merchant_count,
          SUM(payment_count) AS payment_count,
          SUM(gross_captured_eur) AS gross_captured_eur,
          SUM(amount_refunded_eur) AS amount_refunded_eur,
          AVG(tenant_refund_rate_pct) AS avg_refund_rate_pct
        FROM (
          SELECT
            COALESCE(t.industry, 'Unknown') AS vertical,
            t.id AS tenant_id,
            COUNT(*) AS payment_count,
            SUM(p.amount / er.rate) AS gross_captured_eur,
            SUM(p.amount_refunded / er.rate) AS amount_refunded_eur,
            CASE WHEN SUM(p.amount / er.rate) = 0 THEN 0
                 ELSE SUM(p.amount_refunded / er.rate) / SUM(p.amount / er.rate) * 100
            END AS tenant_refund_rate_pct
          FROM payments p
          JOIN tenants t ON t.id = p.tenant_id
          JOIN exchange_rates er
            ON er.base_code = 'EUR'
           AND er.quote_code = p.currency_code
           AND er.as_of = DATE(p.captured_at)
          WHERE p.captured_at >= DATE '2025-01-01'
            AND p.captured_at < DATE '2026-01-01'
            AND p.status IN ('captured','refunded','partially_refunded')
          GROUP BY COALESCE(t.industry, 'Unknown'), t.id
        ) AS tenant_agg
        GROUP BY vertical
      ) AS vertical_agg
      ORDER BY refund_rate_pct DESC, vertical ASC
    • The warehouse answers
      STATUS: COMPILES (dry run on the empty schema, no data read)
      COLUMNS: vertical, merchant_count, payment_count, gross_captured_eur, amount_refunded_eur, refund_rate_pct, avg_refund_rate_per_merchant_pct
    • ViktorThe query compiles and produces the requested columns at one row per vertical.
    • Viktor calls submit
      SELECT
        vertical,
        merchant_count,
        payment_count,
        gross_captured_eur,
        amount_refunded_eur,
        ROUND(CASE WHEN gross_captured_eur = 0 THEN NULL ELSE amount_refunded_eur / gross_captured_eur * 100 END, 2) AS refund_rate_pct,
        ROUND(avg_refund_rate_pct, 2) AS avg_refund_rate_per_merchant_pct
      FROM (
        SELECT
          vertical,
          COUNT(*) AS merchant_count,
          SUM(payment_count) AS payment_count,
          SUM(gross_captured_eur) AS gross_captured_eur,
          SUM(amount_refunded_eur) AS amount_refunded_eur,
          AVG(tenant_refund_rate_pct) AS avg_refund_rate_pct
        FROM (
          SELECT
            COALESCE(t.industry, 'Unknown') AS vertical,
            t.id AS tenant_id,
            COUNT(*) AS payment_count,
            SUM(p.amount / er.rate) AS gross_captured_eur,
            SUM(p.amount_refunded / er.rate) AS amount_refunded_eur,
            CASE WHEN SUM(p.amount / er.rate) = 0 THEN 0
                 ELSE SUM(p.amount_refunded / er.rate) / SUM(p.amount / er.rate) * 100
            END AS tenant_refund_rate_pct
          FROM payments p
          JOIN tenants t ON t.id = p.tenant_id
          JOIN exchange_rates er
            ON er.base_code = 'EUR'
           AND er.quote_code = p.currency_code
           AND er.as_of = DATE(p.captured_at)
          WHERE p.captured_at >= DATE '2025-01-01'
            AND p.captured_at < DATE '2026-01-01'
            AND p.status IN ('captured','refunded','partially_refunded')
          GROUP BY COALESCE(t.industry, 'Unknown'), t.id
        ) AS tenant_agg
        GROUP BY vertical
      ) AS vertical_agg
      ORDER BY refund_rate_pct DESC, vertical ASC
    • The warehouse answers
      Submitted to tonight's batch. Your work on this request is done.
    • ViktorSubmitted. Summary of what the query does: - **Grain:** one row per tenant industry vertical (from `tenants.industry`, with nulls bucketed as "Unknown"). - **Filter:** payments with `captured_at` in 2025 and status in `captured`, `refunded`, or `partially_refunded`. - **FX:** each payment's `amount` and `amount_refunded` are converted to EUR using that day's `EUR→currency_code` rate from `exchange_rates`. - **Metrics:** merchant count, payment count, gross captured EUR, refunded EUR, the volume-weighted refund rate (sum refunded / sum captured), and the simple average of each merchant's own EUR-based refund rate within the vertical — letting the CFO spot verticals where a few large merchants skew the headline rate. - Ordered by weighted refund rate descending, tie-broken by vertical name. Noted in the submission: industry is free text rather than a fixed vertical taxonomy, so a few tenants may fall under "Unknown" if `industry` is null.