DB Olympics

Queries · q095 · Head of Data · week 1, night 3

Refund timing relative to payout settlement

“When a payment is refunded, does the refund happen before its payout settles (so it can cleanly reduce that payout) or after (so the clawback has no clean home in the payout data), and how much money is affected?”

Manager · asked the questionRosa Van den BergHead of Data. Took over the analytics team last week. Before the team's numbers go to the board she wants to know how far they can be trusted.
Analyst · planned the reportYuki TanakaDetail-oriented: picks specific merchants, products or days and looks at them closely.
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. A business event lands on the Head of Data's desk

    Week 1; shared with Tobias Lindqvist, Chief Financial Officer

    Before the board pack goes out, Data must certify the key metrics. Do revenue, orders, customers, refunds and payouts agree wherever they are recorded, and where they do not, why not and by how much?

  2. Rosa Van den Berg writes to Yuki Tanaka

    The manager, at the start of week 1

    Subject: Certifying the key metrics before the board pack — what I need from you this week

    Hi Yuki,

    Tobias and I have to certify revenue, orders, customers, refunds and payouts before they go into the board pack. "Certify" means we're telling the board these numbers are reconcilable — so this week's job is to find out where the different places we record these figures agree, where they don't, and whether any gap is big enough to change the story we tell. If something doesn't reconcile, I need to know why and by how much, not just that it's "close enough." That feeds directly into the go/no-go Tobias and I give on the board pack, so please prioritize breadth across the five metrics over depth on any one of them — we can always dig deeper next week on whichever one looks shakiest.

    You only have a handful of report slots a night, so here's what matters most to me, roughly in priority order:

    1. If someone asks "what was our revenue last month," do we get the same answer regardless of which part of the business we ask? Where it diverges, how far apart are the answers, and is the gap explained by something sensible (timing, currency, returns) or does it look like a real inconsistency?
    2. Do our order counts and our revenue tell a consistent story — i.e., is there a clean, traceable link from an order to the money that was actually collected for it, or are there orders that seem to vanish or multiply along the way?
    3. Refunds and payouts: when money goes back to a customer, does it show up consistently as a reduction in revenue and in what we actually pay out to merchants? I want to know if refunds could be hiding or double-counted anywhere.
    4. Customers: can we count "how many customers we have" or "how many are active" in one consistent way, or do different parts of the data give meaningfully different headcounts?
    5. If you have a slot left: is there anything about how timestamps or currencies are handled that could quietly distort any of the above when we roll numbers up across time or across merchants?

    Please flag anything you find inconsistent together with your best read of why, and roughly how big the discrepancy is in practice — that's what Tobias and I need to decide whether we can certify as-is, certify with a caveat, or need to hold a number back.

    Thank you — I know this is a lot for one week. Let's talk before you spend your first slot tonight if you want to sanity-check the plan.

    Rosa

    A note from the Chief Financial Officer, Tobias Lindqvist

    Subject: What I need for the revenue/payout certification

    Hi Yuki,

    A few things from my side as you scope this week's work:

    1. Does our reported revenue tie out end to end? I want to know whether what we book as platform fees and processing fees matches what actually settles to us through payouts — and if there's a gap, roughly how large it is and whether it's timing (month-end cutoff) or something structural.
    2. Do merchant payouts reconcile to the gross volume and refunds we report? I'm particularly interested in whether net payouts consistently equal gross sales minus refunds minus our fees, or whether certain merchants/currencies show persistent drift.
    3. Are our B2B wholesalers' outstanding receivables consistent with what we'd show in the annual accounts? I want to understand how much is genuinely overdue versus just lagging normal payment terms, since that affects how we present them.

    If anything else jumps out while you're in there — especially around currency conversion timing or refund treatment — flag it even if it's outside these three.

    Thanks, Tobias

  3. Yuki Tanaka plans the night

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

    Night 2 gave three follow-ups worth running tonight. (1) The order-status breakdown showed "refunded" orders at a near-total 99.8% gap and "fulfilled" orders at a persistent 22% gap — I suspect both are partly a filter artifact: my captured-payment match only counted payments.status='captured', but a payment that was later refunded moves to status 'refunded'/'partially_refunded' and would be invisible to that filter even though money genuinely changed hands. I want to re-run with a status-inclusive filter to see how much of the gap is this artifact vs a real reconciliation hole, especially for 'fulfilled' orders where there's no reason to expect any gap at all. (2) The payment-linked payout/refund check got noisier, not cleaner, than the calendar-month version (swinging -16.6% to +17.7% with no consistent sign) — payout_items has a unique constraint on payment_id (one row per payment, ever), so a refund processed after a payment's payout has already settled has no slot in that payout to be reflected in; it would have to hit some later, differently-linked settlement, breaking the clean per-payout linkage I assumed. I want to directly test how often refunds are processed after their payment's payout already settled, since that would explain the noise and is itself a traceability gap worth flagging to Tobias. (3) Rosa's customer-count question (#4) hasn't been touched yet and we're entering the back half of the week, so I'm using tonight's third slot to get a first cut at how many "customers" we have under different plausible definitions, to see if that's a quick certify or another can of worms for night 4.

    Refund timing relative to payout settlement
    Question
    When a payment is refunded, does the refund happen before its payout settles (so it can cleanly reduce that payout) or after (so the clawback has no clean home in the payout data), and how much money is affected?
    One row per
    timing bucket (before payout settled / after payout settled / payment not yet in any payout)
    Columns
    • timing_bucket
    • refund_count
    • refund_amount_eur
    • pct_of_total_refund_amount
    • avg_days_lag
  4. 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
      timing_bucket,
      COUNT(*) AS refund_count,
      ROUND(SUM(amount_eur), 2) AS refund_amount_eur,
      ROUND(SUM(amount_eur) / SUM(SUM(amount_eur)) OVER () * 100, 1) AS pct_of_total_refund_amount,
      ROUND(AVG(CASE WHEN timing_bucket = 'refund after payout paid_at' THEN days_lag END), 1) AS avg_days_lag
    FROM (
      SELECT
        pr.id,
        CASE
          WHEN po.id IS NULL THEN 'payment not yet settled in any payout'
          WHEN pr.processed_at <= po.paid_at THEN 'refund before payout paid_at'
          ELSE 'refund after payout paid_at'
        END AS timing_bucket,
        CASE WHEN pr.currency_code = 'EUR' THEN pr.amount ELSE pr.amount / er.rate END AS amount_eur,
        EXTRACT(EPOCH FROM (pr.processed_at - po.paid_at)) / 86400.0 AS days_lag
      FROM payment_refunds pr
      LEFT JOIN payout_items pi ON pi.payment_id = pr.payment_id
      LEFT JOIN payouts po ON po.id = pi.payout_id
      LEFT JOIN exchange_rates er
        ON er.base_code = 'EUR'
        AND er.quote_code = pr.currency_code
        AND er.as_of = pr.processed_at::date
      WHERE pr.status = 'succeeded'
        AND pr.processed_at >= DATE '2023-01-01'
        AND pr.processed_at < DATE '2026-01-01'
    ) sub
    GROUP BY timing_bucket
    ORDER BY refund_amount_eur DESC

    “Query compiles and produces the requested 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 buckets every succeeded refund (2023‑01‑01 through 2025‑12‑31) into the three timing states, converts amounts to EUR via the daily exchange_rates, and computes the average lag in days only for refunds that landed after their payout's paid_at.”

  5. The report comes back overnight

    As Yuki Tanaka saw it, from the small dataset the agents worked on

    timing_bucket                         | refund_count | refund_amount_eur | pct_of_total_refund_amount | avg_days_lag
    --------------------------------------+--------------+-------------------+----------------------------+-------------
    refund after payout paid_at           |         7455 |         983341.22 |                       85.5 |         20.9
    refund before payout paid_at          |          577 |         167042.45 |                       14.5 |         NULL
    payment not yet settled in any payout |            1 |            330.69 |                        0.0 |         NULL
    (3 rows)
  6. 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:

  7. It becomes benchmark query q095

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

    • spellingcast
    • determinismtie-breaker: the grouping key
    • numericsdocs/NUMERICS.md: rounding removed; results keep full precision
    • numericsdocs/NUMERICS.md: EXTRACT(EPOCH FROM (b - a)) cast to NUMERIC(18,6)
    • numericsdocs/NUMERICS.md: x / exchange rate cast to NUMERIC(18,6)
    • made explicita selected t.col gets AS col, its standard name made explicit
    • placeholderseconds between
    • 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  timing_bucket,  COUNT(*) AS refund_count,  ROUND(SUM(amount_eur), 2) AS refund_amount_eur,  ROUND(SUM(amount_eur) / SUM(SUM(amount_eur)) OVER () * 100, 1) AS pct_of_total_refund_amount,  ROUND(AVG(CASE WHEN timing_bucket = 'refund after payout paid_at' THEN days_lag END), 1) AS avg_days_lag  SUM(amount_eur) AS refund_amount_eur,  SUM(amount_eur) / SUM(SUM(amount_eur)) OVER () * 100 AS pct_of_total_refund_amount,  AVG(CASE WHEN timing_bucket = 'refund after payout paid_at' THEN days_lag END) AS avg_days_lagFROM (  SELECT    pr.id,    pr.id AS id,    CASE      WHEN po.id IS NULL THEN 'payment not yet settled in any payout'      WHEN pr.processed_at <= po.paid_at THEN 'refund before payout paid_at'      ELSE 'refund after payout paid_at'    END AS timing_bucket,    CASE WHEN pr.currency_code = 'EUR' THEN pr.amount ELSE pr.amount / er.rate END AS amount_eur,    EXTRACT(EPOCH FROM (pr.processed_at - po.paid_at)) / 86400.0 AS days_lag    CASE WHEN pr.currency_code = 'EUR' THEN pr.amount ELSE CAST(pr.amount / er.rate AS NUMERIC(18,6)) END AS amount_eur,    {{seconds_between(po.paid_at, pr.processed_at)}} / 86400.0 AS days_lag  FROM payment_refunds pr  LEFT JOIN payout_items pi ON pi.payment_id = pr.payment_id  LEFT JOIN payouts po ON po.id = pi.payout_id  LEFT JOIN exchange_rates er    ON er.base_code = 'EUR'    AND er.quote_code = pr.currency_code    AND er.as_of = pr.processed_at::date    AND er.as_of = CAST(pr.processed_at AS date)  WHERE pr.status = 'succeeded'    AND pr.processed_at >= DATE '2023-01-01'    AND pr.processed_at < DATE '2026-01-01') subGROUP BY timing_bucketORDER BY refund_amount_eur DESCORDER BY refund_amount_eur DESC NULLS FIRST, timing_bucket NULLS LAST
    SELECT
      timing_bucket,
      COUNT(*) AS refund_count,
      SUM(amount_eur) AS refund_amount_eur,
      SUM(amount_eur) / SUM(SUM(amount_eur)) OVER () * 100 AS pct_of_total_refund_amount,
      AVG(CASE WHEN timing_bucket = 'refund after payout paid_at' THEN days_lag END) AS avg_days_lag
    FROM (
      SELECT
        pr.id AS id,
        CASE
          WHEN po.id IS NULL THEN 'payment not yet settled in any payout'
          WHEN pr.processed_at <= po.paid_at THEN 'refund before payout paid_at'
          ELSE 'refund after payout paid_at'
        END AS timing_bucket,
        CASE WHEN pr.currency_code = 'EUR' THEN pr.amount ELSE CAST(pr.amount / er.rate AS NUMERIC(18,6)) END AS amount_eur,
        CAST(EXTRACT(EPOCH FROM (pr.processed_at - po.paid_at)) AS NUMERIC(18,6)) / 86400.0 AS days_lag
      FROM payment_refunds pr
      LEFT JOIN payout_items pi ON pi.payment_id = pr.payment_id
      LEFT JOIN payouts po ON po.id = pi.payout_id
      LEFT JOIN exchange_rates er
        ON er.base_code = 'EUR'
        AND er.quote_code = pr.currency_code
        AND er.as_of = CAST(pr.processed_at AS date)
      WHERE pr.status = 'succeeded'
        AND pr.processed_at >= DATE '2023-01-01'
        AND pr.processed_at < DATE '2026-01-01'
    ) sub
    GROUP BY timing_bucket
    ORDER BY refund_amount_eur DESC NULLS FIRST, timing_bucket NULLS LAST
    10 ms100 ms1 sHyper163 msDuckDB103 msGizmoSQL95.9 msclickhouse_brew936 msFirebolterrorDataFusion192 msClickHouseerror
    • Firebolt{ "errors": [ { "description": "Line 15, Column 75: 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": "f626fc02-4f18-414b-a82f-b31bbf7cb39c", "query_lab
    • ClickHouseCode: 69. DB::Exception: Decimal result's scale is less than argument's one: while executing function divide on arguments __table2.amount Decimal(14, 4) Decimal64(size = 0), __table5.rate Nullable(Decimal(12, 6)) Nullable(size = 0, Decimal64(size = 0), UInt8(size = 0)). (ARGUMENT_OUT_OF_BOUND)
  8. 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 Yuki Tanaka (analyst for the Head of Data):
      
      Title: Refund timing relative to payout settlement
      Question: When a payment is refunded, does the refund happen before its payout settles (so it can cleanly reduce that payout) or after (so the clawback has no clean home in the payout data), and how much money is affected?
      Grain: timing bucket (before payout settled / after payout settled / payment not yet in any payout)
      Columns:
      - timing_bucket
      - refund_count
      - refund_amount_eur
      - pct_of_total_refund_amount
      - avg_days_lag
      Details: Population: all payment_refunds with status='succeeded' across full history (2023-01-01 to 2025-12-31). For each refund, find the payout (if any) that settled its payment via payout_items (payout_items.payment_id = payment_refunds.payment_id) joined to payouts (payout_items.payout_id = payouts.id). Bucket each refund as: 'refund before payout paid_at' when payment_refunds.processed_at <= payouts.paid_at; 'refund after payout paid_at' when payment_refunds.processed_at > payouts.paid_at; 'payment not yet settled in any payout' when no matching payout_items row exists for that payment_id. refund_count = count of refunds in the bucket. refund_amount_eur = sum of payment_refunds.amount converted to EUR via payment_refunds.currency_code and processed_at date. pct_of_total_refund_amount = refund_amount_eur / total refund_amount_eur across all buckets * 100, rounded to 1 decimal. avg_days_lag = average of (payment_refunds.processed_at - payouts.paid_at) in days for the 'after' bucket only (NULL for the other two buckets), rounded to 1 decimal. Tables: payment_refunds, payout_items, payouts, exchange_rates. Order by refund_amount_eur descending.
      
      Check your query with the warehouse's dry_run tool, then hand it in with submit.
    • Viktor calls dry_run
      SELECT
        timing_bucket,
        COUNT(*) AS refund_count,
        ROUND(SUM(amount_eur), 2) AS refund_amount_eur,
        ROUND(SUM(amount_eur) / SUM(SUM(amount_eur)) OVER () * 100, 1) AS pct_of_total_refund_amount,
        ROUND(AVG(CASE WHEN timing_bucket = 'refund after payout paid_at' THEN days_lag END), 1) AS avg_days_lag
      FROM (
        SELECT
          pr.id,
          CASE
            WHEN po.id IS NULL THEN 'payment not yet settled in any payout'
            WHEN pr.processed_at <= po.paid_at THEN 'refund before payout paid_at'
            ELSE 'refund after payout paid_at'
          END AS timing_bucket,
          CASE WHEN pr.currency_code = 'EUR' THEN pr.amount ELSE pr.amount / er.rate END AS amount_eur,
          EXTRACT(EPOCH FROM (pr.processed_at - po.paid_at)) / 86400.0 AS days_lag
        FROM payment_refunds pr
        LEFT JOIN payout_items pi ON pi.payment_id = pr.payment_id
        LEFT JOIN payouts po ON po.id = pi.payout_id
        LEFT JOIN exchange_rates er
          ON er.base_code = 'EUR'
          AND er.quote_code = pr.currency_code
          AND er.as_of = pr.processed_at::date
        WHERE pr.status = 'succeeded'
          AND pr.processed_at >= DATE '2023-01-01'
          AND pr.processed_at < DATE '2026-01-01'
      ) sub
      GROUP BY timing_bucket
      ORDER BY refund_amount_eur DESC
    • The warehouse answers
      STATUS: COMPILES (dry run on the empty schema, no data read)
      COLUMNS: timing_bucket, refund_count, refund_amount_eur, pct_of_total_refund_amount, avg_days_lag
    • ViktorQuery compiles and produces the requested columns. Submitting.
    • Viktor calls submit
      SELECT
        timing_bucket,
        COUNT(*) AS refund_count,
        ROUND(SUM(amount_eur), 2) AS refund_amount_eur,
        ROUND(SUM(amount_eur) / SUM(SUM(amount_eur)) OVER () * 100, 1) AS pct_of_total_refund_amount,
        ROUND(AVG(CASE WHEN timing_bucket = 'refund after payout paid_at' THEN days_lag END), 1) AS avg_days_lag
      FROM (
        SELECT
          pr.id,
          CASE
            WHEN po.id IS NULL THEN 'payment not yet settled in any payout'
            WHEN pr.processed_at <= po.paid_at THEN 'refund before payout paid_at'
            ELSE 'refund after payout paid_at'
          END AS timing_bucket,
          CASE WHEN pr.currency_code = 'EUR' THEN pr.amount ELSE pr.amount / er.rate END AS amount_eur,
          EXTRACT(EPOCH FROM (pr.processed_at - po.paid_at)) / 86400.0 AS days_lag
        FROM payment_refunds pr
        LEFT JOIN payout_items pi ON pi.payment_id = pr.payment_id
        LEFT JOIN payouts po ON po.id = pi.payout_id
        LEFT JOIN exchange_rates er
          ON er.base_code = 'EUR'
          AND er.quote_code = pr.currency_code
          AND er.as_of = pr.processed_at::date
        WHERE pr.status = 'succeeded'
          AND pr.processed_at >= DATE '2023-01-01'
          AND pr.processed_at < DATE '2026-01-01'
      ) sub
      GROUP BY timing_bucket
      ORDER BY refund_amount_eur DESC
    • The warehouse answers
      Submitted to tonight's batch. Your work on this request is done.
    • ViktorSubmitted. The report buckets every succeeded refund (2023‑01‑01 through 2025‑12‑31) into the three timing states, converts amounts to EUR via the daily `exchange_rates`, and computes the average lag in days only for refunds that landed after their payout's `paid_at`.