DB Olympics

Queries · q013 · Chief Financial Officer · week 1, night 1

Tidewick Processing Fee Revenue by Month, 2025

“How much processing-fee revenue did Tidewick earn each month in 2025, and how is it trending?”

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 SQLBeaWrites SQL the way the company's BI tool generates it: verbose fully qualified aliases (t0, t1, ...), a subquery per measure joined back on the grain keys, COALESCE everywhere.

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

    Tobias ranked #1 (2025 revenue mix) and #2 (merchant concentration) as this week's priorities, so I'm spending all 3 slots there tonight.

    Scoping note on #1: I looked for a table that records merchants paying Tidewick its own monthly platform-plan fee. The plans/subscriptions/invoices tables are tenant-scoped and model each merchant's own subscription business toward their end customers (invoice.order_id/subscription_id point at the merchant's own orders/users) — they are not Tidewick billing the merchant. The only piece of Tidewick's own revenue that is actually observable in this warehouse is the processing fee Tidewick keeps on settled payments, captured explicitly in payouts.fee_amount per the handbook. So tonight I'm only requesting the processing-fee side of #1, trended monthly, and I'll flag to Tobias in the memo that platform-plan fee revenue isn't in this warehouse (likely lives in Tidewick's own internal billing/finance system) before promising a full split.

    For #2, I expect a small number of merchants to dominate — my hypothesis is the top ~10% of merchants account for well over half of gross volume. I'm requesting one snapshot (who the big merchants are and their share in 2025) and one two-point trend (2024 vs 2025) to directly test whether that concentration is growing or shrinking, which is the risk question Tobias actually asked.

    All three reports use payouts (gross/fee/net already split there) converted to EUR via exchange_rates, joined on the payout's currency and settlement date.

    Tidewick Processing Fee Revenue by Month, 2025
    Question
    How much processing-fee revenue did Tidewick earn each month in 2025, and how is it trending?
    One row per
    one calendar month in 2025
    Columns
    • month (YYYY-MM)
    • payout count
    • gross volume (EUR)
    • fee revenue (EUR) — Tidewick's processing-fee take
    • net paid out to merchants (EUR)
    • fee rate (%) = fee revenue / gross volume
    • fee revenue same month prior year (EUR)
  3. Bea 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)
    WITH months AS (
      SELECT DISTINCT t0.month_num AS month_num, t0.month_label AS month_label
      FROM calendar_days t0
      WHERE t0.year_num = 2025
    ),
    converted_payouts AS (
      SELECT
        t0.id AS payout_id,
        t2.year_num AS year_num,
        t2.month_num AS month_num,
        t0.gross_amount / t1.rate AS gross_eur,
        t0.fee_amount / t1.rate AS fee_eur,
        t0.net_amount / t1.rate AS net_eur
      FROM payouts t0
      JOIN exchange_rates t1
        ON t1.base_code = 'EUR'
       AND t1.quote_code = t0.currency_code
       AND t1.as_of = CAST(t0.paid_at AS date)
      JOIN calendar_days t2
        ON t2.calendar_date = CAST(t0.paid_at AS date)
      WHERE t0.paid_at IS NOT NULL
        AND t0.status IN ('paid', 'completed')
    ),
    current_year AS (
      SELECT
        t0.month_num AS month_num,
        COUNT(*) AS payout_count,
        SUM(t0.gross_eur) AS gross_eur_total,
        SUM(t0.fee_eur) AS fee_eur_total,
        SUM(t0.net_eur) AS net_eur_total
      FROM converted_payouts t0
      WHERE t0.year_num = 2025
      GROUP BY t0.month_num
    ),
    prior_year AS (
      SELECT
        t0.month_num AS month_num,
        SUM(t0.fee_eur) AS fee_eur_total_prior
      FROM converted_payouts t0
      WHERE t0.year_num = 2024
      GROUP BY t0.month_num
    )
    SELECT
      t0.month_label AS month,
      COALESCE(t1.payout_count, 0) AS payout_count,
      ROUND(COALESCE(t1.gross_eur_total, 0), 2) AS gross_volume_eur,
      ROUND(COALESCE(t1.fee_eur_total, 0), 2) AS fee_revenue_eur,
      ROUND(COALESCE(t1.net_eur_total, 0), 2) AS net_paid_out_eur,
      ROUND(COALESCE(t1.fee_eur_total, 0) / NULLIF(t1.gross_eur_total, 0) * 100, 1) AS fee_rate_pct,
      ROUND(COALESCE(t2.fee_eur_total_prior, 0), 2) AS fee_revenue_prior_year_eur
    FROM months t0
    LEFT JOIN current_year t1 ON t1.month_num = t0.month_num
    LEFT JOIN prior_year t2 ON t2.month_num = t0.month_num
    ORDER BY t0.month_num
    Submitted · the SQL of the last dry run, unchanged · Submitted to tonight's batch. Your work on this request is done.

    “Submitted. One flag for you: the payouts.status column isn't an enumerated field in the table catalog (no allowed-values list given), so I filtered on status IN ('paid','completed') as the settled/completed states per the request's intent. If the actual literal values differ (e.g. 'succeeded'), let me know and I'll correct it in a follow-up request.”

  4. The report comes back overnight

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

    month   | payout_count | gross_volume_eur | fee_revenue_eur | net_paid_out_eur | fee_rate_pct | fee_revenue_prior_year_eur
    --------+--------------+------------------+-----------------+------------------+--------------+---------------------------
    2025-01 |          503 |        199172.31 |         4954.45 |        151256.63 |          2.5 |                    2988.79
    2025-02 |          426 |        117777.21 |         2907.62 |          95906.6 |          2.5 |                    3263.45
    2025-03 |          475 |        135070.48 |         3567.13 |        115334.32 |          2.6 |                     2972.1
    2025-04 |          449 |        188851.76 |         5006.79 |        169982.71 |          2.7 |                    3242.26
    2025-05 |          482 |        127570.11 |         3482.02 |         98002.35 |          2.7 |                    3490.98
    2025-06 |          463 |        113673.02 |         3123.65 |         91578.07 |          2.7 |                    2773.41
    2025-07 |          484 |        132602.63 |         3604.89 |         108283.9 |          2.7 |                    2844.58
    2025-08 |          474 |        141126.23 |         4276.29 |         118864.4 |          3.0 |                    2727.22
    2025-09 |          458 |         122449.4 |         3165.85 |        100229.52 |          2.6 |                    2991.07
    2025-10 |          507 |        154129.95 |         4108.16 |        130131.01 |          2.7 |                    3558.57
    2025-11 |          621 |        236843.96 |         6380.01 |        199899.74 |          2.7 |                    4548.44
    2025-12 |          775 |        322182.01 |          8609.0 |        250319.95 |          2.7 |                    7526.96
    (12 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 q013

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

    • determinismtie-breaker: month_label is not a function of month_num by schema
    • numericsdocs/NUMERICS.md: rounding removed; results keep full precision
    • numericsdocs/NUMERICS.md: x / exchange rate cast to NUMERIC(18,6) (3×)
    • 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
    WITH months AS (  SELECT DISTINCT t0.month_num AS month_num, t0.month_label AS month_label  FROM calendar_days t0  WHERE t0.year_num = 2025),converted_payouts AS (  SELECT    t0.id AS payout_id,    t2.year_num AS year_num,    t2.month_num AS month_num,    t0.gross_amount / t1.rate AS gross_eur,    t0.fee_amount / t1.rate AS fee_eur,    t0.net_amount / t1.rate AS net_eur    CAST(t0.gross_amount / t1.rate AS NUMERIC(18,6)) AS gross_eur,    CAST(t0.fee_amount / t1.rate AS NUMERIC(18,6)) AS fee_eur,    CAST(t0.net_amount / t1.rate AS NUMERIC(18,6)) AS net_eur  FROM payouts t0  JOIN exchange_rates t1    ON t1.base_code = 'EUR'   AND t1.quote_code = t0.currency_code   AND t1.as_of = CAST(t0.paid_at AS date)  JOIN calendar_days t2    ON t2.calendar_date = CAST(t0.paid_at AS date)  WHERE t0.paid_at IS NOT NULL    AND t0.status IN ('paid', 'completed')),current_year AS (  SELECT    t0.month_num AS month_num,    COUNT(*) AS payout_count,    SUM(t0.gross_eur) AS gross_eur_total,    SUM(t0.fee_eur) AS fee_eur_total,    SUM(t0.net_eur) AS net_eur_total  FROM converted_payouts t0  WHERE t0.year_num = 2025  GROUP BY t0.month_num),prior_year AS (  SELECT    t0.month_num AS month_num,    SUM(t0.fee_eur) AS fee_eur_total_prior  FROM converted_payouts t0  WHERE t0.year_num = 2024  GROUP BY t0.month_num)SELECT  t0.month_label AS month,  COALESCE(t1.payout_count, 0) AS payout_count,  ROUND(COALESCE(t1.gross_eur_total, 0), 2) AS gross_volume_eur,  ROUND(COALESCE(t1.fee_eur_total, 0), 2) AS fee_revenue_eur,  ROUND(COALESCE(t1.net_eur_total, 0), 2) AS net_paid_out_eur,  ROUND(COALESCE(t1.fee_eur_total, 0) / NULLIF(t1.gross_eur_total, 0) * 100, 1) AS fee_rate_pct,  ROUND(COALESCE(t2.fee_eur_total_prior, 0), 2) AS fee_revenue_prior_year_eur  COALESCE(t1.gross_eur_total, 0) AS gross_volume_eur,  COALESCE(t1.fee_eur_total, 0) AS fee_revenue_eur,  COALESCE(t1.net_eur_total, 0) AS net_paid_out_eur,  COALESCE(t1.fee_eur_total, 0) / NULLIF(t1.gross_eur_total, 0) * 100 AS fee_rate_pct,  COALESCE(t2.fee_eur_total_prior, 0) AS fee_revenue_prior_year_eurFROM months t0LEFT JOIN current_year t1 ON t1.month_num = t0.month_numLEFT JOIN prior_year t2 ON t2.month_num = t0.month_numORDER BY t0.month_numORDER BY t0.month_num NULLS LAST, t0.month_label NULLS LAST
    WITH months AS (
      SELECT DISTINCT t0.month_num AS month_num, t0.month_label AS month_label
      FROM calendar_days t0
      WHERE t0.year_num = 2025
    ),
    converted_payouts AS (
      SELECT
        t0.id AS payout_id,
        t2.year_num AS year_num,
        t2.month_num AS month_num,
        CAST(t0.gross_amount / t1.rate AS NUMERIC(18,6)) AS gross_eur,
        CAST(t0.fee_amount / t1.rate AS NUMERIC(18,6)) AS fee_eur,
        CAST(t0.net_amount / t1.rate AS NUMERIC(18,6)) AS net_eur
      FROM payouts t0
      JOIN exchange_rates t1
        ON t1.base_code = 'EUR'
       AND t1.quote_code = t0.currency_code
       AND t1.as_of = CAST(t0.paid_at AS date)
      JOIN calendar_days t2
        ON t2.calendar_date = CAST(t0.paid_at AS date)
      WHERE t0.paid_at IS NOT NULL
        AND t0.status IN ('paid', 'completed')
    ),
    current_year AS (
      SELECT
        t0.month_num AS month_num,
        COUNT(*) AS payout_count,
        SUM(t0.gross_eur) AS gross_eur_total,
        SUM(t0.fee_eur) AS fee_eur_total,
        SUM(t0.net_eur) AS net_eur_total
      FROM converted_payouts t0
      WHERE t0.year_num = 2025
      GROUP BY t0.month_num
    ),
    prior_year AS (
      SELECT
        t0.month_num AS month_num,
        SUM(t0.fee_eur) AS fee_eur_total_prior
      FROM converted_payouts t0
      WHERE t0.year_num = 2024
      GROUP BY t0.month_num
    )
    SELECT
      t0.month_label AS month,
      COALESCE(t1.payout_count, 0) AS payout_count,
      COALESCE(t1.gross_eur_total, 0) AS gross_volume_eur,
      COALESCE(t1.fee_eur_total, 0) AS fee_revenue_eur,
      COALESCE(t1.net_eur_total, 0) AS net_paid_out_eur,
      COALESCE(t1.fee_eur_total, 0) / NULLIF(t1.gross_eur_total, 0) * 100 AS fee_rate_pct,
      COALESCE(t2.fee_eur_total_prior, 0) AS fee_revenue_prior_year_eur
    FROM months t0
    LEFT JOIN current_year t1 ON t1.month_num = t0.month_num
    LEFT JOIN prior_year t2 ON t2.month_num = t0.month_num
    ORDER BY t0.month_num NULLS LAST, t0.month_label NULLS LAST
    10 ms100 ms1 sHyper13.1 msDuckDB13.5 msGizmoSQL19.8 msclickhouse_brew121 msFirebolterrorDataFusion76.6 msClickHouseerror
    • Firebolt{ "errors": [ { "description": "Line 11, Column 26: 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": "41f274da-9de4-49b4-8d67-9ae6541d0c2a", "query_lab
    • ClickHouseCode: 69. DB::Exception: Decimal result's scale is less than argument's one: while executing function divide on arguments __table5.gross_amount Decimal(14, 4) Decimal64(size = 0), __table6.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

    Bea's whole session, 6 turns
    • The request, as Bea received it
      Report request from Kofi Mensah (analyst for the Chief Financial Officer):
      
      Title: Tidewick Processing Fee Revenue by Month, 2025
      Question: How much processing-fee revenue did Tidewick earn each month in 2025, and how is it trending?
      Grain: one calendar month in 2025
      Columns:
      - month (YYYY-MM)
      - payout count
      - gross volume (EUR)
      - fee revenue (EUR) — Tidewick's processing-fee take
      - net paid out to merchants (EUR)
      - fee rate (%) = fee revenue / gross volume
      - fee revenue same month prior year (EUR)
      Details: Source: payouts (gross_amount, fee_amount, net_amount, currency_code, paid_at/arrival_date). Include only payouts with a non-null paid_at and status indicating completed/paid settlement. Bucket by month of paid_at using calendar_days.month_label for 2025-01 through 2025-12. Convert each payout's amounts from its currency_code to EUR using exchange_rates (base_code='EUR', quote_code=payout currency, as_of = date(paid_at)); EUR amount = amount / rate. For the 'prior year' column, compute the same aggregation for the matching month in 2024. Round money to 2 decimals and the fee rate to 1 decimal place. Order by month ascending.
      
      Check your query with the warehouse's dry_run tool, then hand it in with submit.
    • Bea calls dry_run
      WITH months AS (
        SELECT DISTINCT t0.month_num AS month_num, t0.month_label AS month_label
        FROM calendar_days t0
        WHERE t0.year_num = 2025
      ),
      converted_payouts AS (
        SELECT
          t0.id AS payout_id,
          t2.year_num AS year_num,
          t2.month_num AS month_num,
          t0.gross_amount / t1.rate AS gross_eur,
          t0.fee_amount / t1.rate AS fee_eur,
          t0.net_amount / t1.rate AS net_eur
        FROM payouts t0
        JOIN exchange_rates t1
          ON t1.base_code = 'EUR'
         AND t1.quote_code = t0.currency_code
         AND t1.as_of = CAST(t0.paid_at AS date)
        JOIN calendar_days t2
          ON t2.calendar_date = CAST(t0.paid_at AS date)
        WHERE t0.paid_at IS NOT NULL
          AND t0.status IN ('paid', 'completed')
      ),
      current_year AS (
        SELECT
          t0.month_num AS month_num,
          COUNT(*) AS payout_count,
          SUM(t0.gross_eur) AS gross_eur_total,
          SUM(t0.fee_eur) AS fee_eur_total,
          SUM(t0.net_eur) AS net_eur_total
        FROM converted_payouts t0
        WHERE t0.year_num = 2025
        GROUP BY t0.month_num
      ),
      prior_year AS (
        SELECT
          t0.month_num AS month_num,
          SUM(t0.fee_eur) AS fee_eur_total_prior
        FROM converted_payouts t0
        WHERE t0.year_num = 2024
        GROUP BY t0.month_num
      )
      SELECT
        t0.month_label AS month,
        COALESCE(t1.payout_count, 0) AS payout_count,
        ROUND(COALESCE(t1.gross_eur_total, 0), 2) AS gross_volume_eur,
        ROUND(COALESCE(t1.fee_eur_total, 0), 2) AS fee_revenue_eur,
        ROUND(COALESCE(t1.net_eur_total, 0), 2) AS net_paid_out_eur,
        ROUND(COALESCE(t1.fee_eur_total, 0) / NULLIF(t1.gross_eur_total, 0) * 100, 1) AS fee_rate_pct,
        ROUND(COALESCE(t2.fee_eur_total_prior, 0), 2) AS fee_revenue_prior_year_eur
      FROM months t0
      LEFT JOIN current_year t1 ON t1.month_num = t0.month_num
      LEFT JOIN prior_year t2 ON t2.month_num = t0.month_num
      ORDER BY t0.month_num
    • The warehouse answers
      STATUS: COMPILES (dry run on the empty schema, no data read)
      COLUMNS: month, payout_count, gross_volume_eur, fee_revenue_eur, net_paid_out_eur, fee_rate_pct, fee_revenue_prior_year_eur
    • Bea calls submit
      WITH months AS (
        SELECT DISTINCT t0.month_num AS month_num, t0.month_label AS month_label
        FROM calendar_days t0
        WHERE t0.year_num = 2025
      ),
      converted_payouts AS (
        SELECT
          t0.id AS payout_id,
          t2.year_num AS year_num,
          t2.month_num AS month_num,
          t0.gross_amount / t1.rate AS gross_eur,
          t0.fee_amount / t1.rate AS fee_eur,
          t0.net_amount / t1.rate AS net_eur
        FROM payouts t0
        JOIN exchange_rates t1
          ON t1.base_code = 'EUR'
         AND t1.quote_code = t0.currency_code
         AND t1.as_of = CAST(t0.paid_at AS date)
        JOIN calendar_days t2
          ON t2.calendar_date = CAST(t0.paid_at AS date)
        WHERE t0.paid_at IS NOT NULL
          AND t0.status IN ('paid', 'completed')
      ),
      current_year AS (
        SELECT
          t0.month_num AS month_num,
          COUNT(*) AS payout_count,
          SUM(t0.gross_eur) AS gross_eur_total,
          SUM(t0.fee_eur) AS fee_eur_total,
          SUM(t0.net_eur) AS net_eur_total
        FROM converted_payouts t0
        WHERE t0.year_num = 2025
        GROUP BY t0.month_num
      ),
      prior_year AS (
        SELECT
          t0.month_num AS month_num,
          SUM(t0.fee_eur) AS fee_eur_total_prior
        FROM converted_payouts t0
        WHERE t0.year_num = 2024
        GROUP BY t0.month_num
      )
      SELECT
        t0.month_label AS month,
        COALESCE(t1.payout_count, 0) AS payout_count,
        ROUND(COALESCE(t1.gross_eur_total, 0), 2) AS gross_volume_eur,
        ROUND(COALESCE(t1.fee_eur_total, 0), 2) AS fee_revenue_eur,
        ROUND(COALESCE(t1.net_eur_total, 0), 2) AS net_paid_out_eur,
        ROUND(COALESCE(t1.fee_eur_total, 0) / NULLIF(t1.gross_eur_total, 0) * 100, 1) AS fee_rate_pct,
        ROUND(COALESCE(t2.fee_eur_total_prior, 0), 2) AS fee_revenue_prior_year_eur
      FROM months t0
      LEFT JOIN current_year t1 ON t1.month_num = t0.month_num
      LEFT JOIN prior_year t2 ON t2.month_num = t0.month_num
      ORDER BY t0.month_num
    • The warehouse answers
      Submitted to tonight's batch. Your work on this request is done.
    • BeaSubmitted. One flag for you: the `payouts.status` column isn't an enumerated field in the table catalog (no allowed-values list given), so I filtered on `status IN ('paid','completed')` as the settled/completed states per the request's intent. If the actual literal values differ (e.g. `'succeeded'`), let me know and I'll correct it in a follow-up request.