DB Olympics

Queries · q022 · Chief Financial Officer · week 1, night 4

Refund Rate by Vertical and Quarter, 2025

“Which vertical(s) outside Apparel & Footwear drove the rise in company-wide refund rate through 2025, especially the Q2-Q3 spike?”

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 4, request 1 of 3

    Report 1 confirms the fix: the corrected monthly series now ties exactly to the independently-verified 2025 annual totals (€3,972,987.99 gross / €93,583.20 fee / 2.4% blended fee rate), and payout counts roughly doubled vs. the original buggy report — consistent with it having silently dropped close to half of all payouts for a reason we never fully root-caused, but which is now moot since the corrected figures cross-foot cleanly. #1 is now safe to put in front of Tobias, with the one caveat I'll carry into the memo: nothing in this warehouse captures Tidewick's own platform-plan billing of merchants (the plans/subscriptions/invoices tables here model each merchant's billing of their customers, not Tidewick's billing of the merchant) — so I can only report the processing-fee half of the revenue split, not the plan-fee half.

    On refunds, I did the arithmetic behind Report 2 and 3 by hand: subtracting Apparel's numbers from the company totals shows the non-Apparel refund rate was stable at ~2-3% through 2024, but spiked to 4.9% in 2025-Q2 and 9.1% in 2025-Q3 before dropping back to 4.2% in Q4 — a 3-4x jump that Apparel's own flat/improving trend doesn't explain. Apparel itself looks structural (19-26% every quarter, actually lowest in the latest quarter), so that part of #3 is answered and not worsening. But this non-Apparel spike is new information worth chasing on this last night before the memo, since it could be a specific merchant issue (e.g. a product recall or quality problem) rather than normal seasonal variation. I'm spending two slots to localize it (by vertical, then by merchant) and a third to cross-check the payments-based refund figures against the independent order_returns table, given how costly the earlier monthly-revenue bug was — I don't want to hand Tobias a refund narrative built on a single, unverified source.

    Refund Rate by Vertical and Quarter, 2025
    Question
    Which vertical(s) outside Apparel & Footwear drove the rise in company-wide refund rate through 2025, especially the Q2-Q3 spike?
    One row per
    one tenant industry vertical and one calendar quarter, 2025 only
    Columns
    • vertical
    • quarter
    • merchant_count
    • payment_count
    • gross_captured_eur
    • amount_refunded_eur
    • refund_rate_pct
  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
      COALESCE(t.industry, 'Unknown') AS vertical,
      'Q' || cd.quarter_num AS quarter,
      COUNT(DISTINCT t.id) AS merchant_count,
      COUNT(*) AS payment_count,
      SUM(p.amount / er.rate) AS gross_captured_eur,
      SUM(p.amount_refunded / er.rate) AS amount_refunded_eur,
      ROUND(100.0 * SUM(p.amount_refunded / er.rate) / NULLIF(SUM(p.amount / er.rate), 0), 2) AS refund_rate_pct
    FROM payments p
    JOIN tenants t ON t.id = p.tenant_id
    JOIN calendar_days cd ON cd.calendar_date = CAST(p.captured_at AS date)
    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.status IN ('captured', 'refunded', 'partially_refunded')
      AND p.captured_at >= DATE '2025-01-01'
      AND p.captured_at < DATE '2026-01-01'
    GROUP BY 1, 2
    ORDER BY vertical ASC, quarter ASC

    “Query compiles and returns the expected columns. Submitting.”

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

    “Submitted. The report groups payments by tenants.industry (nulls as "Unknown") and calendar quarter of captured_at for 2025, converting local amounts to EUR via the daily EUR-base exchange rate matched to the capture date, and computes refund rate as refunded/gross. This should let Kofi see which non-apparel verticals contributed most to the Q2–Q3 refund-rate spike.”

  4. The report comes back overnight

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

    vertical                  | quarter | merchant_count | payment_count | gross_captured_eur | amount_refunded_eur | refund_rate_pct
    --------------------------+---------+----------------+---------------+--------------------+---------------------+----------------
    Apparel & Footwear        | Q1      |             11 |          1150 | 216621.44091669368 |  44831.176879493345 |            20.7
    Apparel & Footwear        | Q2      |             11 |          1169 |  198881.2193894893 |   44578.07783795187 |           22.41
    Apparel & Footwear        | Q3      |             11 |          1212 | 192047.59636464564 |   42818.10689885081 |            22.3
    Apparel & Footwear        | Q4      |             11 |          2298 |  470544.8364552379 |   91672.61863472672 |           19.48
    Beauty                    | Q1      |              6 |           280 |  25818.54401057241 |  1085.7447446305723 |            4.21
    Beauty                    | Q2      |              7 |           228 |  25725.83935097318 |     619.50811391392 |            2.41
    Beauty                    | Q3      |              8 |           284 | 29641.158365632327 |  3150.3888966371146 |           10.63
    Beauty                    | Q4      |              7 |           459 | 44113.676007047616 |   2923.057264107972 |            6.63
    Books & Stationery        | Q1      |              3 |            27 | 1355.1836199234158 |   58.63298735958467 |            4.33
    Books & Stationery        | Q2      |              3 |            26 | 1434.8048158637575 |   75.81729784451257 |            5.28
    Books & Stationery        | Q3      |              4 |            17 |  817.6496332968031 |  11.501861056716475 |            1.41
    Books & Stationery        | Q4      |              4 |            45 |  2023.393101739535 |    40.8426243531702 |            2.02
    Consumer Electronics      | Q1      |              2 |             7 | 8090.2199209759565 |   57.04991401762959 |            0.71
    Consumer Electronics      | Q2      |              2 |             8 | 1029.9743631223505 |                 0.0 |             0.0
    Consumer Electronics      | Q3      |              2 |             9 | 11249.432963260206 |                 0.0 |             0.0
    Consumer Electronics      | Q4      |              3 |            21 | 12334.929902163894 |   4479.206917994399 |           36.31
    Food & Coffee             | Q1      |              3 |           330 | 18354.785502809722 |  62.383472348541886 |            0.34
    Food & Coffee             | Q2      |              3 |           355 |  18249.53217315367 |   269.9056568395979 |            1.48
    Food & Coffee             | Q3      |              3 |           363 | 15662.413332444743 |  180.75184916705518 |            1.15
    Food & Coffee             | Q4      |              5 |           482 | 24928.853024262517 |   368.7037831923822 |            1.48
    Home & Living             | Q1      |              9 |           167 | 27894.272465097492 |   2433.823480440758 |            8.73
    Home & Living             | Q2      |              9 |           154 | 23751.388145572895 |  2898.7327936654633 |            12.2
    Home & Living             | Q3      |              9 |           158 | 24198.566575605862 |  1015.1305518444232 |             4.2
    Home & Living             | Q4      |              9 |           347 |  55031.91113703543 |   3289.176261514632 |            5.98
    Industrial Supplies (B2B) | Q1      |              1 |            30 |  67009.71065267481 |                 0.0 |             0.0
    Industrial Supplies (B2B) | Q2      |              1 |            42 | 130168.99482296039 |   667.9769975297136 |            0.51
    Industrial Supplies (B2B) | Q3      |              1 |            35 |  86880.28106375752 |                 0.0 |             0.0
    Industrial Supplies (B2B) | Q4      |              1 |            47 | 102428.63827279814 |   879.2332472909086 |            0.86
    Jewelry & Accessories     | Q1      |              1 |             3 |  708.3609146047186 |                 0.0 |             0.0
    Jewelry & Accessories     | Q2      |              1 |             4 |  789.8097417728766 |                 0.0 |             0.0
    Jewelry & Accessories     | Q3      |              1 |             1 | 474.09138186513644 |                 0.0 |             0.0
    Jewelry & Accessories     | Q4      |              1 |             6 | 1029.6122237688733 |                 0.0 |             0.0
    Office Supplies (B2B)     | Q1      |              2 |            61 | 269281.99522658327 |   538.7285520011415 |             0.2
    Office Supplies (B2B)     | Q2      |              2 |            43 | 139804.73630781923 |   6759.114464105353 |            4.83
    Office Supplies (B2B)     | Q3      |              2 |            60 | 156766.32653283176 |  23267.743667366227 |           14.84
    Office Supplies (B2B)     | Q4      |              2 |            75 |  141893.6413073626 |                 0.0 |             0.0
    Pet Supplies              | Q1      |              5 |           241 | 10973.548507433949 |  137.34976084931193 |            1.25
    Pet Supplies              | Q2      |              6 |           252 | 12697.327961349287 |   68.52328917673564 |            0.54
    Pet Supplies              | Q3      |              7 |           264 | 13981.660023570754 |  248.39446202754175 |            1.78
    Pet Supplies              | Q4      |              7 |           338 |  17623.34787040026 |   423.2845831840298 |             2.4
    Sports & Outdoor          | Q1      |              4 |           133 | 48481.881703017454 |   4748.589361778662 |            9.79
    Sports & Outdoor          | Q2      |              4 |           136 |  53277.39476609937 |     8671.6034168609 |           16.28
    Sports & Outdoor          | Q3      |              4 |           122 |  50531.48401879147 |    7597.09439253762 |           15.03
    Sports & Outdoor          | Q4      |              5 |           151 | 56351.904767109656 |    6598.52473055408 |           11.71
    (44 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:

    It builds on:

  6. It becomes benchmark query q022

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

    • numericsdocs/NUMERICS.md: rounding removed; results keep full precision
    • numericsdocs/NUMERICS.md: x / exchange rate cast to NUMERIC(18,6) (4×)
    • 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  COALESCE(t.industry, 'Unknown') AS vertical,  'Q' || cd.quarter_num AS quarter,  COUNT(DISTINCT t.id) AS merchant_count,  COUNT(*) AS payment_count,  SUM(p.amount / er.rate) AS gross_captured_eur,  SUM(p.amount_refunded / er.rate) AS amount_refunded_eur,  ROUND(100.0 * SUM(p.amount_refunded / er.rate) / NULLIF(SUM(p.amount / er.rate), 0), 2) AS refund_rate_pct  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,  100.0 * SUM(CAST(p.amount_refunded / er.rate AS NUMERIC(18,6))) / NULLIF(SUM(CAST(p.amount / er.rate AS NUMERIC(18,6))), 0) AS refund_rate_pctFROM payments pJOIN tenants t ON t.id = p.tenant_idJOIN calendar_days cd ON cd.calendar_date = CAST(p.captured_at AS date)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.status IN ('captured', 'refunded', 'partially_refunded')  AND p.captured_at >= DATE '2025-01-01'  AND p.captured_at < DATE '2026-01-01'GROUP BY 1, 2ORDER BY vertical ASC, quarter ASCORDER BY vertical ASC NULLS LAST, quarter ASC NULLS LAST
    SELECT
      COALESCE(t.industry, 'Unknown') AS vertical,
      'Q' || cd.quarter_num AS quarter,
      COUNT(DISTINCT t.id) AS merchant_count,
      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,
      100.0 * SUM(CAST(p.amount_refunded / er.rate AS NUMERIC(18,6))) / NULLIF(SUM(CAST(p.amount / er.rate AS NUMERIC(18,6))), 0) AS refund_rate_pct
    FROM payments p
    JOIN tenants t ON t.id = p.tenant_id
    JOIN calendar_days cd ON cd.calendar_date = CAST(p.captured_at AS date)
    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.status IN ('captured', 'refunded', 'partially_refunded')
      AND p.captured_at >= DATE '2025-01-01'
      AND p.captured_at < DATE '2026-01-01'
    GROUP BY 1, 2
    ORDER BY vertical ASC NULLS LAST, quarter ASC NULLS LAST
    10 ms100 ms1 s10 sHyper79.0 msDuckDB1.95 sGizmoSQL1.82 sclickhouse_brew189 msFirebolterrorDataFusion141 msClickHouseerror
    • Firebolt{ "errors": [ { "description": "Line 6, Column 21: 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": "a8b6ed50-c79c-435d-b6ae-56df7d3faba8", "query_labe
    • ClickHouseCode: 69. DB::Exception: Decimal result's scale is less than argument's one: while executing function divide on arguments __table1.amount Decimal(14, 4) Decimal64(size = 0), __table4.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 Vertical and Quarter, 2025
      Question: Which vertical(s) outside Apparel & Footwear drove the rise in company-wide refund rate through 2025, especially the Q2-Q3 spike?
      Grain: one tenant industry vertical and one calendar quarter, 2025 only
      Columns:
      - vertical
      - quarter
      - merchant_count
      - payment_count
      - gross_captured_eur
      - amount_refunded_eur
      - refund_rate_pct
      Details: Source: payments joined to tenants (tenants.industry), filtered to status reflecting captured/refunded/partially_refunded with captured_at in calendar year 2025. Convert amount and amount_refunded to EUR via exchange_rates (base_code='EUR', quote_code=payments.currency_code, as_of=date(captured_at)), dividing local amount by the matched rate, consistent with the validated method used in prior reports this week. Group by tenants.industry (null industry as 'Unknown') and calendar quarter of captured_at. Order by vertical name ascending, then quarter ascending (expect up to ~44 rows for ~11 verticals x 4 quarters).
      
      Check your query with the warehouse's dry_run tool, then hand it in with submit.
    • Viktor calls dry_run
      SELECT
        COALESCE(t.industry, 'Unknown') AS vertical,
        'Q' || cd.quarter_num AS quarter,
        COUNT(DISTINCT t.id) AS merchant_count,
        COUNT(*) AS payment_count,
        SUM(p.amount / er.rate) AS gross_captured_eur,
        SUM(p.amount_refunded / er.rate) AS amount_refunded_eur,
        ROUND(100.0 * SUM(p.amount_refunded / er.rate) / NULLIF(SUM(p.amount / er.rate), 0), 2) AS refund_rate_pct
      FROM payments p
      JOIN tenants t ON t.id = p.tenant_id
      JOIN calendar_days cd ON cd.calendar_date = CAST(p.captured_at AS date)
      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.status IN ('captured', 'refunded', 'partially_refunded')
        AND p.captured_at >= DATE '2025-01-01'
        AND p.captured_at < DATE '2026-01-01'
      GROUP BY 1, 2
      ORDER BY vertical ASC, quarter ASC
    • The warehouse answers
      STATUS: COMPILES (dry run on the empty schema, no data read)
      COLUMNS: vertical, quarter, merchant_count, payment_count, gross_captured_eur, amount_refunded_eur, refund_rate_pct
    • ViktorQuery compiles and returns the expected columns. Submitting.
    • Viktor calls submit
      SELECT
        COALESCE(t.industry, 'Unknown') AS vertical,
        'Q' || cd.quarter_num AS quarter,
        COUNT(DISTINCT t.id) AS merchant_count,
        COUNT(*) AS payment_count,
        SUM(p.amount / er.rate) AS gross_captured_eur,
        SUM(p.amount_refunded / er.rate) AS amount_refunded_eur,
        ROUND(100.0 * SUM(p.amount_refunded / er.rate) / NULLIF(SUM(p.amount / er.rate), 0), 2) AS refund_rate_pct
      FROM payments p
      JOIN tenants t ON t.id = p.tenant_id
      JOIN calendar_days cd ON cd.calendar_date = CAST(p.captured_at AS date)
      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.status IN ('captured', 'refunded', 'partially_refunded')
        AND p.captured_at >= DATE '2025-01-01'
        AND p.captured_at < DATE '2026-01-01'
      GROUP BY 1, 2
      ORDER BY vertical ASC, quarter ASC
    • The warehouse answers
      Submitted to tonight's batch. Your work on this request is done.
    • ViktorSubmitted. The report groups payments by `tenants.industry` (nulls as "Unknown") and calendar quarter of `captured_at` for 2025, converting local amounts to EUR via the daily EUR-base exchange rate matched to the capture date, and computes refund rate as refunded/gross. This should let Kofi see which non-apparel verticals contributed most to the Q2–Q3 refund-rate spike.