DB Olympics

Queries · q269 · Head of Merchant Success · week 2, night 2

Pre-churn warning signs across sales, repeat buying, refunds, and support tickets

“In the months before a churned merchant's last order, did sales, shopper repeat-buying, refund rates, or support ticket volume already show warning signs?”

Manager · asked the questionSamuel WhitcombeHead of Merchant Success. New in the role, owns the relationship with the largest merchants and quarterly business reviews with them in January.
Analyst · planned the reportArjun PatelSegmenter: breaks totals down by merchant size, vertical, country and channel.
Engineer · wrote the SQLGraceWrites tidy, layered CTEs with descriptive names, one step per CTE, then a final select.

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 Merchant Success's desk

    Week 2; shared with Chloé Marchetti, Head of Growth Marketing and Daniel Achterberg, VP Customer Experience

    Churn has become a board topic, for merchants and for their shoppers. A cross-functional task force wants to understand which experiences precede leaving, and which early signals would let us act before it happens.

  2. Samuel Whitcombe writes to Arjun Patel

    The manager, at the start of week 2

    Subject: Next week's priorities — QBR follow-ups + the churn task force

    Hi Arjun,

    Really useful memo — thank you. A few things jumped out that I want to understand better, plus a new cross-functional ask that's going to need a slot or two this week.

    What surprised me / needs explaining first:

    • Castell Fasteners is a contradiction — our #4 merchant, highly sticky customer base (88.6% repeat), but the weakest YoY growth of the top tier and a steep traffic drop. I want to know whether that traffic drop is a real warning sign or just how B2B industrial buying seasonally looks, before I raise it with them in their QBR.
    • Good catch on the refund-rate math — please don't let anything with the >100% problem anywhere near a client-facing page until it's fixed.

    My questions for this week:

    1. Is Castell's traffic decline a real risk or seasonal noise? I want a clear enough answer that I either flag it to them directly in the QBR or leave it alone.
    2. How are our top ~15 merchants' shoppers being supported, and does it show up in how those merchants feel about us? This was cut last week — please pick it back up.
    3. For the task force below: which merchants have actually left the platform (or are clearly heading that way), and what did their last few months look like beforehand — their sales trend, their shoppers' buying behavior, how much support friction they had? I'm less interested in a single root cause and more in whatever pattern shows up consistently across the merchants who left, since that's what would let us catch the next one early.

    New context — the churn task force: Churn is now a board topic, both merchant churn and end-shopper churn, and I'm leading this jointly with Chloé Marchetti and Daniel Achterberg. The deliverable the three of us owe the board is a shortlist of early-warning signals — things we can observe weeks or months before a merchant (or a merchant's shoppers) leaves — and a recommendation on which of those signals is reliable enough to act on. It feeds directly into two decisions: whether Merchant Success should get a proactive outreach trigger for at-risk accounts, and whether Growth Marketing and CX get an equivalent retention trigger for end-shoppers. So anything you can tell me this week about what preceded past departures — on the merchant side especially, since that's my piece — is directly useful input, even if it's partial. I'll compare notes with Chloé's and Daniel's analysts before we draft anything for the board.

    Given the slot limits, prioritize in this order: Castell explanation, then the task-force churn pattern, then support/CSAT if a slot remains.

    Thanks, Samuel

    A note from the VP Customer Experience, Daniel Achterberg

    Subject: CX signals for the churn task force

    Arjun,

    A few things I'd want the task force to look at from the shopper/support side:

    1. Does ticket experience precede merchant churn? For merchants who later left, did their shoppers see slower first responses, more reopened tickets, or more escalations in the months before? I'd want to know how far out a warning shows up.
    2. Do returns/refunds friction and churn move together? Where refunds get delayed, disputed, or partially handled, do we see both unhappy shoppers (low ratings/CSAT) and merchants churning shortly after? I'm curious whether refund delay is a leading indicator or just a symptom.
    3. Are complaint themes shifting before a merchant leaves? Is there a recognizable pattern in what shoppers are complaining about (e.g. shipping, product quality, billing) in the weeks before a merchant churns, versus a stable merchant?

    If we can pin down even one or two of these as reliable early signals, it changes what Samuel's team and I should be monitoring week to week rather than finding out after the fact.

    Daniel

    A note from the Head of Growth Marketing, Chloé Marchetti

    Subject: Churn task force — what I'd like us to look at from Growth

    Hi Arjun,

    Glad this is getting cross-functional attention. From the marketing side, here's what I think matters:

    1. Do shoppers who stop engaging with our marketing touches (emails, campaigns, loyalty) churn sooner than the ones who stay engaged? If disengagement shows up weeks before a churn, that's an early signal worth acting on.
    2. Does a merchant's own marketing activity level relate to whether their shoppers stick around? I want to know if merchants who run fewer campaigns, promotions, or loyalty programs are the ones seeing higher shopper churn — that would tell us whether under-investment in marketing tools is part of the story.
    3. Is there a pattern in how promo- or discount-driven shoppers behave right before leaving — e.g. do they go quiet after a coupon or loyalty redemption, or does spend drop off without any further marketing response from us?

    Happy to jump on a call if useful — mainly want to make sure whatever "early warning" signals come out of this include the marketing-touch side, not just support or payment signals.

    Best, Chloé

  3. Arjun Patel plans the night

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

    Isolating a possible length issue - submitting one longer report alone.

    Pre-churn warning signs across sales, repeat buying, refunds, and support tickets
    Question
    In the months before a churned merchant's last order, did sales, shopper repeat-buying, refund rates, or support ticket volume already show warning signs?
    One row per
    merchant (tenant) with status equal to churned
    Columns
    • merchant name
    • industry
    • orders final 90 days
    • orders prior 90 days
    • revenue growth pct
    • repeat rate final 90 days
    • repeat rate prior 90 days
    • refund rate final 90 days
    • refund rate prior 90 days
    • tickets final 90 days
    • tickets prior 90 days
  4. Grace 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 churned_tenants AS (
      SELECT id AS tenant_id, name, industry
      FROM tenants
      WHERE status = 'churned'
    ),
    anchor_dates AS (
      SELECT ct.tenant_id, MAX(o.placed_at) AS anchor_at
      FROM churned_tenants ct
      JOIN orders o ON o.tenant_id = ct.tenant_id AND o.status <> 'cancelled'
      GROUP BY ct.tenant_id
    ),
    windows AS (
      SELECT
        tenant_id,
        anchor_at - interval '90 days' AS final_start,
        anchor_at AS final_end,
        anchor_at - interval '180 days' AS prior_start,
        anchor_at - interval '91 days' AS prior_end
      FROM anchor_dates
    ),
    orders_with_fx AS (
      SELECT
        o.tenant_id, o.id AS order_id, o.user_id, o.placed_at, o.currency_code, o.grand_total,
        er.rate
      FROM orders o
      JOIN churned_tenants ct ON ct.tenant_id = o.tenant_id
      LEFT JOIN exchange_rates er
        ON er.base_code = 'EUR' AND er.quote_code = o.currency_code AND er.as_of = o.placed_at::date
      WHERE o.status <> 'cancelled'
    ),
    orders_classified AS (
      SELECT
        ow.tenant_id,
        ow.order_id,
        ow.user_id,
        CASE WHEN ow.currency_code = 'EUR' THEN ow.grand_total ELSE ow.grand_total / ow.rate END AS revenue_eur,
        CASE
          WHEN ow.placed_at BETWEEN w.final_start AND w.final_end THEN 'final'
          WHEN ow.placed_at BETWEEN w.prior_start AND w.prior_end THEN 'prior'
          ELSE NULL
        END AS period
      FROM orders_with_fx ow
      JOIN windows w ON w.tenant_id = ow.tenant_id
    ),
    order_metrics AS (
      SELECT
        tenant_id,
        COUNT(*) FILTER (WHERE period = 'final') AS orders_final,
        COUNT(*) FILTER (WHERE period = 'prior') AS orders_prior,
        SUM(revenue_eur) FILTER (WHERE period = 'final') AS revenue_final,
        SUM(revenue_eur) FILTER (WHERE period = 'prior') AS revenue_prior
      FROM orders_classified
      GROUP BY tenant_id
    ),
    user_period_counts AS (
      SELECT tenant_id, user_id, period, COUNT(*) AS order_cnt
      FROM orders_classified
      WHERE period IS NOT NULL
      GROUP BY tenant_id, user_id, period
    ),
    repeat_metrics AS (
      SELECT
        tenant_id, period,
        COUNT(*) AS customers_total,
        COUNT(*) FILTER (WHERE order_cnt >= 2) AS customers_repeat
      FROM user_period_counts
      GROUP BY tenant_id, period
    ),
    repeat_pivot AS (
      SELECT
        tenant_id,
        MAX(CASE WHEN period = 'final' THEN customers_repeat::numeric / NULLIF(customers_total, 0) END) AS repeat_rate_final,
        MAX(CASE WHEN period = 'prior' THEN customers_repeat::numeric / NULLIF(customers_total, 0) END) AS repeat_rate_prior
      FROM repeat_metrics
      GROUP BY tenant_id
    ),
    payments_with_fx AS (
      SELECT
        p.tenant_id, p.captured_at, p.currency_code, p.amount, er.rate
      FROM payments p
      JOIN churned_tenants ct ON ct.tenant_id = p.tenant_id
      LEFT JOIN exchange_rates er
        ON er.base_code = 'EUR' AND er.quote_code = p.currency_code AND er.as_of = p.captured_at::date
      WHERE p.captured_at IS NOT NULL
    ),
    payments_classified AS (
      SELECT
        pw.tenant_id,
        CASE WHEN pw.currency_code = 'EUR' THEN pw.amount ELSE pw.amount / pw.rate END AS amount_eur,
        CASE
          WHEN pw.captured_at BETWEEN w.final_start AND w.final_end THEN 'final'
          WHEN pw.captured_at BETWEEN w.prior_start AND w.prior_end THEN 'prior'
          ELSE NULL
        END AS period
      FROM payments_with_fx pw
      JOIN windows w ON w.tenant_id = pw.tenant_id
    ),
    payment_metrics AS (
      SELECT
        tenant_id,
        SUM(amount_eur) FILTER (WHERE period = 'final') AS payments_final,
        SUM(amount_eur) FILTER (WHERE period = 'prior') AS payments_prior
      FROM payments_classified
      GROUP BY tenant_id
    ),
    refunds_with_fx AS (
      SELECT
        r.tenant_id, r.processed_at, r.currency_code, r.amount, er.rate
      FROM payment_refunds r
      JOIN churned_tenants ct ON ct.tenant_id = r.tenant_id
      LEFT JOIN exchange_rates er
        ON er.base_code = 'EUR' AND er.quote_code = r.currency_code AND er.as_of = r.processed_at::date
      WHERE r.status = 'succeeded' AND r.processed_at IS NOT NULL
    ),
    refunds_classified AS (
      SELECT
        rw.tenant_id,
        CASE WHEN rw.currency_code = 'EUR' THEN rw.amount ELSE rw.amount / rw.rate END AS amount_eur,
        CASE
          WHEN rw.processed_at BETWEEN w.final_start AND w.final_end THEN 'final'
          WHEN rw.processed_at BETWEEN w.prior_start AND w.prior_end THEN 'prior'
          ELSE NULL
        END AS period
      FROM refunds_with_fx rw
      JOIN windows w ON w.tenant_id = rw.tenant_id
    ),
    refund_metrics AS (
      SELECT
        tenant_id,
        SUM(amount_eur) FILTER (WHERE period = 'final') AS refunds_final,
        SUM(amount_eur) FILTER (WHERE period = 'prior') AS refunds_prior
      FROM refunds_classified
      GROUP BY tenant_id
    ),
    tickets_classified AS (
      SELECT
        t.tenant_id,
        CASE
          WHEN t.created_at BETWEEN w.final_start AND w.final_end THEN 'final'
          WHEN t.created_at BETWEEN w.prior_start AND w.prior_end THEN 'prior'
          ELSE NULL
        END AS period
      FROM tickets t
      JOIN windows w ON w.tenant_id = t.tenant_id
    ),
    ticket_metrics AS (
      SELECT
        tenant_id,
        COUNT(*) FILTER (WHERE period = 'final') AS tickets_final,
        COUNT(*) FILTER (WHERE period = 'prior') AS tickets_prior
      FROM tickets_classified
      GROUP BY tenant_id
    ),
    report AS (
      SELECT
        ct.tenant_id,
        ct.name AS merchant_name,
        ct.industry,
        COALESCE(om.orders_final, 0) AS orders_final_90d,
        COALESCE(om.orders_prior, 0) AS orders_prior_90d,
        CASE
          WHEN om.revenue_prior IS NULL OR om.revenue_prior = 0 THEN NULL
          ELSE ROUND(((om.revenue_final - om.revenue_prior) / om.revenue_prior) * 100, 1)
        END AS revenue_growth_pct,
        ROUND(rp.repeat_rate_final * 100, 1) AS repeat_rate_final_pct,
        ROUND(rp.repeat_rate_prior * 100, 1) AS repeat_rate_prior_pct,
        CASE
          WHEN pm.payments_final IS NULL OR pm.payments_final = 0 THEN NULL
          ELSE ROUND(COALESCE(rfm.refunds_final, 0) / pm.payments_final * 100, 1)
        END AS refund_rate_final_pct,
        CASE
          WHEN pm.payments_prior IS NULL OR pm.payments_prior = 0 THEN NULL
          ELSE ROUND(COALESCE(rfm.refunds_prior, 0) / pm.payments_prior * 100, 1)
        END AS refund_rate_prior_pct,
        COALESCE(tm.tickets_final, 0) AS tickets_final_90d,
        COALESCE(tm.tickets_prior, 0) AS tickets_prior_90d,
        COUNT(*) OVER () AS total_churned_merchants
      FROM churned_tenants ct
      LEFT JOIN order_metrics om ON om.tenant_id = ct.tenant_id
      LEFT JOIN repeat_pivot rp ON rp.tenant_id = ct.tenant_id
      LEFT JOIN payment_metrics pm ON pm.tenant_id = ct.tenant_id
      LEFT JOIN refund_metrics rfm ON rfm.tenant_id = ct.tenant_id
      LEFT JOIN ticket_metrics tm ON tm.tenant_id = ct.tenant_id
    )
    SELECT
      merchant_name,
      industry,
      orders_final_90d,
      orders_prior_90d,
      revenue_growth_pct,
      repeat_rate_final_pct,
      repeat_rate_prior_pct,
      refund_rate_final_pct,
      refund_rate_prior_pct,
      tickets_final_90d,
      tickets_prior_90d,
      total_churned_merchants
    FROM report
    ORDER BY orders_final_90d ASC, tenant_id ASC
    LIMIT 50

    “Query compiles and produces 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.

    “The report has been submitted to tonight's batch. Summary of approach: - Anchored each churned tenant's analysis window on their own last non-cancelled order date, then built final (0-90 days before) and prior (91-180 days before) windows. - Converted all money (order revenue, payments, refunds) to EUR using each row's own currency and the exchange rate as of that row's date. - Repeat rate = share of distinct customers with ≥2 orders in the window. - Refund rate = succeeded refunds (EUR) ÷ captured payments (EUR) within the same window. - Tickets counted by created_at falling in each window. - Added a total_churned_merchants column so the fixed 50-row cap is traceable to the true population size, as the handbook asks for.”

  5. The report comes back overnight

    As Arjun Patel saw it, from the small dataset the agents worked on

    merchant_name      | industry              | orders_final_90d | orders_prior_90d | revenue_growth_pct | repeat_rate_final_pct | repeat_rate_prior_pct | refund_rate_final_pct | refund_rate_prior_pct | tickets_final_90d | tickets_prior_90d | total_churned_merchants
    -------------------+-----------------------+------------------+------------------+--------------------+-----------------------+-----------------------+-----------------------+-----------------------+-------------------+-------------------+------------------------
    Paddock & Pine     | Apparel & Footwear    |                1 |                0 |               NULL |                   0.0 |                  NULL |                  NULL |                  NULL |                 0 |                 0 |                       9
    Petal & Pestle     | Beauty                |                3 |                4 |               41.4 |                   0.0 |                  50.0 |                   0.0 |                   0.0 |                 0 |                 0 |                       9
    Klara Beauty       | Beauty                |                3 |                0 |               NULL |                 100.0 |                  NULL |                   0.0 |                  NULL |                 0 |                 0 |                       9
    Dewdrop Studio     | Beauty                |                3 |                0 |               NULL |                 100.0 |                  NULL |                   0.0 |                  NULL |                 0 |                 0 |                       9
    Aurum & Ash        | Jewelry & Accessories |                4 |                2 |              280.1 |                  33.3 |                 100.0 |                   0.0 |                   0.0 |                 0 |                 0 |                       9
    Halden Boots       | Apparel & Footwear    |                5 |                2 |              -57.6 |                 100.0 |                   0.0 |                   0.0 |                   0.0 |                 1 |                 0 |                       9
    Marginalia         | Books & Stationery    |                5 |                1 |              117.2 |                  33.3 |                   0.0 |                   0.0 |                   0.0 |                 0 |                 1 |                       9
    Northwire Audio    | Consumer Electronics  |                7 |                0 |               NULL |                 100.0 |                  NULL |                  21.3 |                  NULL |                 0 |                 0 |                       9
    Saltmarsh Knitwear | Apparel & Footwear    |               11 |                3 |               73.5 |                  25.0 |                   0.0 |                  28.8 |                  13.1 |                 0 |                 1 |                       9
    (9 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:

  7. It becomes benchmark query q269

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

    • spellingcast (5×)
    • spellingfetch first
    • numericsdocs/NUMERICS.md: bare NUMERIC cast that only served the rounding removed; intended fraction: integer numerator cast to NUMERIC(18,6) before the division
    • numericsdocs/NUMERICS.md: rounding removed; results keep full precision
    • numericsdocs/NUMERICS.md: rounding removed; results keep full precision
    • numericsdocs/NUMERICS.md: rounding removed; results keep full precision
    • numericsdocs/NUMERICS.md: rounding removed; results keep full precision
    • numericsdocs/NUMERICS.md: x / exchange rate cast to NUMERIC(18,6) (3×)
    • made explicita selected t.col gets AS col, its standard name made explicit (25×)
    • 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×)
    • placeholderfetch first
    The benchmark text, against the submitted SQL
    WITH churned_tenants AS (  SELECT id AS tenant_id, name, industry  FROM tenants  WHERE status = 'churned'),anchor_dates AS (  SELECT ct.tenant_id, MAX(o.placed_at) AS anchor_at  SELECT ct.tenant_id AS tenant_id, MAX(o.placed_at) AS anchor_at  FROM churned_tenants ct  JOIN orders o ON o.tenant_id = ct.tenant_id AND o.status <> 'cancelled'  GROUP BY ct.tenant_id),windows AS (  SELECT    tenant_id,    anchor_at - interval '90 days' AS final_start,    anchor_at AS final_end,    anchor_at - interval '180 days' AS prior_start,    anchor_at - interval '91 days' AS prior_end  FROM anchor_dates),orders_with_fx AS (  SELECT    o.tenant_id, o.id AS order_id, o.user_id, o.placed_at, o.currency_code, o.grand_total,    er.rate    o.tenant_id AS tenant_id, o.id AS order_id, o.user_id AS user_id, o.placed_at AS placed_at, o.currency_code AS currency_code, o.grand_total AS grand_total,    er.rate AS rate  FROM orders o  JOIN churned_tenants ct ON ct.tenant_id = o.tenant_id  LEFT JOIN exchange_rates er    ON er.base_code = 'EUR' AND er.quote_code = o.currency_code AND er.as_of = o.placed_at::date    ON er.base_code = 'EUR' AND er.quote_code = o.currency_code AND er.as_of = CAST(o.placed_at AS date)  WHERE o.status <> 'cancelled'),orders_classified AS (  SELECT    ow.tenant_id,    ow.order_id,    ow.user_id,    CASE WHEN ow.currency_code = 'EUR' THEN ow.grand_total ELSE ow.grand_total / ow.rate END AS revenue_eur,    ow.tenant_id AS tenant_id,    ow.order_id AS order_id,    ow.user_id AS user_id,    CASE WHEN ow.currency_code = 'EUR' THEN ow.grand_total ELSE CAST(ow.grand_total / ow.rate AS NUMERIC(18,6)) END AS revenue_eur,    CASE      WHEN ow.placed_at BETWEEN w.final_start AND w.final_end THEN 'final'      WHEN ow.placed_at BETWEEN w.prior_start AND w.prior_end THEN 'prior'      ELSE NULL    END AS period  FROM orders_with_fx ow  JOIN windows w ON w.tenant_id = ow.tenant_id),order_metrics AS (  SELECT    tenant_id,    COUNT(*) FILTER (WHERE period = 'final') AS orders_final,    COUNT(*) FILTER (WHERE period = 'prior') AS orders_prior,    SUM(revenue_eur) FILTER (WHERE period = 'final') AS revenue_final,    SUM(revenue_eur) FILTER (WHERE period = 'prior') AS revenue_prior  FROM orders_classified  GROUP BY tenant_id),user_period_counts AS (  SELECT tenant_id, user_id, period, COUNT(*) AS order_cnt  FROM orders_classified  WHERE period IS NOT NULL  GROUP BY tenant_id, user_id, period),repeat_metrics AS (  SELECT    tenant_id, period,    COUNT(*) AS customers_total,    COUNT(*) FILTER (WHERE order_cnt >= 2) AS customers_repeat  FROM user_period_counts  GROUP BY tenant_id, period),repeat_pivot AS (  SELECT    tenant_id,    MAX(CASE WHEN period = 'final' THEN customers_repeat::numeric / NULLIF(customers_total, 0) END) AS repeat_rate_final,    MAX(CASE WHEN period = 'prior' THEN customers_repeat::numeric / NULLIF(customers_total, 0) END) AS repeat_rate_prior    MAX(CASE WHEN period = 'final' THEN CAST(customers_repeat AS numeric(18,6)) / NULLIF(customers_total, 0) END) AS repeat_rate_final,    MAX(CASE WHEN period = 'prior' THEN CAST(customers_repeat AS numeric(18,6)) / NULLIF(customers_total, 0) END) AS repeat_rate_prior  FROM repeat_metrics  GROUP BY tenant_id),payments_with_fx AS (  SELECT    p.tenant_id, p.captured_at, p.currency_code, p.amount, er.rate    p.tenant_id AS tenant_id, p.captured_at AS captured_at, p.currency_code AS currency_code, p.amount AS amount, er.rate AS rate  FROM payments p  JOIN churned_tenants ct ON ct.tenant_id = p.tenant_id  LEFT JOIN exchange_rates er    ON er.base_code = 'EUR' AND er.quote_code = p.currency_code AND er.as_of = p.captured_at::date    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 IS NOT NULL),payments_classified AS (  SELECT    pw.tenant_id,    CASE WHEN pw.currency_code = 'EUR' THEN pw.amount ELSE pw.amount / pw.rate END AS amount_eur,    pw.tenant_id AS tenant_id,    CASE WHEN pw.currency_code = 'EUR' THEN pw.amount ELSE CAST(pw.amount / pw.rate AS NUMERIC(18,6)) END AS amount_eur,    CASE      WHEN pw.captured_at BETWEEN w.final_start AND w.final_end THEN 'final'      WHEN pw.captured_at BETWEEN w.prior_start AND w.prior_end THEN 'prior'      ELSE NULL    END AS period  FROM payments_with_fx pw  JOIN windows w ON w.tenant_id = pw.tenant_id),payment_metrics AS (  SELECT    tenant_id,    SUM(amount_eur) FILTER (WHERE period = 'final') AS payments_final,    SUM(amount_eur) FILTER (WHERE period = 'prior') AS payments_prior  FROM payments_classified  GROUP BY tenant_id),refunds_with_fx AS (  SELECT    r.tenant_id, r.processed_at, r.currency_code, r.amount, er.rate    r.tenant_id AS tenant_id, r.processed_at AS processed_at, r.currency_code AS currency_code, r.amount AS amount, er.rate AS rate  FROM payment_refunds r  JOIN churned_tenants ct ON ct.tenant_id = r.tenant_id  LEFT JOIN exchange_rates er    ON er.base_code = 'EUR' AND er.quote_code = r.currency_code AND er.as_of = r.processed_at::date    ON er.base_code = 'EUR' AND er.quote_code = r.currency_code AND er.as_of = CAST(r.processed_at AS date)  WHERE r.status = 'succeeded' AND r.processed_at IS NOT NULL),refunds_classified AS (  SELECT    rw.tenant_id,    CASE WHEN rw.currency_code = 'EUR' THEN rw.amount ELSE rw.amount / rw.rate END AS amount_eur,    rw.tenant_id AS tenant_id,    CASE WHEN rw.currency_code = 'EUR' THEN rw.amount ELSE CAST(rw.amount / rw.rate AS NUMERIC(18,6)) END AS amount_eur,    CASE      WHEN rw.processed_at BETWEEN w.final_start AND w.final_end THEN 'final'      WHEN rw.processed_at BETWEEN w.prior_start AND w.prior_end THEN 'prior'      ELSE NULL    END AS period  FROM refunds_with_fx rw  JOIN windows w ON w.tenant_id = rw.tenant_id),refund_metrics AS (  SELECT    tenant_id,    SUM(amount_eur) FILTER (WHERE period = 'final') AS refunds_final,    SUM(amount_eur) FILTER (WHERE period = 'prior') AS refunds_prior  FROM refunds_classified  GROUP BY tenant_id),tickets_classified AS (  SELECT    t.tenant_id,    t.tenant_id AS tenant_id,    CASE      WHEN t.created_at BETWEEN w.final_start AND w.final_end THEN 'final'      WHEN t.created_at BETWEEN w.prior_start AND w.prior_end THEN 'prior'      ELSE NULL    END AS period  FROM tickets t  JOIN windows w ON w.tenant_id = t.tenant_id),ticket_metrics AS (  SELECT    tenant_id,    COUNT(*) FILTER (WHERE period = 'final') AS tickets_final,    COUNT(*) FILTER (WHERE period = 'prior') AS tickets_prior  FROM tickets_classified  GROUP BY tenant_id),report AS (  SELECT    ct.tenant_id,    ct.tenant_id AS tenant_id,    ct.name AS merchant_name,    ct.industry,    ct.industry AS industry,    COALESCE(om.orders_final, 0) AS orders_final_90d,    COALESCE(om.orders_prior, 0) AS orders_prior_90d,    CASE      WHEN om.revenue_prior IS NULL OR om.revenue_prior = 0 THEN NULL      ELSE ROUND(((om.revenue_final - om.revenue_prior) / om.revenue_prior) * 100, 1)      ELSE ((om.revenue_final - om.revenue_prior) / om.revenue_prior) * 100    END AS revenue_growth_pct,    ROUND(rp.repeat_rate_final * 100, 1) AS repeat_rate_final_pct,    ROUND(rp.repeat_rate_prior * 100, 1) AS repeat_rate_prior_pct,    rp.repeat_rate_final * 100 AS repeat_rate_final_pct,    rp.repeat_rate_prior * 100 AS repeat_rate_prior_pct,    CASE      WHEN pm.payments_final IS NULL OR pm.payments_final = 0 THEN NULL      ELSE ROUND(COALESCE(rfm.refunds_final, 0) / pm.payments_final * 100, 1)      ELSE COALESCE(rfm.refunds_final, 0) / pm.payments_final * 100    END AS refund_rate_final_pct,    CASE      WHEN pm.payments_prior IS NULL OR pm.payments_prior = 0 THEN NULL      ELSE ROUND(COALESCE(rfm.refunds_prior, 0) / pm.payments_prior * 100, 1)      ELSE COALESCE(rfm.refunds_prior, 0) / pm.payments_prior * 100    END AS refund_rate_prior_pct,    COALESCE(tm.tickets_final, 0) AS tickets_final_90d,    COALESCE(tm.tickets_prior, 0) AS tickets_prior_90d,    COUNT(*) OVER () AS total_churned_merchants  FROM churned_tenants ct  LEFT JOIN order_metrics om ON om.tenant_id = ct.tenant_id  LEFT JOIN repeat_pivot rp ON rp.tenant_id = ct.tenant_id  LEFT JOIN payment_metrics pm ON pm.tenant_id = ct.tenant_id  LEFT JOIN refund_metrics rfm ON rfm.tenant_id = ct.tenant_id  LEFT JOIN ticket_metrics tm ON tm.tenant_id = ct.tenant_id)SELECT  merchant_name,  industry,  orders_final_90d,  orders_prior_90d,  revenue_growth_pct,  repeat_rate_final_pct,  repeat_rate_prior_pct,  refund_rate_final_pct,  refund_rate_prior_pct,  tickets_final_90d,  tickets_prior_90d,  total_churned_merchantsFROM reportORDER BY orders_final_90d ASC, tenant_id ASCLIMIT 50ORDER BY orders_final_90d ASC NULLS LAST, tenant_id ASC NULLS LAST{{fetch_first(50)}}
    WITH churned_tenants AS (
      SELECT id AS tenant_id, name, industry
      FROM tenants
      WHERE status = 'churned'
    ),
    anchor_dates AS (
      SELECT ct.tenant_id AS tenant_id, MAX(o.placed_at) AS anchor_at
      FROM churned_tenants ct
      JOIN orders o ON o.tenant_id = ct.tenant_id AND o.status <> 'cancelled'
      GROUP BY ct.tenant_id
    ),
    windows AS (
      SELECT
        tenant_id,
        anchor_at - interval '90 days' AS final_start,
        anchor_at AS final_end,
        anchor_at - interval '180 days' AS prior_start,
        anchor_at - interval '91 days' AS prior_end
      FROM anchor_dates
    ),
    orders_with_fx AS (
      SELECT
        o.tenant_id AS tenant_id, o.id AS order_id, o.user_id AS user_id, o.placed_at AS placed_at, o.currency_code AS currency_code, o.grand_total AS grand_total,
        er.rate AS rate
      FROM orders o
      JOIN churned_tenants ct ON ct.tenant_id = o.tenant_id
      LEFT JOIN exchange_rates er
        ON er.base_code = 'EUR' AND er.quote_code = o.currency_code AND er.as_of = CAST(o.placed_at AS date)
      WHERE o.status <> 'cancelled'
    ),
    orders_classified AS (
      SELECT
        ow.tenant_id AS tenant_id,
        ow.order_id AS order_id,
        ow.user_id AS user_id,
        CASE WHEN ow.currency_code = 'EUR' THEN ow.grand_total ELSE CAST(ow.grand_total / ow.rate AS NUMERIC(18,6)) END AS revenue_eur,
        CASE
          WHEN ow.placed_at BETWEEN w.final_start AND w.final_end THEN 'final'
          WHEN ow.placed_at BETWEEN w.prior_start AND w.prior_end THEN 'prior'
          ELSE NULL
        END AS period
      FROM orders_with_fx ow
      JOIN windows w ON w.tenant_id = ow.tenant_id
    ),
    order_metrics AS (
      SELECT
        tenant_id,
        COUNT(*) FILTER (WHERE period = 'final') AS orders_final,
        COUNT(*) FILTER (WHERE period = 'prior') AS orders_prior,
        SUM(revenue_eur) FILTER (WHERE period = 'final') AS revenue_final,
        SUM(revenue_eur) FILTER (WHERE period = 'prior') AS revenue_prior
      FROM orders_classified
      GROUP BY tenant_id
    ),
    user_period_counts AS (
      SELECT tenant_id, user_id, period, COUNT(*) AS order_cnt
      FROM orders_classified
      WHERE period IS NOT NULL
      GROUP BY tenant_id, user_id, period
    ),
    repeat_metrics AS (
      SELECT
        tenant_id, period,
        COUNT(*) AS customers_total,
        COUNT(*) FILTER (WHERE order_cnt >= 2) AS customers_repeat
      FROM user_period_counts
      GROUP BY tenant_id, period
    ),
    repeat_pivot AS (
      SELECT
        tenant_id,
        MAX(CASE WHEN period = 'final' THEN CAST(customers_repeat AS numeric(18,6)) / NULLIF(customers_total, 0) END) AS repeat_rate_final,
        MAX(CASE WHEN period = 'prior' THEN CAST(customers_repeat AS numeric(18,6)) / NULLIF(customers_total, 0) END) AS repeat_rate_prior
      FROM repeat_metrics
      GROUP BY tenant_id
    ),
    payments_with_fx AS (
      SELECT
        p.tenant_id AS tenant_id, p.captured_at AS captured_at, p.currency_code AS currency_code, p.amount AS amount, er.rate AS rate
      FROM payments p
      JOIN churned_tenants ct ON ct.tenant_id = p.tenant_id
      LEFT 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 IS NOT NULL
    ),
    payments_classified AS (
      SELECT
        pw.tenant_id AS tenant_id,
        CASE WHEN pw.currency_code = 'EUR' THEN pw.amount ELSE CAST(pw.amount / pw.rate AS NUMERIC(18,6)) END AS amount_eur,
        CASE
          WHEN pw.captured_at BETWEEN w.final_start AND w.final_end THEN 'final'
          WHEN pw.captured_at BETWEEN w.prior_start AND w.prior_end THEN 'prior'
          ELSE NULL
        END AS period
      FROM payments_with_fx pw
      JOIN windows w ON w.tenant_id = pw.tenant_id
    ),
    payment_metrics AS (
      SELECT
        tenant_id,
        SUM(amount_eur) FILTER (WHERE period = 'final') AS payments_final,
        SUM(amount_eur) FILTER (WHERE period = 'prior') AS payments_prior
      FROM payments_classified
      GROUP BY tenant_id
    ),
    refunds_with_fx AS (
      SELECT
        r.tenant_id AS tenant_id, r.processed_at AS processed_at, r.currency_code AS currency_code, r.amount AS amount, er.rate AS rate
      FROM payment_refunds r
      JOIN churned_tenants ct ON ct.tenant_id = r.tenant_id
      LEFT JOIN exchange_rates er
        ON er.base_code = 'EUR' AND er.quote_code = r.currency_code AND er.as_of = CAST(r.processed_at AS date)
      WHERE r.status = 'succeeded' AND r.processed_at IS NOT NULL
    ),
    refunds_classified AS (
      SELECT
        rw.tenant_id AS tenant_id,
        CASE WHEN rw.currency_code = 'EUR' THEN rw.amount ELSE CAST(rw.amount / rw.rate AS NUMERIC(18,6)) END AS amount_eur,
        CASE
          WHEN rw.processed_at BETWEEN w.final_start AND w.final_end THEN 'final'
          WHEN rw.processed_at BETWEEN w.prior_start AND w.prior_end THEN 'prior'
          ELSE NULL
        END AS period
      FROM refunds_with_fx rw
      JOIN windows w ON w.tenant_id = rw.tenant_id
    ),
    refund_metrics AS (
      SELECT
        tenant_id,
        SUM(amount_eur) FILTER (WHERE period = 'final') AS refunds_final,
        SUM(amount_eur) FILTER (WHERE period = 'prior') AS refunds_prior
      FROM refunds_classified
      GROUP BY tenant_id
    ),
    tickets_classified AS (
      SELECT
        t.tenant_id AS tenant_id,
        CASE
          WHEN t.created_at BETWEEN w.final_start AND w.final_end THEN 'final'
          WHEN t.created_at BETWEEN w.prior_start AND w.prior_end THEN 'prior'
          ELSE NULL
        END AS period
      FROM tickets t
      JOIN windows w ON w.tenant_id = t.tenant_id
    ),
    ticket_metrics AS (
      SELECT
        tenant_id,
        COUNT(*) FILTER (WHERE period = 'final') AS tickets_final,
        COUNT(*) FILTER (WHERE period = 'prior') AS tickets_prior
      FROM tickets_classified
      GROUP BY tenant_id
    ),
    report AS (
      SELECT
        ct.tenant_id AS tenant_id,
        ct.name AS merchant_name,
        ct.industry AS industry,
        COALESCE(om.orders_final, 0) AS orders_final_90d,
        COALESCE(om.orders_prior, 0) AS orders_prior_90d,
        CASE
          WHEN om.revenue_prior IS NULL OR om.revenue_prior = 0 THEN NULL
          ELSE ((om.revenue_final - om.revenue_prior) / om.revenue_prior) * 100
        END AS revenue_growth_pct,
        rp.repeat_rate_final * 100 AS repeat_rate_final_pct,
        rp.repeat_rate_prior * 100 AS repeat_rate_prior_pct,
        CASE
          WHEN pm.payments_final IS NULL OR pm.payments_final = 0 THEN NULL
          ELSE COALESCE(rfm.refunds_final, 0) / pm.payments_final * 100
        END AS refund_rate_final_pct,
        CASE
          WHEN pm.payments_prior IS NULL OR pm.payments_prior = 0 THEN NULL
          ELSE COALESCE(rfm.refunds_prior, 0) / pm.payments_prior * 100
        END AS refund_rate_prior_pct,
        COALESCE(tm.tickets_final, 0) AS tickets_final_90d,
        COALESCE(tm.tickets_prior, 0) AS tickets_prior_90d,
        COUNT(*) OVER () AS total_churned_merchants
      FROM churned_tenants ct
      LEFT JOIN order_metrics om ON om.tenant_id = ct.tenant_id
      LEFT JOIN repeat_pivot rp ON rp.tenant_id = ct.tenant_id
      LEFT JOIN payment_metrics pm ON pm.tenant_id = ct.tenant_id
      LEFT JOIN refund_metrics rfm ON rfm.tenant_id = ct.tenant_id
      LEFT JOIN ticket_metrics tm ON tm.tenant_id = ct.tenant_id
    )
    SELECT
      merchant_name,
      industry,
      orders_final_90d,
      orders_prior_90d,
      revenue_growth_pct,
      repeat_rate_final_pct,
      repeat_rate_prior_pct,
      refund_rate_final_pct,
      refund_rate_prior_pct,
      tickets_final_90d,
      tickets_prior_90d,
      total_churned_merchants
    FROM report
    ORDER BY orders_final_90d ASC NULLS LAST, tenant_id ASC NULLS LAST
    FETCH FIRST 50 ROWS ONLY
    10 ms100 ms1 sHyper30.5 msDuckDB28.0 msGizmoSQL43.8 msclickhouse_brew429 msFirebolterrorDataFusion582 msClickHouseerror
    • Firebolt{ "errors": [ { "description": "Line 36, Column 85: 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": "3ba9e304-7b30-4555-b302-0af630e0cce9", "query_lab
    • ClickHouseCode: 69. DB::Exception: Decimal result's scale is less than argument's one: while executing function divide on arguments __table6.grand_total Decimal(14, 4) Decimal64(size = 0), __table6.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

    Grace's whole session, 7 turns
    • The request, as Grace received it
      Report request from Arjun Patel (analyst for the Head of Merchant Success):
      
      Title: Pre-churn warning signs across sales, repeat buying, refunds, and support tickets
      Question: In the months before a churned merchant's last order, did sales, shopper repeat-buying, refund rates, or support ticket volume already show warning signs?
      Grain: one row per merchant (tenant) with status equal to churned
      Columns:
      - merchant name
      - industry
      - orders final 90 days
      - orders prior 90 days
      - revenue growth pct
      - repeat rate final 90 days
      - repeat rate prior 90 days
      - refund rate final 90 days
      - refund rate prior 90 days
      - tickets final 90 days
      - tickets prior 90 days
      Details: For each churned tenant, anchor date is that tenant's own max orders.placed_at excluding cancelled orders. Final window is anchor minus 90 days through anchor. Prior window is anchor minus 180 days through anchor minus 91 days. Orders and revenue from orders table, converted to EUR via exchange_rates, excluding cancelled orders; revenue growth pct is final revenue minus prior revenue, divided by prior revenue, blank if prior is zero. Repeat rate is distinct customers with at least two orders divided by distinct customers with at least one order, per window. Refund rate is sum of payment_refunds.amount EUR divided by sum of payments.amount EUR captured in that window. Tickets is count of tickets rows with created_at in that window for that tenant. Order by orders final 90 days ascending, tie-break tenant_id. Cap 50 rows, note total count.
      
      Check your query with the warehouse's dry_run tool, then hand it in with submit.
    • Grace calls dry_run
      WITH churned_tenants AS (
        SELECT id AS tenant_id, name, industry
        FROM tenants
        WHERE status = 'churned'
      ),
      anchor_dates AS (
        SELECT ct.tenant_id, MAX(o.placed_at) AS anchor_at
        FROM churned_tenants ct
        JOIN orders o ON o.tenant_id = ct.tenant_id AND o.status <> 'cancelled'
        GROUP BY ct.tenant_id
      ),
      windows AS (
        SELECT
          tenant_id,
          anchor_at - interval '90 days' AS final_start,
          anchor_at AS final_end,
          anchor_at - interval '180 days' AS prior_start,
          anchor_at - interval '91 days' AS prior_end
        FROM anchor_dates
      ),
      orders_with_fx AS (
        SELECT
          o.tenant_id, o.id AS order_id, o.user_id, o.placed_at, o.currency_code, o.grand_total,
          er.rate
        FROM orders o
        JOIN churned_tenants ct ON ct.tenant_id = o.tenant_id
        LEFT JOIN exchange_rates er
          ON er.base_code = 'EUR' AND er.quote_code = o.currency_code AND er.as_of = o.placed_at::date
        WHERE o.status <> 'cancelled'
      ),
      orders_classified AS (
        SELECT
          ow.tenant_id,
          ow.order_id,
          ow.user_id,
          CASE WHEN ow.currency_code = 'EUR' THEN ow.grand_total ELSE ow.grand_total / ow.rate END AS revenue_eur,
          CASE
            WHEN ow.placed_at BETWEEN w.final_start AND w.final_end THEN 'final'
            WHEN ow.placed_at BETWEEN w.prior_start AND w.prior_end THEN 'prior'
            ELSE NULL
          END AS period
        FROM orders_with_fx ow
        JOIN windows w ON w.tenant_id = ow.tenant_id
      ),
      order_metrics AS (
        SELECT
          tenant_id,
          COUNT(*) FILTER (WHERE period = 'final') AS orders_final,
          COUNT(*) FILTER (WHERE period = 'prior') AS orders_prior,
          SUM(revenue_eur) FILTER (WHERE period = 'final') AS revenue_final,
          SUM(revenue_eur) FILTER (WHERE period = 'prior') AS revenue_prior
        FROM orders_classified
        GROUP BY tenant_id
      ),
      user_period_counts AS (
        SELECT tenant_id, user_id, period, COUNT(*) AS order_cnt
        FROM orders_classified
        WHERE period IS NOT NULL
        GROUP BY tenant_id, user_id, period
      ),
      repeat_metrics AS (
        SELECT
          tenant_id, period,
          COUNT(*) AS customers_total,
          COUNT(*) FILTER (WHERE order_cnt >= 2) AS customers_repeat
        FROM user_period_counts
        GROUP BY tenant_id, period
      ),
      repeat_pivot AS (
        SELECT
          tenant_id,
          MAX(CASE WHEN period = 'final' THEN customers_repeat::numeric / NULLIF(customers_total, 0) END) AS repeat_rate_final,
          MAX(CASE WHEN period = 'prior' THEN customers_repeat::numeric / NULLIF(customers_total, 0) END) AS repeat_rate_prior
        FROM repeat_metrics
        GROUP BY tenant_id
      ),
      payments_with_fx AS (
        SELECT
          p.tenant_id, p.captured_at, p.currency_code, p.amount, er.rate
        FROM payments p
        JOIN churned_tenants ct ON ct.tenant_id = p.tenant_id
        LEFT JOIN exchange_rates er
          ON er.base_code = 'EUR' AND er.quote_code = p.currency_code AND er.as_of = p.captured_at::date
        WHERE p.captured_at IS NOT NULL
      ),
      payments_classified AS (
        SELECT
          pw.tenant_id,
          CASE WHEN pw.currency_code = 'EUR' THEN pw.amount ELSE pw.amount / pw.rate END AS amount_eur,
          CASE
            WHEN pw.captured_at BETWEEN w.final_start AND w.final_end THEN 'final'
            WHEN pw.captured_at BETWEEN w.prior_start AND w.prior_end THEN 'prior'
            ELSE NULL
          END AS period
        FROM payments_with_fx pw
        JOIN windows w ON w.tenant_id = pw.tenant_id
      ),
      payment_metrics AS (
        SELECT
          tenant_id,
          SUM(amount_eur) FILTER (WHERE period = 'final') AS payments_final,
          SUM(amount_eur) FILTER (WHERE period = 'prior') AS payments_prior
        FROM payments_classified
        GROUP BY tenant_id
      ),
      refunds_with_fx AS (
        SELECT
          r.tenant_id, r.processed_at, r.currency_code, r.amount, er.rate
        FROM payment_refunds r
        JOIN churned_tenants ct ON ct.tenant_id = r.tenant_id
        LEFT JOIN exchange_rates er
          ON er.base_code = 'EUR' AND er.quote_code = r.currency_code AND er.as_of = r.processed_at::date
        WHERE r.status = 'succeeded' AND r.processed_at IS NOT NULL
      ),
      refunds_classified AS (
        SELECT
          rw.tenant_id,
          CASE WHEN rw.currency_code = 'EUR' THEN rw.amount ELSE rw.amount / rw.rate END AS amount_eur,
          CASE
            WHEN rw.processed_at BETWEEN w.final_start AND w.final_end THEN 'final'
            WHEN rw.processed_at BETWEEN w.prior_start AND w.prior_end THEN 'prior'
            ELSE NULL
          END AS period
        FROM refunds_with_fx rw
        JOIN windows w ON w.tenant_id = rw.tenant_id
      ),
      refund_metrics AS (
        SELECT
          tenant_id,
          SUM(amount_eur) FILTER (WHERE period = 'final') AS refunds_final,
          SUM(amount_eur) FILTER (WHERE period = 'prior') AS refunds_prior
        FROM refunds_classified
        GROUP BY tenant_id
      ),
      tickets_classified AS (
        SELECT
          t.tenant_id,
          CASE
            WHEN t.created_at BETWEEN w.final_start AND w.final_end THEN 'final'
            WHEN t.created_at BETWEEN w.prior_start AND w.prior_end THEN 'prior'
            ELSE NULL
          END AS period
        FROM tickets t
        JOIN windows w ON w.tenant_id = t.tenant_id
      ),
      ticket_metrics AS (
        SELECT
          tenant_id,
          COUNT(*) FILTER (WHERE period = 'final') AS tickets_final,
          COUNT(*) FILTER (WHERE period = 'prior') AS tickets_prior
        FROM tickets_classified
        GROUP BY tenant_id
      ),
      report AS (
        SELECT
          ct.tenant_id,
          ct.name AS merchant_name,
          ct.industry,
          COALESCE(om.orders_final, 0) AS orders_final_90d,
          COALESCE(om.orders_prior, 0) AS orders_prior_90d,
          CASE
            WHEN om.revenue_prior IS NULL OR om.revenue_prior = 0 THEN NULL
            ELSE ROUND(((om.revenue_final - om.revenue_prior) / om.revenue_prior) * 100, 1)
          END AS revenue_growth_pct,
          ROUND(rp.repeat_rate_final * 100, 1) AS repeat_rate_final_pct,
          ROUND(rp.repeat_rate_prior * 100, 1) AS repeat_rate_prior_pct,
          CASE
            WHEN pm.payments_final IS NULL OR pm.payments_final = 0 THEN NULL
            ELSE ROUND(COALESCE(rfm.refunds_final, 0) / pm.payments_final * 100, 1)
          END AS refund_rate_final_pct,
          CASE
            WHEN pm.payments_prior IS NULL OR pm.payments_prior = 0 THEN NULL
            ELSE ROUND(COALESCE(rfm.refunds_prior, 0) / pm.payments_prior * 100, 1)
          END AS refund_rate_prior_pct,
          COALESCE(tm.tickets_final, 0) AS tickets_final_90d,
          COALESCE(tm.tickets_prior, 0) AS tickets_prior_90d,
          COUNT(*) OVER () AS total_churned_merchants
        FROM churned_tenants ct
        LEFT JOIN order_metrics om ON om.tenant_id = ct.tenant_id
        LEFT JOIN repeat_pivot rp ON rp.tenant_id = ct.tenant_id
        LEFT JOIN payment_metrics pm ON pm.tenant_id = ct.tenant_id
        LEFT JOIN refund_metrics rfm ON rfm.tenant_id = ct.tenant_id
        LEFT JOIN ticket_metrics tm ON tm.tenant_id = ct.tenant_id
      )
      SELECT
        merchant_name,
        industry,
        orders_final_90d,
        orders_prior_90d,
        revenue_growth_pct,
        repeat_rate_final_pct,
        repeat_rate_prior_pct,
        refund_rate_final_pct,
        refund_rate_prior_pct,
        tickets_final_90d,
        tickets_prior_90d,
        total_churned_merchants
      FROM report
      ORDER BY orders_final_90d ASC, tenant_id ASC
      LIMIT 50
    • The warehouse answers
      STATUS: COMPILES (dry run on the empty schema, no data read)
      COLUMNS: merchant_name, industry, orders_final_90d, orders_prior_90d, revenue_growth_pct, repeat_rate_final_pct, repeat_rate_prior_pct, refund_rate_final_pct, refund_rate_prior_pct, tickets_final_90d, tickets_prior_90d, total_churned_merchants
    • GraceQuery compiles and produces the expected columns. Submitting now.
    • Grace calls submit
      WITH churned_tenants AS (
        SELECT id AS tenant_id, name, industry
        FROM tenants
        WHERE status = 'churned'
      ),
      anchor_dates AS (
        SELECT ct.tenant_id, MAX(o.placed_at) AS anchor_at
        FROM churned_tenants ct
        JOIN orders o ON o.tenant_id = ct.tenant_id AND o.status <> 'cancelled'
        GROUP BY ct.tenant_id
      ),
      windows AS (
        SELECT
          tenant_id,
          anchor_at - interval '90 days' AS final_start,
          anchor_at AS final_end,
          anchor_at - interval '180 days' AS prior_start,
          anchor_at - interval '91 days' AS prior_end
        FROM anchor_dates
      ),
      orders_with_fx AS (
        SELECT
          o.tenant_id, o.id AS order_id, o.user_id, o.placed_at, o.currency_code, o.grand_total,
          er.rate
        FROM orders o
        JOIN churned_tenants ct ON ct.tenant_id = o.tenant_id
        LEFT JOIN exchange_rates er
          ON er.base_code = 'EUR' AND er.quote_code = o.currency_code AND er.as_of = o.placed_at::date
        WHERE o.status <> 'cancelled'
      ),
      orders_classified AS (
        SELECT
          ow.tenant_id,
          ow.order_id,
          ow.user_id,
          CASE WHEN ow.currency_code = 'EUR' THEN ow.grand_total ELSE ow.grand_total / ow.rate END AS revenue_eur,
          CASE
            WHEN ow.placed_at BETWEEN w.final_start AND w.final_end THEN 'final'
            WHEN ow.placed_at BETWEEN w.prior_start AND w.prior_end THEN 'prior'
            ELSE NULL
          END AS period
        FROM orders_with_fx ow
        JOIN windows w ON w.tenant_id = ow.tenant_id
      ),
      order_metrics AS (
        SELECT
          tenant_id,
          COUNT(*) FILTER (WHERE period = 'final') AS orders_final,
          COUNT(*) FILTER (WHERE period = 'prior') AS orders_prior,
          SUM(revenue_eur) FILTER (WHERE period = 'final') AS revenue_final,
          SUM(revenue_eur) FILTER (WHERE period = 'prior') AS revenue_prior
        FROM orders_classified
        GROUP BY tenant_id
      ),
      user_period_counts AS (
        SELECT tenant_id, user_id, period, COUNT(*) AS order_cnt
        FROM orders_classified
        WHERE period IS NOT NULL
        GROUP BY tenant_id, user_id, period
      ),
      repeat_metrics AS (
        SELECT
          tenant_id, period,
          COUNT(*) AS customers_total,
          COUNT(*) FILTER (WHERE order_cnt >= 2) AS customers_repeat
        FROM user_period_counts
        GROUP BY tenant_id, period
      ),
      repeat_pivot AS (
        SELECT
          tenant_id,
          MAX(CASE WHEN period = 'final' THEN customers_repeat::numeric / NULLIF(customers_total, 0) END) AS repeat_rate_final,
          MAX(CASE WHEN period = 'prior' THEN customers_repeat::numeric / NULLIF(customers_total, 0) END) AS repeat_rate_prior
        FROM repeat_metrics
        GROUP BY tenant_id
      ),
      payments_with_fx AS (
        SELECT
          p.tenant_id, p.captured_at, p.currency_code, p.amount, er.rate
        FROM payments p
        JOIN churned_tenants ct ON ct.tenant_id = p.tenant_id
        LEFT JOIN exchange_rates er
          ON er.base_code = 'EUR' AND er.quote_code = p.currency_code AND er.as_of = p.captured_at::date
        WHERE p.captured_at IS NOT NULL
      ),
      payments_classified AS (
        SELECT
          pw.tenant_id,
          CASE WHEN pw.currency_code = 'EUR' THEN pw.amount ELSE pw.amount / pw.rate END AS amount_eur,
          CASE
            WHEN pw.captured_at BETWEEN w.final_start AND w.final_end THEN 'final'
            WHEN pw.captured_at BETWEEN w.prior_start AND w.prior_end THEN 'prior'
            ELSE NULL
          END AS period
        FROM payments_with_fx pw
        JOIN windows w ON w.tenant_id = pw.tenant_id
      ),
      payment_metrics AS (
        SELECT
          tenant_id,
          SUM(amount_eur) FILTER (WHERE period = 'final') AS payments_final,
          SUM(amount_eur) FILTER (WHERE period = 'prior') AS payments_prior
        FROM payments_classified
        GROUP BY tenant_id
      ),
      refunds_with_fx AS (
        SELECT
          r.tenant_id, r.processed_at, r.currency_code, r.amount, er.rate
        FROM payment_refunds r
        JOIN churned_tenants ct ON ct.tenant_id = r.tenant_id
        LEFT JOIN exchange_rates er
          ON er.base_code = 'EUR' AND er.quote_code = r.currency_code AND er.as_of = r.processed_at::date
        WHERE r.status = 'succeeded' AND r.processed_at IS NOT NULL
      ),
      refunds_classified AS (
        SELECT
          rw.tenant_id,
          CASE WHEN rw.currency_code = 'EUR' THEN rw.amount ELSE rw.amount / rw.rate END AS amount_eur,
          CASE
            WHEN rw.processed_at BETWEEN w.final_start AND w.final_end THEN 'final'
            WHEN rw.processed_at BETWEEN w.prior_start AND w.prior_end THEN 'prior'
            ELSE NULL
          END AS period
        FROM refunds_with_fx rw
        JOIN windows w ON w.tenant_id = rw.tenant_id
      ),
      refund_metrics AS (
        SELECT
          tenant_id,
          SUM(amount_eur) FILTER (WHERE period = 'final') AS refunds_final,
          SUM(amount_eur) FILTER (WHERE period = 'prior') AS refunds_prior
        FROM refunds_classified
        GROUP BY tenant_id
      ),
      tickets_classified AS (
        SELECT
          t.tenant_id,
          CASE
            WHEN t.created_at BETWEEN w.final_start AND w.final_end THEN 'final'
            WHEN t.created_at BETWEEN w.prior_start AND w.prior_end THEN 'prior'
            ELSE NULL
          END AS period
        FROM tickets t
        JOIN windows w ON w.tenant_id = t.tenant_id
      ),
      ticket_metrics AS (
        SELECT
          tenant_id,
          COUNT(*) FILTER (WHERE period = 'final') AS tickets_final,
          COUNT(*) FILTER (WHERE period = 'prior') AS tickets_prior
        FROM tickets_classified
        GROUP BY tenant_id
      ),
      report AS (
        SELECT
          ct.tenant_id,
          ct.name AS merchant_name,
          ct.industry,
          COALESCE(om.orders_final, 0) AS orders_final_90d,
          COALESCE(om.orders_prior, 0) AS orders_prior_90d,
          CASE
            WHEN om.revenue_prior IS NULL OR om.revenue_prior = 0 THEN NULL
            ELSE ROUND(((om.revenue_final - om.revenue_prior) / om.revenue_prior) * 100, 1)
          END AS revenue_growth_pct,
          ROUND(rp.repeat_rate_final * 100, 1) AS repeat_rate_final_pct,
          ROUND(rp.repeat_rate_prior * 100, 1) AS repeat_rate_prior_pct,
          CASE
            WHEN pm.payments_final IS NULL OR pm.payments_final = 0 THEN NULL
            ELSE ROUND(COALESCE(rfm.refunds_final, 0) / pm.payments_final * 100, 1)
          END AS refund_rate_final_pct,
          CASE
            WHEN pm.payments_prior IS NULL OR pm.payments_prior = 0 THEN NULL
            ELSE ROUND(COALESCE(rfm.refunds_prior, 0) / pm.payments_prior * 100, 1)
          END AS refund_rate_prior_pct,
          COALESCE(tm.tickets_final, 0) AS tickets_final_90d,
          COALESCE(tm.tickets_prior, 0) AS tickets_prior_90d,
          COUNT(*) OVER () AS total_churned_merchants
        FROM churned_tenants ct
        LEFT JOIN order_metrics om ON om.tenant_id = ct.tenant_id
        LEFT JOIN repeat_pivot rp ON rp.tenant_id = ct.tenant_id
        LEFT JOIN payment_metrics pm ON pm.tenant_id = ct.tenant_id
        LEFT JOIN refund_metrics rfm ON rfm.tenant_id = ct.tenant_id
        LEFT JOIN ticket_metrics tm ON tm.tenant_id = ct.tenant_id
      )
      SELECT
        merchant_name,
        industry,
        orders_final_90d,
        orders_prior_90d,
        revenue_growth_pct,
        repeat_rate_final_pct,
        repeat_rate_prior_pct,
        refund_rate_final_pct,
        refund_rate_prior_pct,
        tickets_final_90d,
        tickets_prior_90d,
        total_churned_merchants
      FROM report
      ORDER BY orders_final_90d ASC, tenant_id ASC
      LIMIT 50
    • The warehouse answers
      Submitted to tonight's batch. Your work on this request is done.
    • GraceThe report has been submitted to tonight's batch. **Summary of approach:** - Anchored each churned tenant's analysis window on their own last non-cancelled order date, then built final (0-90 days before) and prior (91-180 days before) windows. - Converted all money (order revenue, payments, refunds) to EUR using each row's own currency and the exchange rate as of that row's date. - Repeat rate = share of distinct customers with ≥2 orders in the window. - Refund rate = succeeded refunds (EUR) ÷ captured payments (EUR) within the same window. - Tickets counted by `created_at` falling in each window. - Added a `total_churned_merchants` column so the fixed 50-row cap is traceable to the true population size, as the handbook asks for.