DB Olympics

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

2025 Payout Reconciliation by Currency (Local vs EUR-Converted)

“Is the 2025 monthly fee-revenue trend understating totals because some currencies/dates fail to match an exchange rate, causing payouts to be dropped from the EUR-converted total?”

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 SQLFelixJunior engineer who nests subqueries in FROM and WHERE (IN, EXISTS, correlated subqueries) rather than 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 1 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.

    2025 Payout Reconciliation by Currency (Local vs EUR-Converted)
    Question
    Is the 2025 monthly fee-revenue trend understating totals because some currencies/dates fail to match an exchange rate, causing payouts to be dropped from the EUR-converted total?
    One row per
    one payout currency_code, for 2025
    Columns
    • currency_code
    • payout_count
    • gross_volume_local_sum (sum of gross_amount in that currency, unconverted)
    • gross_volume_eur (converted)
    • fee_revenue_eur (converted)
    • payout_count_with_matched_fx_rate
    • payout_count_missing_fx_rate
    • avg_eur_rate_used
  3. Felix 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
      p.currency_code,
      COUNT(*) AS payout_count,
      SUM(p.gross_amount) AS gross_volume_local_sum,
      SUM(CASE WHEN p.currency_code = 'EUR' THEN p.gross_amount
               WHEN er.rate IS NOT NULL THEN p.gross_amount / er.rate
               ELSE 0 END) AS gross_volume_eur,
      SUM(CASE WHEN p.currency_code = 'EUR' THEN p.fee_amount
               WHEN er.rate IS NOT NULL THEN p.fee_amount / er.rate
               ELSE 0 END) AS fee_revenue_eur,
      COUNT(CASE WHEN p.currency_code = 'EUR' OR er.rate IS NOT NULL THEN 1 END) AS payout_count_with_matched_fx_rate,
      COUNT(CASE WHEN p.currency_code <> 'EUR' AND er.rate IS NULL THEN 1 END) AS payout_count_missing_fx_rate,
      ROUND(AVG(CASE WHEN p.currency_code = 'EUR' THEN 1.0
                     WHEN er.rate IS NOT NULL THEN er.rate END), 6) AS avg_eur_rate_used
    FROM payouts p
    LEFT JOIN exchange_rates er
      ON er.base_code = 'EUR'
      AND er.quote_code = p.currency_code
      AND er.as_of = CAST(p.paid_at AS date)
    WHERE p.status IN ('paid','completed')
      AND p.paid_at >= DATE '2025-01-01'
      AND p.paid_at < DATE '2026-01-01'
    GROUP BY p.currency_code
    ORDER BY payout_count DESC, p.currency_code ASC

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

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

    “Submitted. The report gives one row per currency for 2025 paid/completed payouts, showing local vs. EUR-converted volume and fee revenue, plus matched/missing FX-rate counts and the average rate used — so Kofi can see exactly which currencies (if any) are losing payouts from the EUR total due to missing exchange rate rows.”

  4. The report comes back overnight

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

    currency_code | payout_count | gross_volume_local_sum |   gross_volume_eur |    fee_revenue_eur | payout_count_with_matched_fx_rate | payout_count_missing_fx_rate | avg_eur_rate_used
    --------------+--------------+------------------------+--------------------+--------------------+-----------------------------------+------------------------------+------------------
    EUR           |         5169 |           1981538.9100 | 1981538.9099999547 | 40397.349999999984 |                              5169 |                            0 |               1.0
    GBP           |         3165 |            576156.8200 |  669712.4031839605 | 13707.647149318931 |                              3165 |                            0 |          0.860772
    USD           |         1802 |           1175910.6900 |  1021107.726817953 |  32522.97257591372 |                              1802 |                            0 |          1.150943
    SEK           |          567 |            874215.7600 |  79451.65101996991 | 1292.3859172680086 |                               567 |                            0 |         10.987477
    DKK           |          345 |            595621.2100 |  79924.92689831926 |  1413.800634631733 |                               345 |                            0 |          7.451982
    NOK           |          238 |           1617567.2600 | 141252.37557324008 |  4249.040729682132 |                               238 |                            0 |         11.500274
    (6 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 q016

    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) (2×)
    • made explicita selected t.col gets AS col, its standard name made explicit
    • 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  p.currency_code,  p.currency_code AS currency_code,  COUNT(*) AS payout_count,  SUM(p.gross_amount) AS gross_volume_local_sum,  SUM(CASE WHEN p.currency_code = 'EUR' THEN p.gross_amount           WHEN er.rate IS NOT NULL THEN p.gross_amount / er.rate           WHEN er.rate IS NOT NULL THEN CAST(p.gross_amount / er.rate AS NUMERIC(18,6))           ELSE 0 END) AS gross_volume_eur,  SUM(CASE WHEN p.currency_code = 'EUR' THEN p.fee_amount           WHEN er.rate IS NOT NULL THEN p.fee_amount / er.rate           WHEN er.rate IS NOT NULL THEN CAST(p.fee_amount / er.rate AS NUMERIC(18,6))           ELSE 0 END) AS fee_revenue_eur,  COUNT(CASE WHEN p.currency_code = 'EUR' OR er.rate IS NOT NULL THEN 1 END) AS payout_count_with_matched_fx_rate,  COUNT(CASE WHEN p.currency_code <> 'EUR' AND er.rate IS NULL THEN 1 END) AS payout_count_missing_fx_rate,  ROUND(AVG(CASE WHEN p.currency_code = 'EUR' THEN 1.0                 WHEN er.rate IS NOT NULL THEN er.rate END), 6) AS avg_eur_rate_used  AVG(CASE WHEN p.currency_code = 'EUR' THEN 1.0           WHEN er.rate IS NOT NULL THEN er.rate END) AS avg_eur_rate_usedFROM payouts pLEFT JOIN exchange_rates er  ON er.base_code = 'EUR'  AND er.quote_code = p.currency_code  AND er.as_of = CAST(p.paid_at AS date)WHERE p.status IN ('paid','completed')  AND p.paid_at >= DATE '2025-01-01'  AND p.paid_at < DATE '2026-01-01'GROUP BY p.currency_codeORDER BY payout_count DESC, p.currency_code ASCORDER BY payout_count DESC NULLS FIRST, p.currency_code ASC NULLS LAST
    SELECT
      p.currency_code AS currency_code,
      COUNT(*) AS payout_count,
      SUM(p.gross_amount) AS gross_volume_local_sum,
      SUM(CASE WHEN p.currency_code = 'EUR' THEN p.gross_amount
               WHEN er.rate IS NOT NULL THEN CAST(p.gross_amount / er.rate AS NUMERIC(18,6))
               ELSE 0 END) AS gross_volume_eur,
      SUM(CASE WHEN p.currency_code = 'EUR' THEN p.fee_amount
               WHEN er.rate IS NOT NULL THEN CAST(p.fee_amount / er.rate AS NUMERIC(18,6))
               ELSE 0 END) AS fee_revenue_eur,
      COUNT(CASE WHEN p.currency_code = 'EUR' OR er.rate IS NOT NULL THEN 1 END) AS payout_count_with_matched_fx_rate,
      COUNT(CASE WHEN p.currency_code <> 'EUR' AND er.rate IS NULL THEN 1 END) AS payout_count_missing_fx_rate,
      AVG(CASE WHEN p.currency_code = 'EUR' THEN 1.0
               WHEN er.rate IS NOT NULL THEN er.rate END) AS avg_eur_rate_used
    FROM payouts p
    LEFT JOIN exchange_rates er
      ON er.base_code = 'EUR'
      AND er.quote_code = p.currency_code
      AND er.as_of = CAST(p.paid_at AS date)
    WHERE p.status IN ('paid','completed')
      AND p.paid_at >= DATE '2025-01-01'
      AND p.paid_at < DATE '2026-01-01'
    GROUP BY p.currency_code
    ORDER BY payout_count DESC NULLS FIRST, p.currency_code ASC NULLS LAST
    1 ms10 ms100 msHyper10.2 msDuckDB7.0 msGizmoSQL10.9 msclickhouse_brew41.2 msFirebolterrorDataFusion40.0 msClickHouseerror
    • Firebolt{ "errors": [ { "description": "Line 6, Column 62: 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": "11111b8b-6e9e-4aad-96dc-6c07acff10db", "query_labe
    • ClickHouseCode: 69. DB::Exception: Decimal result's scale is less than argument's one: while executing function divide on arguments __table1.gross_amount Decimal(14, 4) Decimal64(size = 0), __table2.rate Nullable(Decimal(12, 6)) Nullable(size = 0, Decimal64(size = 0), UInt8(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

    Felix's whole session, 7 turns
    • The request, as Felix received it
      Report request from Kofi Mensah (analyst for the Chief Financial Officer):
      
      Title: 2025 Payout Reconciliation by Currency (Local vs EUR-Converted)
      Question: Is the 2025 monthly fee-revenue trend understating totals because some currencies/dates fail to match an exchange rate, causing payouts to be dropped from the EUR-converted total?
      Grain: one payout currency_code, for 2025
      Columns:
      - currency_code
      - payout_count
      - gross_volume_local_sum (sum of gross_amount in that currency, unconverted)
      - gross_volume_eur (converted)
      - fee_revenue_eur (converted)
      - payout_count_with_matched_fx_rate
      - payout_count_missing_fx_rate
      - avg_eur_rate_used
      Details: Source: payouts, filtered to status IN ('paid','completed') and paid_at in calendar year 2025. For each payout, LEFT JOIN exchange_rates on base_code='EUR', quote_code=payouts.currency_code, as_of=date(paid_at) (treat currency_code='EUR' as rate=1 with no join needed). Do NOT inner-join or filter out unmatched rows — keep every payout, and count how many per currency found a matching exchange_rate row vs how many did not (gross_volume_eur/fee_revenue_eur should reflect only the matched rows so the gap vs Night 1's Report 1 total becomes visible per currency). Group by currency_code. Order by payout_count descending, tie-break currency_code ascending.
      
      Check your query with the warehouse's dry_run tool, then hand it in with submit.
    • Felix calls dry_run
      SELECT
        p.currency_code,
        COUNT(*) AS payout_count,
        SUM(p.gross_amount) AS gross_volume_local_sum,
        SUM(CASE WHEN p.currency_code = 'EUR' THEN p.gross_amount
                 WHEN er.rate IS NOT NULL THEN p.gross_amount / er.rate
                 ELSE 0 END) AS gross_volume_eur,
        SUM(CASE WHEN p.currency_code = 'EUR' THEN p.fee_amount
                 WHEN er.rate IS NOT NULL THEN p.fee_amount / er.rate
                 ELSE 0 END) AS fee_revenue_eur,
        COUNT(CASE WHEN p.currency_code = 'EUR' OR er.rate IS NOT NULL THEN 1 END) AS payout_count_with_matched_fx_rate,
        COUNT(CASE WHEN p.currency_code <> 'EUR' AND er.rate IS NULL THEN 1 END) AS payout_count_missing_fx_rate,
        ROUND(AVG(CASE WHEN p.currency_code = 'EUR' THEN 1.0
                       WHEN er.rate IS NOT NULL THEN er.rate END), 6) AS avg_eur_rate_used
      FROM payouts p
      LEFT JOIN exchange_rates er
        ON er.base_code = 'EUR'
        AND er.quote_code = p.currency_code
        AND er.as_of = CAST(p.paid_at AS date)
      WHERE p.status IN ('paid','completed')
        AND p.paid_at >= DATE '2025-01-01'
        AND p.paid_at < DATE '2026-01-01'
      GROUP BY p.currency_code
      ORDER BY payout_count DESC, p.currency_code ASC
    • The warehouse answers
      STATUS: COMPILES (dry run on the empty schema, no data read)
      COLUMNS: currency_code, payout_count, gross_volume_local_sum, gross_volume_eur, fee_revenue_eur, payout_count_with_matched_fx_rate, payout_count_missing_fx_rate, avg_eur_rate_used
    • FelixQuery compiles and returns the expected columns. Submitting now.
    • Felix calls submit
      SELECT
        p.currency_code,
        COUNT(*) AS payout_count,
        SUM(p.gross_amount) AS gross_volume_local_sum,
        SUM(CASE WHEN p.currency_code = 'EUR' THEN p.gross_amount
                 WHEN er.rate IS NOT NULL THEN p.gross_amount / er.rate
                 ELSE 0 END) AS gross_volume_eur,
        SUM(CASE WHEN p.currency_code = 'EUR' THEN p.fee_amount
                 WHEN er.rate IS NOT NULL THEN p.fee_amount / er.rate
                 ELSE 0 END) AS fee_revenue_eur,
        COUNT(CASE WHEN p.currency_code = 'EUR' OR er.rate IS NOT NULL THEN 1 END) AS payout_count_with_matched_fx_rate,
        COUNT(CASE WHEN p.currency_code <> 'EUR' AND er.rate IS NULL THEN 1 END) AS payout_count_missing_fx_rate,
        ROUND(AVG(CASE WHEN p.currency_code = 'EUR' THEN 1.0
                       WHEN er.rate IS NOT NULL THEN er.rate END), 6) AS avg_eur_rate_used
      FROM payouts p
      LEFT JOIN exchange_rates er
        ON er.base_code = 'EUR'
        AND er.quote_code = p.currency_code
        AND er.as_of = CAST(p.paid_at AS date)
      WHERE p.status IN ('paid','completed')
        AND p.paid_at >= DATE '2025-01-01'
        AND p.paid_at < DATE '2026-01-01'
      GROUP BY p.currency_code
      ORDER BY payout_count DESC, p.currency_code ASC
    • The warehouse answers
      Submitted to tonight's batch. Your work on this request is done.
    • FelixSubmitted. The report gives one row per currency for 2025 paid/completed payouts, showing local vs. EUR-converted volume and fee revenue, plus matched/missing FX-rate counts and the average rate used — so Kofi can see exactly which currencies (if any) are losing payouts from the EUR total due to missing exchange rate rows.