DB Olympics

Queries · q381 · Head of Lifecycle Marketing · week 2, night 4

Post-redemption drop-off by how quickly members first redeemed

“Are members who redeem points very soon after enrolling - likely signup-bonus hunters - disproportionately the ones who never place another order, which would be the clearest sign of rewards given without real repeat purchase behind them?”

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

Fictional people at a fictional company, each played by a language-model agent. Who they are and what they are told

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

    Night 3 was the turning point. Within Apparel, tenure-controlled, genuinely engaged members (ever redeemed a point) clearly beat non-members of the same tenure - roughly double the orders and 85-91% more revenue at 12-24 and 24-plus month tenure, with refund rates statistically indistinguishable from non-members. The 'member penalty' seen all along turns out to live entirely in the 83% of members who enrolled but have never redeemed a single point - they underperform both the engaged members and non-members on every metric. Unit economics confirm the realized cost is 100% concentrated in that engaged 17% (225 of 1330 members), and for them it's a rich 40.95% of their own revenue - a much higher 'discount rate' than the blended 19.4% figure suggested, though against genuinely larger, more valuable customers. The new wrinkle: among engaged members, annualized order frequency drops sharply after their first redemption (6.64 to 2.79 orders/year) and 30% place no order at all afterward - before I call this a 'redemption kills engagement' story I need to rule out that it's just normal customer-lifecycle deceleration that would show up for anyone. For this final night: (1) test whether that frequency drop is specific to redeemers or a universal pattern, by comparing first-half vs second-half-of-tenure frequency across non-members, never-redeemed members and ever-redeemed members; (2) confirm the engagement-separates-good-from-bad-members finding holds platform-wide, not just in Apparel; (3) check whether members who redeem quickly after enrolling (likely signup-bonus hunters) are disproportionately the ones who never order again, which would be the clearest 'reward without real repeat purchase' red flag Aisha asked about.

    Post-redemption drop-off by how quickly members first redeemed
    Question
    Are members who redeem points very soon after enrolling - likely signup-bonus hunters - disproportionately the ones who never place another order, which would be the clearest sign of rewards given without real repeat purchase behind them?
    One row per
    time-to-first-redemption bucket, among members who have redeemed at least once
    Columns
    • time_to_first_redemption_bucket
    • member_count
    • pct_with_no_orders_after_redemption
    • avg_orders_after_per_customer
    • avg_revenue_after_eur
  4. Bea writes the SQL

    The engineer · 1 dry run on the empty schema, then the submission

    Dry run 1 · STATUS: COMPILES (dry run on the empty schema, no data read)
    WITH member_enroll AS (
      SELECT la.user_id AS user_id, MIN(la.enrolled_at) AS enrolled_at
      FROM loyalty_accounts la
      GROUP BY la.user_id
    ),
    member_first_burn AS (
      SELECT lt.user_id AS user_id, MIN(lt.occurred_at) AS first_burn_at
      FROM loyalty_transactions lt
      WHERE lt.direction = 'burn'
      GROUP BY lt.user_id
    ),
    member_base AS (
      SELECT
        t0.user_id AS user_id,
        t0.enrolled_at AS enrolled_at,
        t1.first_burn_at AS first_burn_at,
        CASE
          WHEN (t1.first_burn_at::date - t0.enrolled_at::date) <= 30 THEN 'within 30 days of enrollment'
          WHEN (t1.first_burn_at::date - t0.enrolled_at::date) <= 180 THEN '31 to 180 days'
          ELSE 'over 180 days'
        END AS bucket
      FROM member_enroll t0
      JOIN member_first_burn t1 ON t1.user_id = t0.user_id
    ),
    orders_after_count AS (
      SELECT
        mb.user_id AS user_id,
        COUNT(o.id) AS orders_after_count
      FROM member_base mb
      LEFT JOIN orders o
        ON o.user_id = mb.user_id
       AND o.placed_at >= mb.first_burn_at
      GROUP BY mb.user_id
    ),
    orders_after_revenue AS (
      SELECT
        mb.user_id AS user_id,
        SUM(
          CASE
            WHEN o.id IS NULL THEN 0
            WHEN o.currency_code = 'EUR' THEN o.grand_total
            ELSE o.grand_total / NULLIF(er.rate, 0)
          END
        ) AS revenue_after_eur
      FROM member_base mb
      LEFT JOIN orders o
        ON o.user_id = mb.user_id
       AND o.placed_at >= mb.first_burn_at
       AND o.status <> 'cancelled'
      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
      GROUP BY mb.user_id
    )
    SELECT
      mb.bucket AS time_to_first_redemption_bucket,
      COUNT(*) AS member_count,
      ROUND(100.0 * SUM(CASE WHEN COALESCE(oac.orders_after_count, 0) = 0 THEN 1 ELSE 0 END) / COUNT(*), 1) AS pct_with_no_orders_after_redemption,
      ROUND(AVG(COALESCE(oac.orders_after_count, 0)), 2) AS avg_orders_after_per_customer,
      ROUND(AVG(COALESCE(oar.revenue_after_eur, 0)), 2) AS avg_revenue_after_eur
    FROM member_base mb
    LEFT JOIN orders_after_count oac ON oac.user_id = mb.user_id
    LEFT JOIN orders_after_revenue oar ON oar.user_id = mb.user_id
    GROUP BY mb.bucket
    ORDER BY
      CASE mb.bucket
        WHEN 'within 30 days of enrollment' THEN 1
        WHEN '31 to 180 days' THEN 2
        WHEN 'over 180 days' THEN 3
      END

    “Query compiles with 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 buckets loyalty members by days-from-enrollment to first redemption (≤30 / 31–180 / >180) and shows member count, share with zero post-redemption orders, average post-redemption order count, and average post-redemption EUR revenue per member — directly testing whether fast redeemers are disproportionately one-and-done.”

  5. The report comes back overnight

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

    time_to_first_redemption_bucket | member_count | pct_with_no_orders_after_redemption | avg_orders_after_per_customer | avg_revenue_after_eur
    --------------------------------+--------------+-------------------------------------+-------------------------------+----------------------
    31 to 180 days                  |           61 |                                29.5 |                          3.89 |                879.05
    over 180 days                   |          164 |                                29.9 |                          2.85 |                588.34
    (2 rows)
  6. What came of it

    The memo and the queries that followed; links found by title, so inferred

    It builds on:

  7. It becomes benchmark query q381

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

    • spellingcast (5×)
    • 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)
    • placeholderdate diff days (2×)
    • determinismthe standard leaves the place of NULLs in a sort to the implementation: NULLS LAST ascending, NULLS FIRST descending, as in the reference results
    The benchmark text, against the submitted SQL
    WITH member_enroll AS (  SELECT la.user_id AS user_id, MIN(la.enrolled_at) AS enrolled_at  FROM loyalty_accounts la  GROUP BY la.user_id),member_first_burn AS (  SELECT lt.user_id AS user_id, MIN(lt.occurred_at) AS first_burn_at  FROM loyalty_transactions lt  WHERE lt.direction = 'burn'  GROUP BY lt.user_id),member_base AS (  SELECT    t0.user_id AS user_id,    t0.enrolled_at AS enrolled_at,    t1.first_burn_at AS first_burn_at,    CASE      WHEN (t1.first_burn_at::date - t0.enrolled_at::date) <= 30 THEN 'within 30 days of enrollment'      WHEN (t1.first_burn_at::date - t0.enrolled_at::date) <= 180 THEN '31 to 180 days'      WHEN ({{date_diff_days(t0.enrolled_at, t1.first_burn_at)}}) <= 30 THEN 'within 30 days of enrollment'      WHEN ({{date_diff_days(t0.enrolled_at, t1.first_burn_at)}}) <= 180 THEN '31 to 180 days'      ELSE 'over 180 days'    END AS bucket  FROM member_enroll t0  JOIN member_first_burn t1 ON t1.user_id = t0.user_id),orders_after_count AS (  SELECT    mb.user_id AS user_id,    COUNT(o.id) AS orders_after_count  FROM member_base mb  LEFT JOIN orders o    ON o.user_id = mb.user_id   AND o.placed_at >= mb.first_burn_at  GROUP BY mb.user_id),orders_after_revenue AS (  SELECT    mb.user_id AS user_id,    SUM(      CASE        WHEN o.id IS NULL THEN 0        WHEN o.currency_code = 'EUR' THEN o.grand_total        ELSE o.grand_total / NULLIF(er.rate, 0)        ELSE CAST(o.grand_total / NULLIF(er.rate, 0) AS NUMERIC(18,6))      END    ) AS revenue_after_eur  FROM member_base mb  LEFT JOIN orders o    ON o.user_id = mb.user_id   AND o.placed_at >= mb.first_burn_at   AND o.status <> 'cancelled'  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   AND er.as_of = CAST(o.placed_at AS date)  GROUP BY mb.user_id)SELECT  mb.bucket AS time_to_first_redemption_bucket,  COUNT(*) AS member_count,  ROUND(100.0 * SUM(CASE WHEN COALESCE(oac.orders_after_count, 0) = 0 THEN 1 ELSE 0 END) / COUNT(*), 1) AS pct_with_no_orders_after_redemption,  ROUND(AVG(COALESCE(oac.orders_after_count, 0)), 2) AS avg_orders_after_per_customer,  ROUND(AVG(COALESCE(oar.revenue_after_eur, 0)), 2) AS avg_revenue_after_eur  100.0 * SUM(CASE WHEN COALESCE(oac.orders_after_count, 0) = 0 THEN 1 ELSE 0 END) / COUNT(*) AS pct_with_no_orders_after_redemption,  AVG(CAST(COALESCE(oac.orders_after_count, 0) AS NUMERIC(18,6))) AS avg_orders_after_per_customer,  AVG(COALESCE(oar.revenue_after_eur, 0)) AS avg_revenue_after_eurFROM member_base mbLEFT JOIN orders_after_count oac ON oac.user_id = mb.user_idLEFT JOIN orders_after_revenue oar ON oar.user_id = mb.user_idGROUP BY mb.bucketORDER BY  CASE mb.bucket    WHEN 'within 30 days of enrollment' THEN 1    WHEN '31 to 180 days' THEN 2    WHEN 'over 180 days' THEN 3  END  END NULLS LAST
    WITH member_enroll AS (
      SELECT la.user_id AS user_id, MIN(la.enrolled_at) AS enrolled_at
      FROM loyalty_accounts la
      GROUP BY la.user_id
    ),
    member_first_burn AS (
      SELECT lt.user_id AS user_id, MIN(lt.occurred_at) AS first_burn_at
      FROM loyalty_transactions lt
      WHERE lt.direction = 'burn'
      GROUP BY lt.user_id
    ),
    member_base AS (
      SELECT
        t0.user_id AS user_id,
        t0.enrolled_at AS enrolled_at,
        t1.first_burn_at AS first_burn_at,
        CASE
          WHEN (DATE_DIFF('day', t0.enrolled_at, t1.first_burn_at)) <= 30 THEN 'within 30 days of enrollment'
          WHEN (DATE_DIFF('day', t0.enrolled_at, t1.first_burn_at)) <= 180 THEN '31 to 180 days'
          ELSE 'over 180 days'
        END AS bucket
      FROM member_enroll t0
      JOIN member_first_burn t1 ON t1.user_id = t0.user_id
    ),
    orders_after_count AS (
      SELECT
        mb.user_id AS user_id,
        COUNT(o.id) AS orders_after_count
      FROM member_base mb
      LEFT JOIN orders o
        ON o.user_id = mb.user_id
       AND o.placed_at >= mb.first_burn_at
      GROUP BY mb.user_id
    ),
    orders_after_revenue AS (
      SELECT
        mb.user_id AS user_id,
        SUM(
          CASE
            WHEN o.id IS NULL THEN 0
            WHEN o.currency_code = 'EUR' THEN o.grand_total
            ELSE CAST(o.grand_total / NULLIF(er.rate, 0) AS NUMERIC(18,6))
          END
        ) AS revenue_after_eur
      FROM member_base mb
      LEFT JOIN orders o
        ON o.user_id = mb.user_id
       AND o.placed_at >= mb.first_burn_at
       AND o.status <> 'cancelled'
      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)
      GROUP BY mb.user_id
    )
    SELECT
      mb.bucket AS time_to_first_redemption_bucket,
      COUNT(*) AS member_count,
      100.0 * SUM(CASE WHEN COALESCE(oac.orders_after_count, 0) = 0 THEN 1 ELSE 0 END) / COUNT(*) AS pct_with_no_orders_after_redemption,
      AVG(CAST(COALESCE(oac.orders_after_count, 0) AS NUMERIC(18,6))) AS avg_orders_after_per_customer,
      AVG(COALESCE(oar.revenue_after_eur, 0)) AS avg_revenue_after_eur
    FROM member_base mb
    LEFT JOIN orders_after_count oac ON oac.user_id = mb.user_id
    LEFT JOIN orders_after_revenue oar ON oar.user_id = mb.user_id
    GROUP BY mb.bucket
    ORDER BY
      CASE mb.bucket
        WHEN 'within 30 days of enrollment' THEN 1
        WHEN '31 to 180 days' THEN 2
        WHEN 'over 180 days' THEN 3
      END NULLS LAST
    10 ms100 ms1 sHyper52.8 msDuckDB112 msGizmoSQL112 msclickhouse_brew795 msFirebolterrorDataFusion625 msClickHouseerror
    • Firebolt{ "errors": [ { "description": "Line 42, Column 33: 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": "418081f1-155e-4e87-bdd5-f4880fcfb5a7", "query_lab
    • ClickHouseCode: 69. DB::Exception: Decimal result's scale is less than argument's one: while executing function divide on arguments __table19.grand_total Nullable(Decimal(14, 4)) Nullable(size = 0, Decimal64(size = 0), UInt8(size = 0)), nullIf(__table20.rate, 0_UInt8) Nullable(Decimal(12, 6)) Nullable(size =
  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

    Bea's whole session, 7 turns
    • The request, as Bea received it
      Report request from Ingrid Solberg (analyst for the Head of Lifecycle Marketing):
      
      Title: Post-redemption drop-off by how quickly members first redeemed
      Question: Are members who redeem points very soon after enrolling - likely signup-bonus hunters - disproportionately the ones who never place another order, which would be the clearest sign of rewards given without real repeat purchase behind them?
      Grain: one row per time-to-first-redemption bucket, among members who have redeemed at least once
      Columns:
      - time_to_first_redemption_bucket
      - member_count
      - pct_with_no_orders_after_redemption
      - avg_orders_after_per_customer
      - avg_revenue_after_eur
      Details: Population: users with a loyalty_accounts row who have at least one 'burn'-direction loyalty_transactions row. For each such user, compute days between loyalty_accounts.enrolled_at and the occurred_at of their first 'burn' transaction; bucket into 'within 30 days of enrollment', '31 to 180 days', 'over 180 days'. pct_with_no_orders_after_redemption = share of members in the bucket with zero orders placed on or after their first redemption's occurred_at. avg_orders_after_per_customer = average count of orders (any status) placed on or after the first redemption, per member in the bucket (including zeros). avg_revenue_after_eur = average EUR revenue (non-cancelled orders, grand_total converted via exchange_rates as_of placed_at) from orders placed on or after the first redemption, per member in the bucket (including zeros). Order rows by time_to_first_redemption_bucket in the order listed above.
      
      Check your query with the warehouse's dry_run tool, then hand it in with submit.
    • Bea calls dry_run
      WITH member_enroll AS (
        SELECT la.user_id AS user_id, MIN(la.enrolled_at) AS enrolled_at
        FROM loyalty_accounts la
        GROUP BY la.user_id
      ),
      member_first_burn AS (
        SELECT lt.user_id AS user_id, MIN(lt.occurred_at) AS first_burn_at
        FROM loyalty_transactions lt
        WHERE lt.direction = 'burn'
        GROUP BY lt.user_id
      ),
      member_base AS (
        SELECT
          t0.user_id AS user_id,
          t0.enrolled_at AS enrolled_at,
          t1.first_burn_at AS first_burn_at,
          CASE
            WHEN (t1.first_burn_at::date - t0.enrolled_at::date) <= 30 THEN 'within 30 days of enrollment'
            WHEN (t1.first_burn_at::date - t0.enrolled_at::date) <= 180 THEN '31 to 180 days'
            ELSE 'over 180 days'
          END AS bucket
        FROM member_enroll t0
        JOIN member_first_burn t1 ON t1.user_id = t0.user_id
      ),
      orders_after_count AS (
        SELECT
          mb.user_id AS user_id,
          COUNT(o.id) AS orders_after_count
        FROM member_base mb
        LEFT JOIN orders o
          ON o.user_id = mb.user_id
         AND o.placed_at >= mb.first_burn_at
        GROUP BY mb.user_id
      ),
      orders_after_revenue AS (
        SELECT
          mb.user_id AS user_id,
          SUM(
            CASE
              WHEN o.id IS NULL THEN 0
              WHEN o.currency_code = 'EUR' THEN o.grand_total
              ELSE o.grand_total / NULLIF(er.rate, 0)
            END
          ) AS revenue_after_eur
        FROM member_base mb
        LEFT JOIN orders o
          ON o.user_id = mb.user_id
         AND o.placed_at >= mb.first_burn_at
         AND o.status <> 'cancelled'
        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
        GROUP BY mb.user_id
      )
      SELECT
        mb.bucket AS time_to_first_redemption_bucket,
        COUNT(*) AS member_count,
        ROUND(100.0 * SUM(CASE WHEN COALESCE(oac.orders_after_count, 0) = 0 THEN 1 ELSE 0 END) / COUNT(*), 1) AS pct_with_no_orders_after_redemption,
        ROUND(AVG(COALESCE(oac.orders_after_count, 0)), 2) AS avg_orders_after_per_customer,
        ROUND(AVG(COALESCE(oar.revenue_after_eur, 0)), 2) AS avg_revenue_after_eur
      FROM member_base mb
      LEFT JOIN orders_after_count oac ON oac.user_id = mb.user_id
      LEFT JOIN orders_after_revenue oar ON oar.user_id = mb.user_id
      GROUP BY mb.bucket
      ORDER BY
        CASE mb.bucket
          WHEN 'within 30 days of enrollment' THEN 1
          WHEN '31 to 180 days' THEN 2
          WHEN 'over 180 days' THEN 3
        END
    • The warehouse answers
      STATUS: COMPILES (dry run on the empty schema, no data read)
      COLUMNS: time_to_first_redemption_bucket, member_count, pct_with_no_orders_after_redemption, avg_orders_after_per_customer, avg_revenue_after_eur
    • BeaQuery compiles with the expected columns and grain. Submitting.
    • Bea calls submit
      WITH member_enroll AS (
        SELECT la.user_id AS user_id, MIN(la.enrolled_at) AS enrolled_at
        FROM loyalty_accounts la
        GROUP BY la.user_id
      ),
      member_first_burn AS (
        SELECT lt.user_id AS user_id, MIN(lt.occurred_at) AS first_burn_at
        FROM loyalty_transactions lt
        WHERE lt.direction = 'burn'
        GROUP BY lt.user_id
      ),
      member_base AS (
        SELECT
          t0.user_id AS user_id,
          t0.enrolled_at AS enrolled_at,
          t1.first_burn_at AS first_burn_at,
          CASE
            WHEN (t1.first_burn_at::date - t0.enrolled_at::date) <= 30 THEN 'within 30 days of enrollment'
            WHEN (t1.first_burn_at::date - t0.enrolled_at::date) <= 180 THEN '31 to 180 days'
            ELSE 'over 180 days'
          END AS bucket
        FROM member_enroll t0
        JOIN member_first_burn t1 ON t1.user_id = t0.user_id
      ),
      orders_after_count AS (
        SELECT
          mb.user_id AS user_id,
          COUNT(o.id) AS orders_after_count
        FROM member_base mb
        LEFT JOIN orders o
          ON o.user_id = mb.user_id
         AND o.placed_at >= mb.first_burn_at
        GROUP BY mb.user_id
      ),
      orders_after_revenue AS (
        SELECT
          mb.user_id AS user_id,
          SUM(
            CASE
              WHEN o.id IS NULL THEN 0
              WHEN o.currency_code = 'EUR' THEN o.grand_total
              ELSE o.grand_total / NULLIF(er.rate, 0)
            END
          ) AS revenue_after_eur
        FROM member_base mb
        LEFT JOIN orders o
          ON o.user_id = mb.user_id
         AND o.placed_at >= mb.first_burn_at
         AND o.status <> 'cancelled'
        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
        GROUP BY mb.user_id
      )
      SELECT
        mb.bucket AS time_to_first_redemption_bucket,
        COUNT(*) AS member_count,
        ROUND(100.0 * SUM(CASE WHEN COALESCE(oac.orders_after_count, 0) = 0 THEN 1 ELSE 0 END) / COUNT(*), 1) AS pct_with_no_orders_after_redemption,
        ROUND(AVG(COALESCE(oac.orders_after_count, 0)), 2) AS avg_orders_after_per_customer,
        ROUND(AVG(COALESCE(oar.revenue_after_eur, 0)), 2) AS avg_revenue_after_eur
      FROM member_base mb
      LEFT JOIN orders_after_count oac ON oac.user_id = mb.user_id
      LEFT JOIN orders_after_revenue oar ON oar.user_id = mb.user_id
      GROUP BY mb.bucket
      ORDER BY
        CASE mb.bucket
          WHEN 'within 30 days of enrollment' THEN 1
          WHEN '31 to 180 days' THEN 2
          WHEN 'over 180 days' THEN 3
        END
    • The warehouse answers
      Submitted to tonight's batch. Your work on this request is done.
    • BeaSubmitted. The report buckets loyalty members by days-from-enrollment to first redemption (≤30 / 31–180 / >180) and shows member count, share with zero post-redemption orders, average post-redemption order count, and average post-redemption EUR revenue per member — directly testing whether fast redeemers are disproportionately one-and-done.