DB Olympics

Queries · q370 · Head of Lifecycle Marketing · week 2, night 1

Apparel and Footwear: loyalty member vs non-member behavior by tenure

“Within Apparel and Footwear alone, where 72% of loyalty members sit, do members still look worse off than non-members on orders, revenue and refunds once we also control for how long they have been a customer?”

Manager · asked the questionAisha RahmanHead of Lifecycle Marketing. Ran customer messaging at a subscription box company.
Analyst · planned the reportIngrid SolbergRisk-focused: looks for the tail, the outliers and the worst cases.
Engineer · wrote the SQLOmarPragmatic: the shortest correct query, aggregates first and joins dimensions afterwards, FILTER clauses for conditional counts.

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 Lifecycle Marketing's desk

    Week 1; shared with Tobias Lindqvist, Chief Financial Officer

    Is the loyalty and referral programme worth it? Do members and referred customers behave differently from others, and what does the programme cost?

  2. Aisha Rahman writes to Ingrid Solberg

    The manager, at the start of week 2

    Subject: Re: Loyalty & referral — thank you, and where to dig next

    Ingrid, this is excellent — especially catching that the refund-rate gap was a vertical mix artifact. Good instinct to go back and break that out rather than let the scary headline stand.

    What surprised me most, and what I want explained before we take this to Tobias as a joint recommendation: a 19.4% cost-to-member-revenue ratio is strikingly high, and members showing lower revenue and order counts than non-members is the opposite of what a loyalty programme is supposed to produce. I don't want to present "the programme loses money" to leadership until I understand whether that's a true causal effect or another mix artifact like the refund rate turned out to be — since 72% of members sit in one vertical, I'm wary of a second apples-to-oranges comparison hiding in there.

    Questions for this week, in my own words:

    1. Within Apparel alone — since that's where almost all members live — do members still look worse off than similar non-member customers in that same vertical, once we also account for how long they've been customers? That's the comparison I actually trust as a verdict on the programme.
    2. Is the growing points liability a timing issue or a real design flaw? I want to understand whether we're simply in a "young programme, redemptions lag issuance" phase that will level out, or whether the redemption rate structurally understates true future cost — i.e., should I expect that €491k to keep climbing indefinitely at this pace.
    3. Can we fix the redemption-to-order linkage well enough to test the gaming hypothesis properly? That was left untested, not cleared, and it's exactly the kind of finding that would change my recommendation, so I'd like one more serious attempt before we call it inconclusive for good.
    4. If the member economics do hold up as genuinely negative, is there a design change that would plausibly fix it — rather than only "keep, cut, or extend to B2B" as the choice — are there any patterns in how the better-performing members (if any exist) use the programme differently from the rest? I don't need a redesign from you, just whether the data shows any sign of what a healthier version would look like.

    The B2B extension question can wait — I agree we shouldn't open that door until we trust the member economics number. Let's keep that out of this week's slots unless there's room left over.

    Thanks again, Ingrid — this is exactly the kind of rigor I want from this team.

    Best, Aisha

  3. Ingrid Solberg plans the night

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

    Aisha's push is right: with 72% of members in one vertical, the revenue/order gap could be a second Simpson's-paradox case just like refunds were. Tonight I'm prioritizing her questions 1-3 in order since she explicitly ranked the apparel-only comparison as the one she'll trust as a verdict, and flagged the liability trajectory and the redemption-linkage retest as the two things that could change the recommendation. Before rebuilding the gaming-hypothesis join (which returned zero matches last time), I'm first running a cheap diagnostic on whether order_id is ever populated on redemption-direction loyalty_transactions at all - if it's structurally never set, I need a different join strategy (e.g. time-proximity matching) rather than repeating the same failed approach, and I'd rather spend a slot finding that out now than waste a bigger request later. Holding question 4 (what better-performing members look like) and the B2B question for later nights, per Aisha's explicit deprioritization.

    Apparel and Footwear: loyalty member vs non-member behavior by tenure
    Question
    Within Apparel and Footwear alone, where 72% of loyalty members sit, do members still look worse off than non-members on orders, revenue and refunds once we also control for how long they have been a customer?
    One row per
    combination of account-tenure band and loyalty segment (member vs non-member), restricted to the Apparel and Footwear vertical
    Columns
    • tenure_band
    • segment
    • customer_count
    • avg_lifetime_orders
    • avg_lifetime_revenue_eur
    • avg_order_value_eur
    • refund_rate_pct
  4. Omar 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 apparel_tenants AS (
      SELECT id AS tenant_id
      FROM tenants
      WHERE industry = 'Apparel & Footwear'
    ),
    qualifying_users AS (
      SELECT u.id AS user_id, u.created_at
      FROM users u
      JOIN apparel_tenants t ON t.tenant_id = u.tenant_id
      WHERE u.is_staff = false
        AND EXISTS (
          SELECT 1 FROM orders o
          WHERE o.user_id = u.id
            AND o.placed_at >= DATE '2023-01-01'
            AND o.placed_at < DATE '2026-01-01'
        )
    ),
    order_agg AS (
      SELECT
        o.user_id,
        COUNT(*) AS total_order_count,
        COUNT(*) FILTER (WHERE o.status <> 'cancelled') AS non_cancelled_order_count,
        SUM(CASE WHEN o.status <> 'cancelled' THEN o.grand_total / er.rate ELSE 0 END) AS total_revenue_eur,
        COUNT(*) FILTER (
          WHERE EXISTS (SELECT 1 FROM payments p WHERE p.order_id = o.id AND p.amount_refunded > 0)
             OR EXISTS (SELECT 1 FROM order_returns r WHERE r.order_id = o.id)
        ) AS refunded_order_count
      FROM orders o
      JOIN qualifying_users qu ON qu.user_id = o.user_id
      LEFT JOIN exchange_rates er
        ON er.base_code = 'EUR'
       AND er.quote_code = o.currency_code
       AND er.as_of = COALESCE(o.placed_at, o.created_at)::date
      GROUP BY o.user_id
    ),
    segmented AS (
      SELECT
        qu.user_id,
        CASE
          WHEN EXISTS (SELECT 1 FROM loyalty_accounts la WHERE la.user_id = qu.user_id)
          THEN 'loyalty member' ELSE 'non-member'
        END AS segment,
        CASE
          WHEN qu.created_at > DATE '2025-12-31' - INTERVAL '6 months' THEN 'under 6 months'
          WHEN qu.created_at > DATE '2025-12-31' - INTERVAL '12 months' THEN '6 to 12 months'
          WHEN qu.created_at > DATE '2025-12-31' - INTERVAL '24 months' THEN '12 to 24 months'
          ELSE 'over 24 months'
        END AS tenure_band
      FROM qualifying_users qu
    )
    SELECT
      s.tenure_band,
      s.segment,
      COUNT(*) AS customer_count,
      ROUND(AVG(oa.total_order_count), 2) AS avg_lifetime_orders,
      ROUND(AVG(oa.total_revenue_eur), 2) AS avg_lifetime_revenue_eur,
      ROUND(SUM(oa.total_revenue_eur) / NULLIF(SUM(oa.non_cancelled_order_count), 0), 2) AS avg_order_value_eur,
      ROUND(100.0 * SUM(oa.refunded_order_count) / NULLIF(SUM(oa.total_order_count), 0), 2) AS refund_rate_pct
    FROM segmented s
    JOIN order_agg oa ON oa.user_id = s.user_id
    GROUP BY s.tenure_band, s.segment
    ORDER BY
      CASE s.tenure_band
        WHEN 'under 6 months' THEN 1
        WHEN '6 to 12 months' THEN 2
        WHEN '12 to 24 months' THEN 3
        WHEN 'over 24 months' THEN 4
      END,
      CASE s.segment WHEN 'loyalty member' THEN 1 ELSE 2 END

    “Query compiles and returns the expected columns and grain. 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 computes, per tenure-band × loyalty-segment cell within Apparel & Footwear tenants: customer count, average lifetime orders, average lifetime EUR revenue (non-cancelled orders), aggregate average order value in EUR, and refund rate (any order with a refunded payment or a return record). One note for Ingrid: I defined avg_order_value_eur as total non-cancelled EUR revenue divided by total non-cancelled order count within each cell (the standard AOV formula), rather than averaging each customer's own AOV — flagged in the submit note in case the earlier report used a different convention.”

  5. The report comes back overnight

    As Ingrid Solberg saw it, from the small dataset the agents worked on

    tenure_band     | segment        | customer_count | avg_lifetime_orders | avg_lifetime_revenue_eur | avg_order_value_eur | refund_rate_pct
    ----------------+----------------+----------------+---------------------+--------------------------+---------------------+----------------
    under 6 months  | loyalty member |            186 |                1.71 |                   283.46 |              128.41 |           33.65
    under 6 months  | non-member     |            645 |                 1.8 |                   345.91 |              177.61 |           37.04
    6 to 12 months  | loyalty member |            152 |                2.41 |                   369.57 |              101.47 |           38.42
    6 to 12 months  | non-member     |            474 |                2.76 |                   495.31 |              162.48 |           37.23
    12 to 24 months | loyalty member |            264 |                4.24 |                   649.01 |              115.76 |           39.86
    12 to 24 months | non-member     |           1019 |                4.05 |                   752.18 |              176.95 |           38.77
    over 24 months  | loyalty member |            351 |                5.09 |                   745.05 |              106.56 |           38.26
    over 24 months  | non-member     |           1273 |                5.33 |                   989.22 |              177.34 |           39.48
    (8 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 q370

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

    • spellingcast
    • numericsdocs/NUMERICS.md: rounding removed; results keep full precision; AVG over an integer computed as a decimal: AVG(CAST(x AS 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 (5×)
    • 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 apparel_tenants AS (  SELECT id AS tenant_id  FROM tenants  WHERE industry = 'Apparel & Footwear'),qualifying_users AS (  SELECT u.id AS user_id, u.created_at  SELECT u.id AS user_id, u.created_at AS created_at  FROM users u  JOIN apparel_tenants t ON t.tenant_id = u.tenant_id  WHERE u.is_staff = false    AND EXISTS (      SELECT 1 FROM orders o      WHERE o.user_id = u.id        AND o.placed_at >= DATE '2023-01-01'        AND o.placed_at < DATE '2026-01-01'    )),order_agg AS (  SELECT    o.user_id,    o.user_id AS user_id,    COUNT(*) AS total_order_count,    COUNT(*) FILTER (WHERE o.status <> 'cancelled') AS non_cancelled_order_count,    SUM(CASE WHEN o.status <> 'cancelled' THEN o.grand_total / er.rate ELSE 0 END) AS total_revenue_eur,    SUM(CASE WHEN o.status <> 'cancelled' THEN CAST(o.grand_total / er.rate AS NUMERIC(18,6)) ELSE 0 END) AS total_revenue_eur,    COUNT(*) FILTER (      WHERE EXISTS (SELECT 1 FROM payments p WHERE p.order_id = o.id AND p.amount_refunded > 0)         OR EXISTS (SELECT 1 FROM order_returns r WHERE r.order_id = o.id)    ) AS refunded_order_count  FROM orders o  JOIN qualifying_users qu ON qu.user_id = o.user_id  LEFT JOIN exchange_rates er    ON er.base_code = 'EUR'   AND er.quote_code = o.currency_code   AND er.as_of = COALESCE(o.placed_at, o.created_at)::date   AND er.as_of = CAST(COALESCE(o.placed_at, o.created_at) AS date)  GROUP BY o.user_id),segmented AS (  SELECT    qu.user_id,    qu.user_id AS user_id,    CASE      WHEN EXISTS (SELECT 1 FROM loyalty_accounts la WHERE la.user_id = qu.user_id)      THEN 'loyalty member' ELSE 'non-member'    END AS segment,    CASE      WHEN qu.created_at > DATE '2025-12-31' - INTERVAL '6 months' THEN 'under 6 months'      WHEN qu.created_at > DATE '2025-12-31' - INTERVAL '12 months' THEN '6 to 12 months'      WHEN qu.created_at > DATE '2025-12-31' - INTERVAL '24 months' THEN '12 to 24 months'      ELSE 'over 24 months'    END AS tenure_band  FROM qualifying_users qu)SELECT  s.tenure_band,  s.segment,  s.tenure_band AS tenure_band,  s.segment AS segment,  COUNT(*) AS customer_count,  ROUND(AVG(oa.total_order_count), 2) AS avg_lifetime_orders,  ROUND(AVG(oa.total_revenue_eur), 2) AS avg_lifetime_revenue_eur,  ROUND(SUM(oa.total_revenue_eur) / NULLIF(SUM(oa.non_cancelled_order_count), 0), 2) AS avg_order_value_eur,  ROUND(100.0 * SUM(oa.refunded_order_count) / NULLIF(SUM(oa.total_order_count), 0), 2) AS refund_rate_pct  AVG(CAST(oa.total_order_count AS NUMERIC(18,6))) AS avg_lifetime_orders,  AVG(oa.total_revenue_eur) AS avg_lifetime_revenue_eur,  SUM(oa.total_revenue_eur) / NULLIF(SUM(oa.non_cancelled_order_count), 0) AS avg_order_value_eur,  100.0 * SUM(oa.refunded_order_count) / NULLIF(SUM(oa.total_order_count), 0) AS refund_rate_pctFROM segmented sJOIN order_agg oa ON oa.user_id = s.user_idGROUP BY s.tenure_band, s.segmentORDER BY  CASE s.tenure_band    WHEN 'under 6 months' THEN 1    WHEN '6 to 12 months' THEN 2    WHEN '12 to 24 months' THEN 3    WHEN 'over 24 months' THEN 4  END,  CASE s.segment WHEN 'loyalty member' THEN 1 ELSE 2 END  END NULLS LAST,  CASE s.segment WHEN 'loyalty member' THEN 1 ELSE 2 END NULLS LAST
    WITH apparel_tenants AS (
      SELECT id AS tenant_id
      FROM tenants
      WHERE industry = 'Apparel & Footwear'
    ),
    qualifying_users AS (
      SELECT u.id AS user_id, u.created_at AS created_at
      FROM users u
      JOIN apparel_tenants t ON t.tenant_id = u.tenant_id
      WHERE u.is_staff = false
        AND EXISTS (
          SELECT 1 FROM orders o
          WHERE o.user_id = u.id
            AND o.placed_at >= DATE '2023-01-01'
            AND o.placed_at < DATE '2026-01-01'
        )
    ),
    order_agg AS (
      SELECT
        o.user_id AS user_id,
        COUNT(*) AS total_order_count,
        COUNT(*) FILTER (WHERE o.status <> 'cancelled') AS non_cancelled_order_count,
        SUM(CASE WHEN o.status <> 'cancelled' THEN CAST(o.grand_total / er.rate AS NUMERIC(18,6)) ELSE 0 END) AS total_revenue_eur,
        COUNT(*) FILTER (
          WHERE EXISTS (SELECT 1 FROM payments p WHERE p.order_id = o.id AND p.amount_refunded > 0)
             OR EXISTS (SELECT 1 FROM order_returns r WHERE r.order_id = o.id)
        ) AS refunded_order_count
      FROM orders o
      JOIN qualifying_users qu ON qu.user_id = o.user_id
      LEFT JOIN exchange_rates er
        ON er.base_code = 'EUR'
       AND er.quote_code = o.currency_code
       AND er.as_of = CAST(COALESCE(o.placed_at, o.created_at) AS date)
      GROUP BY o.user_id
    ),
    segmented AS (
      SELECT
        qu.user_id AS user_id,
        CASE
          WHEN EXISTS (SELECT 1 FROM loyalty_accounts la WHERE la.user_id = qu.user_id)
          THEN 'loyalty member' ELSE 'non-member'
        END AS segment,
        CASE
          WHEN qu.created_at > DATE '2025-12-31' - INTERVAL '6 months' THEN 'under 6 months'
          WHEN qu.created_at > DATE '2025-12-31' - INTERVAL '12 months' THEN '6 to 12 months'
          WHEN qu.created_at > DATE '2025-12-31' - INTERVAL '24 months' THEN '12 to 24 months'
          ELSE 'over 24 months'
        END AS tenure_band
      FROM qualifying_users qu
    )
    SELECT
      s.tenure_band AS tenure_band,
      s.segment AS segment,
      COUNT(*) AS customer_count,
      AVG(CAST(oa.total_order_count AS NUMERIC(18,6))) AS avg_lifetime_orders,
      AVG(oa.total_revenue_eur) AS avg_lifetime_revenue_eur,
      SUM(oa.total_revenue_eur) / NULLIF(SUM(oa.non_cancelled_order_count), 0) AS avg_order_value_eur,
      100.0 * SUM(oa.refunded_order_count) / NULLIF(SUM(oa.total_order_count), 0) AS refund_rate_pct
    FROM segmented s
    JOIN order_agg oa ON oa.user_id = s.user_id
    GROUP BY s.tenure_band, s.segment
    ORDER BY
      CASE s.tenure_band
        WHEN 'under 6 months' THEN 1
        WHEN '6 to 12 months' THEN 2
        WHEN '12 to 24 months' THEN 3
        WHEN 'over 24 months' THEN 4
      END NULLS LAST,
      CASE s.segment WHEN 'loyalty member' THEN 1 ELSE 2 END NULLS LAST
    100 ms1 s10 sHyper574 msDuckDB824 msGizmoSQL670 msclickhouse_brew1.21 sFirebolterrorDataFusionerrorClickHouseerror
    • Firebolt{ "errors": [ { "description": "Line 23, Column 67: 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": "14cbde41-6dd8-4039-a863-c8ea403966a8", "query_lab
    • DataFusionThis feature is not implemented: Physical plan does not support logical expression Exists(Exists { subquery: <subquery>, negated: false })
    • ClickHouseCode: 48. DB::Exception: Correlated subqueries in aggregate function argument are not supported. (NOT_IMPLEMENTED)
  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

    Omar's whole session, 7 turns
    • The request, as Omar received it
      Report request from Ingrid Solberg (analyst for the Head of Lifecycle Marketing):
      
      Title: Apparel and Footwear: loyalty member vs non-member behavior by tenure
      Question: Within Apparel and Footwear alone, where 72% of loyalty members sit, do members still look worse off than non-members on orders, revenue and refunds once we also control for how long they have been a customer?
      Grain: one row per combination of account-tenure band and loyalty segment (member vs non-member), restricted to the Apparel and Footwear vertical
      Columns:
      - tenure_band
      - segment
      - customer_count
      - avg_lifetime_orders
      - avg_lifetime_revenue_eur
      - avg_order_value_eur
      - refund_rate_pct
      Details: Same population, segment and metric definitions as the earlier 'Loyalty member vs non-member behavior by account tenure' report, but filtered to users whose tenant has tenants.industry = 'Apparel & Footwear' only. Population: users (is_staff = false) in Apparel and Footwear tenants with at least one order 2023-01-01 to 2025-12-31. Tenure band = time from users.created_at to 2025-12-31, bucketed as: 'under 6 months', '6 to 12 months', '12 to 24 months', 'over 24 months'. Segment = 'loyalty member' if present in loyalty_accounts, else 'non-member'. Revenue/AOV use non-cancelled orders converted to EUR via exchange_rates (as_of placed_at, fallback created_at). avg_lifetime_orders and refund_rate_pct use all orders (any status); refund = payments.amount_refunded greater than 0 OR an order_returns row. Order rows by tenure_band (youngest to oldest), then segment ('loyalty member' before 'non-member').
      
      Check your query with the warehouse's dry_run tool, then hand it in with submit.
    • Omar calls dry_run
      WITH apparel_tenants AS (
        SELECT id AS tenant_id
        FROM tenants
        WHERE industry = 'Apparel & Footwear'
      ),
      qualifying_users AS (
        SELECT u.id AS user_id, u.created_at
        FROM users u
        JOIN apparel_tenants t ON t.tenant_id = u.tenant_id
        WHERE u.is_staff = false
          AND EXISTS (
            SELECT 1 FROM orders o
            WHERE o.user_id = u.id
              AND o.placed_at >= DATE '2023-01-01'
              AND o.placed_at < DATE '2026-01-01'
          )
      ),
      order_agg AS (
        SELECT
          o.user_id,
          COUNT(*) AS total_order_count,
          COUNT(*) FILTER (WHERE o.status <> 'cancelled') AS non_cancelled_order_count,
          SUM(CASE WHEN o.status <> 'cancelled' THEN o.grand_total / er.rate ELSE 0 END) AS total_revenue_eur,
          COUNT(*) FILTER (
            WHERE EXISTS (SELECT 1 FROM payments p WHERE p.order_id = o.id AND p.amount_refunded > 0)
               OR EXISTS (SELECT 1 FROM order_returns r WHERE r.order_id = o.id)
          ) AS refunded_order_count
        FROM orders o
        JOIN qualifying_users qu ON qu.user_id = o.user_id
        LEFT JOIN exchange_rates er
          ON er.base_code = 'EUR'
         AND er.quote_code = o.currency_code
         AND er.as_of = COALESCE(o.placed_at, o.created_at)::date
        GROUP BY o.user_id
      ),
      segmented AS (
        SELECT
          qu.user_id,
          CASE
            WHEN EXISTS (SELECT 1 FROM loyalty_accounts la WHERE la.user_id = qu.user_id)
            THEN 'loyalty member' ELSE 'non-member'
          END AS segment,
          CASE
            WHEN qu.created_at > DATE '2025-12-31' - INTERVAL '6 months' THEN 'under 6 months'
            WHEN qu.created_at > DATE '2025-12-31' - INTERVAL '12 months' THEN '6 to 12 months'
            WHEN qu.created_at > DATE '2025-12-31' - INTERVAL '24 months' THEN '12 to 24 months'
            ELSE 'over 24 months'
          END AS tenure_band
        FROM qualifying_users qu
      )
      SELECT
        s.tenure_band,
        s.segment,
        COUNT(*) AS customer_count,
        ROUND(AVG(oa.total_order_count), 2) AS avg_lifetime_orders,
        ROUND(AVG(oa.total_revenue_eur), 2) AS avg_lifetime_revenue_eur,
        ROUND(SUM(oa.total_revenue_eur) / NULLIF(SUM(oa.non_cancelled_order_count), 0), 2) AS avg_order_value_eur,
        ROUND(100.0 * SUM(oa.refunded_order_count) / NULLIF(SUM(oa.total_order_count), 0), 2) AS refund_rate_pct
      FROM segmented s
      JOIN order_agg oa ON oa.user_id = s.user_id
      GROUP BY s.tenure_band, s.segment
      ORDER BY
        CASE s.tenure_band
          WHEN 'under 6 months' THEN 1
          WHEN '6 to 12 months' THEN 2
          WHEN '12 to 24 months' THEN 3
          WHEN 'over 24 months' THEN 4
        END,
        CASE s.segment WHEN 'loyalty member' THEN 1 ELSE 2 END
    • The warehouse answers
      STATUS: COMPILES (dry run on the empty schema, no data read)
      COLUMNS: tenure_band, segment, customer_count, avg_lifetime_orders, avg_lifetime_revenue_eur, avg_order_value_eur, refund_rate_pct
    • OmarQuery compiles and returns the expected columns and grain. Submitting.
    • Omar calls submit
      WITH apparel_tenants AS (
        SELECT id AS tenant_id
        FROM tenants
        WHERE industry = 'Apparel & Footwear'
      ),
      qualifying_users AS (
        SELECT u.id AS user_id, u.created_at
        FROM users u
        JOIN apparel_tenants t ON t.tenant_id = u.tenant_id
        WHERE u.is_staff = false
          AND EXISTS (
            SELECT 1 FROM orders o
            WHERE o.user_id = u.id
              AND o.placed_at >= DATE '2023-01-01'
              AND o.placed_at < DATE '2026-01-01'
          )
      ),
      order_agg AS (
        SELECT
          o.user_id,
          COUNT(*) AS total_order_count,
          COUNT(*) FILTER (WHERE o.status <> 'cancelled') AS non_cancelled_order_count,
          SUM(CASE WHEN o.status <> 'cancelled' THEN o.grand_total / er.rate ELSE 0 END) AS total_revenue_eur,
          COUNT(*) FILTER (
            WHERE EXISTS (SELECT 1 FROM payments p WHERE p.order_id = o.id AND p.amount_refunded > 0)
               OR EXISTS (SELECT 1 FROM order_returns r WHERE r.order_id = o.id)
          ) AS refunded_order_count
        FROM orders o
        JOIN qualifying_users qu ON qu.user_id = o.user_id
        LEFT JOIN exchange_rates er
          ON er.base_code = 'EUR'
         AND er.quote_code = o.currency_code
         AND er.as_of = COALESCE(o.placed_at, o.created_at)::date
        GROUP BY o.user_id
      ),
      segmented AS (
        SELECT
          qu.user_id,
          CASE
            WHEN EXISTS (SELECT 1 FROM loyalty_accounts la WHERE la.user_id = qu.user_id)
            THEN 'loyalty member' ELSE 'non-member'
          END AS segment,
          CASE
            WHEN qu.created_at > DATE '2025-12-31' - INTERVAL '6 months' THEN 'under 6 months'
            WHEN qu.created_at > DATE '2025-12-31' - INTERVAL '12 months' THEN '6 to 12 months'
            WHEN qu.created_at > DATE '2025-12-31' - INTERVAL '24 months' THEN '12 to 24 months'
            ELSE 'over 24 months'
          END AS tenure_band
        FROM qualifying_users qu
      )
      SELECT
        s.tenure_band,
        s.segment,
        COUNT(*) AS customer_count,
        ROUND(AVG(oa.total_order_count), 2) AS avg_lifetime_orders,
        ROUND(AVG(oa.total_revenue_eur), 2) AS avg_lifetime_revenue_eur,
        ROUND(SUM(oa.total_revenue_eur) / NULLIF(SUM(oa.non_cancelled_order_count), 0), 2) AS avg_order_value_eur,
        ROUND(100.0 * SUM(oa.refunded_order_count) / NULLIF(SUM(oa.total_order_count), 0), 2) AS refund_rate_pct
      FROM segmented s
      JOIN order_agg oa ON oa.user_id = s.user_id
      GROUP BY s.tenure_band, s.segment
      ORDER BY
        CASE s.tenure_band
          WHEN 'under 6 months' THEN 1
          WHEN '6 to 12 months' THEN 2
          WHEN '12 to 24 months' THEN 3
          WHEN 'over 24 months' THEN 4
        END,
        CASE s.segment WHEN 'loyalty member' THEN 1 ELSE 2 END
    • The warehouse answers
      Submitted to tonight's batch. Your work on this request is done.
    • OmarSubmitted. The report computes, per tenure-band × loyalty-segment cell within Apparel & Footwear tenants: customer count, average lifetime orders, average lifetime EUR revenue (non-cancelled orders), aggregate average order value in EUR, and refund rate (any order with a refunded payment or a return record). One note for Ingrid: I defined `avg_order_value_eur` as total non-cancelled EUR revenue divided by total non-cancelled order count within each cell (the standard AOV formula), rather than averaging each customer's own AOV — flagged in the submit note in case the earlier report used a different convention.