Runbook: HNR Membership Sales & Metrics Reporting

This runbook is read-only. It extracts, aggregates, and formats data from wpca_prod and the Myriad Events WooCommerce instance. It performs no UPDATEs, DELETEs, or WC API mutations. Any remediation (stale-order cleanup, sync-mismatch fixes, duplicate purge, WC trash) is out of scope and belongs in the complementary duplicate-order cleanup runbook / the existing design journal design-journal/2026-03/duplicate-order-purge.adoc.

1. Context

1.1. What this runbook produces

Two AsciiDoc reports, generated from the same dataset in a single execution:

  1. Customer-facing report — HNR-branded sales and outstanding-payment summary. Modelled on design-journal/2026-04/hnr-membership-report-2026-04-07.md (the last manually-produced report). Intended to be previewed, rendered to HTML in the authoring tool, and cut-and-pasted into an email to HNR.

  2. Internal report — data-quality, duplicate, and stale-order metrics for the same period. Surfaces the reconciliation gaps that the customer report intentionally hides (stale pending orders, sync mismatches, Family pricing anomalies, retry duplicates). Used to decide whether a cleanup pass (see duplicate-order-purge.adoc) is warranted before re-running the customer report.

Both reports are generated by the same SQL pipeline against the same snapshot, so internal numbers and customer numbers reconcile to each other by construction.

1.2. Why two documents

The April 7 customer report already embedded operational footnotes (admin-added assumed-paid, stale pending orders, duplicate orders, Family pricing). Those are useful internally but confusing to the customer — the customer wants "how many paid, how many owe us money, who are they". Splitting the reports lets each one be tuned for its audience without compromise.

1.3. Cadence

Ad-hoc, on request from HNR. Expected cadence is fortnightly during early registration, tapering to monthly after the Early Riser window closes.

2. Scope

2.1. In scope

  • Organisation 8 (Helderberg Nature Reserve)

  • MembershipPeriod 9 (2026 Annual Membership) and MembershipPeriod 10 (2026 Early Riser), or equivalent current-year periods

  • Member categories: Adult, Senior, Child, Family Main, Family Adult, Family Child, Baby, Early Riser

  • Orders on WC instance https://members.myriadevents.co.za/

  • Year-on-year comparison against the immediately prior year’s Annual + Early Riser periods

2.2. Out of scope

  • Remediation of any data-quality issue surfaced. The runbook reads and reports only.

  • Event Participant orders (cycling events, schools) — a separate runbook would be appropriate for those organisations (Western Cape School Cycling, WPCA) if recurring reporting is needed.

  • Product / pricing reconciliation between admin-service and WooCommerce at line-item level — that workflow is covered by design-journal/2026-03/woocommerce-order-reconciliation.adoc and requires the full WC CSV extract. This runbook uses order-level payment amounts from admin-service only, and flags suspected line-item anomalies for follow-up.

3. Prerequisites

3.1. Database access

EMS database (wpca_prod) via SSH tunnel (claude user):

ssh -N -L 3307:idealogic-prod.mysql.svc.cluster.local:6446 \
  -p 30022 -i ~/.ssh/idl-xnl-jhb1-01-bastion \
  -o StrictHostKeyChecking=accept-new \
  [email protected] &

mysql -h 127.0.0.1 -P 3307 -u claude -p"$(cat ~/dev/ems/claude.pwd)" wpca_prod

The claude user has SELECT, INSERT, UPDATE, REFERENCES, LOCK TABLES, CREATE TEMPORARY TABLES on wpca_prod. This runbook only uses SELECT and CREATE TEMPORARY TABLES (optional — only needed for the YoY comparison if staging a prior-year extract).

3.2. WooCommerce API access (optional)

The runbook is fully executable against wpca_prod alone. WC API access is only required if the internal report needs to cross-check EMS order status against WC status (recommended but optional for the first-cut report):

# Credentials for Myriad Events (HNR) instance
curl -s "https://members.myriadevents.co.za/wp-json/wc/v3/orders?per_page=1" \
  -u "<CONSUMER_KEY>:<CONSUMER_SECRET>" | python3 -m json.tool | head -5

Credentials are provided by the HNR WordPress admin out-of-band when needed.

3.3. Parameters

Record these at the top of each run:

Parameter Value (2026 run) Source

REPORT_DATE

e.g. 2026-04-24

Today’s date

ORG_ID

8

organisation table — HNR / Helderberg Nature Reserve

ANNUAL_PERIOD_ID

9

membership_period — "2026 Annual Membership" (valid 2026-03-11 to 2027-03-31)

EARLY_RISER_PERIOD_ID

10

membership_period — "2026 Early Riser" (valid 2026-03-11 to 2026-03-15)

PRIOR_ANNUAL_PERIOD_ID

7

membership_period — "2025" Annual, membership_type_id = 1, valid 2025-03-07 to 2026-03-31

PRIOR_EARLY_RISER_PERIOD_ID

8

membership_period — "2025" Early Riser, membership_type_id = 3, valid 2025-03-07 to 2025-03-31

REGISTRATION_CUTOFF

2026-03-01

Earliest sales_order.transaction_date_time considered in-scope. Mirrors the recon journal’s scope.

Confirm prior-year period IDs with a pre-flight query:

SELECT mp.id, mp.name, mp.valid_from, mp.valid_to, mt.name AS type_name, mt.organisation_id
FROM membership_period mp
JOIN membership_type mt ON mt.id = mp.membership_type_id
WHERE mt.organisation_id = :ORG_ID
ORDER BY mp.valid_from DESC;

4. Outputs

4.1. Customer-facing report layout

Saved as communication/hnr-membership-sales-YYYY-MM-DD.adoc:

  1. Title + Report date + Period descriptions

  2. Registration & Payment Overview table (7 columns: Category / Total / Admin / Online / Paid / Unpaid / Revenue / Outstanding) with per-category rows + TOTAL row

  3. Column definitions block (copied verbatim from the April 7 template)

  4. Key Figures summary table

  5. Year-on-Year Comparison table (Annual and Early Riser vs prior year’s final numbers)

  6. Pending Payments — Annual Membership drill-down per category (Adult, Senior, Child, Family) with contact details (name, cell, email, amount owed)

  7. Pending Payments — Early Riser drill-down

  8. Customer-appropriate notes only (Family pricing explanation; Early Riser period close date; cross-product pending overlap if significant)

4.2. Internal report layout

Saved as communication/hnr-membership-internal-YYYY-MM-DD.adoc:

  1. Title + Report date + Parameter snapshot (period IDs, cut-off, counts)

  2. Reconciliation Snapshot — same per-category matrix as the customer report, annotated with stale-pending counts per row and Family-pricing delta column

  3. Stale Pending Orders — memberships ACTIVE on a different order, but this order is still UNPAID / PENDING in admin-service. Count + rand value + sample list.

  4. Sync Mismatch Candidates — orders PAID in admin-service but WC status unknown, and vice versa. (Populated fully only if WC API extract was done; otherwise section is stubbed with the query to run.)

  5. Duplicate Pending Orders — memberships appearing on 2+ PENDING orders (retry bug symptom, ADO Bug #399). Count per member, worst-case, total duplicated outstanding.

  6. Family Pricing Anomalies — count of FAMILY_ADULT / FAMILY_CHILD line items priced at R450 instead of R0 (recon journal Bug B). Amount overstated in admin-service internal totals.

  7. Unpaid Membership Reachability — of the genuinely-unpaid members, how many have a usable contact number, how many have email, how many have neither. Drives realism of follow-up ROI.

  8. Recommended follow-up actions — 1-2 line checklist pointing at the cleanup runbook / journals (never executed from this runbook).

5. Execution

5.1. Phase 1 — Snapshot the parameters

Open a MySQL session on wpca_prod and SET the working variables:

SET @ORG_ID             := 8;
SET @ANNUAL_PERIOD_ID   := 9;
SET @ER_PERIOD_ID       := 10;
SET @PRIOR_ANNUAL_PERIOD_ID := 7;      -- 2025 Annual (membership_type_id = 1)
SET @PRIOR_ER_PERIOD_ID     := 8;      -- 2025 Early Riser (membership_type_id = 3)
SET @CUTOFF             := '2026-03-01';
SET @REPORT_DATE        := CURDATE();

Sanity-check period scope:

SELECT 'Annual memberships'  AS scope, COUNT(*) FROM membership
  WHERE membership_period_id = @ANNUAL_PERIOD_ID
UNION ALL SELECT 'Early Riser memberships', COUNT(*) FROM membership
  WHERE membership_period_id = @ER_PERIOD_ID
UNION ALL SELECT 'Distinct persons', COUNT(DISTINCT person_id) FROM membership
  WHERE membership_period_id IN (@ANNUAL_PERIOD_ID, @ER_PERIOD_ID);

5.2. Phase 2 — Registration & Payment Overview (customer report)

Core query. One row per membership_criteria, aggregated. The is_online / is_paid / is_unpaid flags implement the definitions from the April 7 template:

  • Admin = membership has no line item with a WC external_id (added by staff, assumed paid offline)

  • Online = membership has at least one line item on an order with a WC external_id (NULL and 'UNPAID' both treated as "no real WC order")

  • Paid = Online AND membership.status = 'A' (active)

  • Unpaid (genuine) = Online AND membership.status IN ('D','P') (not yet active)

  • Revenue = per-membership price charged on its PAID order’s line item (Family Adult/Child zeroed — see Family pricing handling below)

  • Outstanding = per-membership price on its most recent non-cancelled PENDING / UNPAID order

WITH mem_orders AS (
    -- One row per (membership, order) pair linked via order_line_item
    SELECT
        m.id                AS membership_id,
        m.membership_period_id,
        m.status            AS membership_status,
        m.criteria_id,
        m.person_id,
        o.id                AS order_id,
        o.status            AS order_status,
        o.external_id,
        o.transaction_date_time,
        oli.price           AS line_price,
        CASE WHEN o.external_id IS NOT NULL AND o.external_id <> '' AND o.external_id <> 'UNPAID'
             THEN 1 ELSE 0 END AS has_wc_order
    FROM membership m
    LEFT JOIN order_line_item oli ON oli.membership_id = m.id
    LEFT JOIN sales_order     o   ON o.id = oli.order_id
    WHERE m.membership_period_id IN (@ANNUAL_PERIOD_ID, @ER_PERIOD_ID)
),
mem_rollup AS (
    SELECT
        membership_id,
        MAX(membership_status)                             AS membership_status,
        MAX(criteria_id)                                   AS criteria_id,
        MAX(has_wc_order)                                  AS is_online,
        MAX(CASE WHEN order_status = 'P' THEN 1 ELSE 0 END) AS has_paid_order,
        -- Price from a paid order line item (prefer the paid one for Revenue)
        MAX(CASE WHEN order_status = 'P' THEN line_price END) AS paid_price,
        -- Price from a non-paid order line item (for Outstanding)
        MAX(CASE WHEN order_status IN ('D','U') THEN line_price END) AS pending_price
    FROM mem_orders
    GROUP BY membership_id
)
SELECT
    mc.name                            AS category,
    mc.type_selector,
    COUNT(*)                           AS total_count,
    SUM(CASE WHEN is_online = 0 THEN 1 ELSE 0 END)                              AS admin_count,
    SUM(is_online)                                                              AS online_count,
    SUM(CASE WHEN is_online = 1 AND membership_status = 'A' THEN 1 ELSE 0 END)  AS paid_count,
    SUM(CASE WHEN is_online = 1 AND membership_status IN ('D','P')
             THEN 1 ELSE 0 END)                                                 AS unpaid_count,
    -- Revenue: paid-price, but Family Adult/Child contribute R0 (bug-aware)
    SUM(CASE
            WHEN membership_status = 'A' AND is_online = 1
             AND mc.type_selector IN ('FAMILY_ADULT','FAMILY_CHILD') THEN 0
            WHEN membership_status = 'A' AND is_online = 1 THEN COALESCE(paid_price, 0)
            ELSE 0
        END)                                                                    AS revenue_rand,
    SUM(CASE
            WHEN is_online = 1 AND membership_status IN ('D','P')
             AND mc.type_selector IN ('FAMILY_ADULT','FAMILY_CHILD') THEN 0
            WHEN is_online = 1 AND membership_status IN ('D','P')
             THEN COALESCE(pending_price, 0)
            ELSE 0
        END)                                                                    AS outstanding_rand
FROM mem_rollup r
JOIN membership m              ON m.id = r.membership_id
JOIN membership_criteria mc    ON mc.id = r.criteria_id
GROUP BY mc.id, mc.name, mc.type_selector, m.membership_period_id
ORDER BY m.membership_period_id, FIELD(mc.type_selector,
    'ADULT','SENIOR','CHILD','FAMILY_MAIN','FAMILY_ADULT','FAMILY_CHILD','BABY', NULL);

5.2.1. Family pricing handling

Admin-service currently records R450 on every FAMILY_ADULT and FAMILY_CHILD line item instead of R0 (legacy bug, documented in design-journal/2026-03/woocommerce-order-reconciliation.adoc §Bug B). WooCommerce charged the customer correctly — R450 once per family. To keep the customer report truthful (Revenue and Outstanding must reflect cash actually collected / actually owed), Family Adult and Family Child contribute R0 in the aggregations above. Their Total / Online / Paid / Unpaid counts are kept intact because the memberships themselves are real.

5.2.2. Stale-pending exclusion

A membership with status = 'A' plus at least one PENDING order is treated as Paid (not Unpaid) for the customer report, because its active status proves payment was received on some order. The stale PENDING order is counted only on the internal report.

5.3. Phase 3 — Key Figures & YoY (customer report)

5.3.1. Key Figures

Aggregate the Phase 2 output across both periods, or re-query:

WITH totals AS (
    -- (re-use the Phase 2 query as a CTE or save to a TEMPORARY TABLE)
    SELECT /* Phase 2 SELECT without the GROUP BY period split */ ...
)
SELECT
    SUM(total_count)       AS total_registered,
    SUM(admin_count)       AS admin_added,
    SUM(online_count)      AS online_registrations,
    SUM(paid_count)        AS online_paid,
    SUM(unpaid_count)      AS online_unpaid,
    SUM(revenue_rand)      AS revenue_collected,
    SUM(outstanding_rand)  AS outstanding
FROM totals;

5.3.2. Year-on-Year

Same Phase 2 query executed with @PRIOR_ANNUAL_PERIOD_ID / @PRIOR_ER_PERIOD_ID, then the customer report presents:

Type Prior year Final Current % of prior

Annual Membership

(sum of all Annual rows)

(sum of all Annual rows)

(computed)

Early Riser

(single ER row)

(single ER row)

(computed)

5.4. Phase 4 — Pending Payment Drill-Down (customer report)

5.4.1. Person/wp_users linkage (confirmed)

A Person is a WordPress User — PersonWrapper.getId() returns user.getId() directly (database/src/main/java/za/co/idealogic/event/domain/PersonWrapper.java:636). So membership.person_id is the wp_users.ID. No intermediate table; no person table read is required.

All Person attributes used by this runbook are stored as wp_usermeta rows with the meta keys defined as constants on PersonWrapper:

Attribute wp_usermeta.meta_key Notes

First name

first_name

PersonWrapper.USER_META_FIRST_NAME

Last name

last_name

PersonWrapper.USER_META_LAST_NAME

Email (primary)

person_email

PersonWrapper.USER_META_PERSON_EMAIL — preferred source

Email (fallback)

wp_users.user_email

Used only if it does not contain the fake postfix @member.wpcycling.com (PersonWrapper.USER_EMAIL_FAKE_POSTFIX); else treated as no email

Contact number

contact_number

PersonWrapper.USER_META_CONTACT_NUMBER

Parent contact name

parent_name

For children without own contact details

Parent contact email

parent_email

PersonWrapper.USER_META_PARENT_EMAIL

Parent contact number

parent_number

PersonWrapper.USER_META_PARENT_NUMBER

Main-member link

main_member_id

PersonWrapper.USER_META_MAIN_MEMBER_ID — string value equal to the family main member’s wp_users.ID; present on Family Adult / Family Child persons

Display name (computed)

wp_users.display_name

Kept in sync with first/last by PersonWrapper setters — use as last-resort fallback

Sanity check the join before running the drill-downs:

SELECT COUNT(*) AS membership_rows, COUNT(u.ID) AS resolved_users
FROM membership m
LEFT JOIN wp_users u ON u.ID = m.person_id
WHERE m.membership_period_id IN (@ANNUAL_PERIOD_ID, @ER_PERIOD_ID);

Expect resolved_users = membership_rows. Any shortfall points at a corrupted person_id and should be investigated before the drill-down is shown to the customer.

5.4.2. Main drill-down query

SELECT
    mc.type_selector                AS category,
    m.id                            AS membership_id,
    COALESCE(
        NULLIF(TRIM(CONCAT(COALESCE(um_first.meta_value,''),' ',COALESCE(um_last.meta_value,''))), ''),
        u.display_name
    )                               AS member_name,
    COALESCE(um_cell.meta_value,  um_parent_cell.meta_value,  '') AS cell,
    COALESCE(
        um_email.meta_value,
        -- user_email is only usable if it's not the fake postfix
        CASE WHEN u.user_email LIKE '%@member.wpcycling.com' THEN NULL ELSE u.user_email END,
        um_parent_email.meta_value,
        ''
    )                               AS email,
    oli.price                       AS amount_rand,
    o.id                            AS order_id,
    o.external_id                   AS wc_order_id
FROM membership m
JOIN membership_criteria mc    ON mc.id = m.criteria_id
JOIN order_line_item oli       ON oli.membership_id = m.id
JOIN sales_order o             ON o.id = oli.order_id
LEFT JOIN wp_users u           ON u.ID = m.person_id
LEFT JOIN wp_usermeta um_first        ON um_first.user_id        = u.ID AND um_first.meta_key        = 'first_name'
LEFT JOIN wp_usermeta um_last         ON um_last.user_id         = u.ID AND um_last.meta_key         = 'last_name'
LEFT JOIN wp_usermeta um_cell         ON um_cell.user_id         = u.ID AND um_cell.meta_key         = 'contact_number'
LEFT JOIN wp_usermeta um_email        ON um_email.user_id        = u.ID AND um_email.meta_key        = 'person_email'
LEFT JOIN wp_usermeta um_parent_cell  ON um_parent_cell.user_id  = u.ID AND um_parent_cell.meta_key  = 'parent_number'
LEFT JOIN wp_usermeta um_parent_email ON um_parent_email.user_id = u.ID AND um_parent_email.meta_key = 'parent_email'
WHERE m.membership_period_id IN (@ANNUAL_PERIOD_ID, @ER_PERIOD_ID)
  AND m.status IN ('D','P')                                    -- genuinely unpaid (membership not active)
  AND o.status IN ('D','U')                                    -- order also not paid
  AND o.external_id IS NOT NULL AND o.external_id <> 'UNPAID'  -- online order exists
ORDER BY mc.type_selector, member_name;

The customer report presents this split by category (Adult, Senior, Child, Family, Early Riser) with per-category subtotals.

5.4.3. Family drill-down

Family Adult / Family Child persons carry a main_member_id meta value equal to their Family Main’s wp_users.ID. Use it to present each family as one contact block:

SELECT
    main.ID                              AS main_user_id,
    TRIM(CONCAT(COALESCE(mf.meta_value,''),' ',COALESCE(ml.meta_value,''))) AS main_name,
    mc.meta_value                        AS main_cell,
    COALESCE(me.meta_value,
        CASE WHEN main.user_email LIKE '%@member.wpcycling.com' THEN NULL ELSE main.user_email END
    )                                    AS main_email,
    -- Family members as a comma-separated list with their type_selector
    GROUP_CONCAT(
        CONCAT(
            TRIM(CONCAT(COALESCE(mem_f.meta_value,''),' ',COALESCE(mem_l.meta_value,''))),
            ' (', crit.type_selector, ')'
        )
        ORDER BY crit.type_selector, mem_l.meta_value SEPARATOR ', '
    )                                    AS family_members,
    SUM(oli.price)                       AS family_outstanding_rand
FROM membership m_main
JOIN membership_criteria c_main ON c_main.id = m_main.criteria_id AND c_main.type_selector = 'FAMILY_MAIN'
JOIN wp_users main              ON main.ID = m_main.person_id
LEFT JOIN wp_usermeta mf ON mf.user_id = main.ID AND mf.meta_key = 'first_name'
LEFT JOIN wp_usermeta ml ON ml.user_id = main.ID AND ml.meta_key = 'last_name'
LEFT JOIN wp_usermeta mc ON mc.user_id = main.ID AND mc.meta_key = 'contact_number'
LEFT JOIN wp_usermeta me ON me.user_id = main.ID AND me.meta_key = 'person_email'
-- Join the membership's own order line (unpaid order)
JOIN order_line_item oli_main ON oli_main.membership_id = m_main.id
JOIN sales_order o_main       ON o_main.id = oli_main.order_id
                             AND o_main.status IN ('D','U')
                             AND o_main.external_id IS NOT NULL AND o_main.external_id <> 'UNPAID'
-- Gather the family members via main_member_id meta
LEFT JOIN wp_usermeta mm        ON mm.meta_key = 'main_member_id' AND mm.meta_value = CAST(main.ID AS CHAR)
LEFT JOIN wp_users member       ON member.ID = mm.user_id
LEFT JOIN wp_usermeta mem_f     ON mem_f.user_id = member.ID AND mem_f.meta_key = 'first_name'
LEFT JOIN wp_usermeta mem_l     ON mem_l.user_id = member.ID AND mem_l.meta_key = 'last_name'
LEFT JOIN membership m_mem      ON m_mem.person_id = member.ID
                               AND m_mem.membership_period_id = @ANNUAL_PERIOD_ID
LEFT JOIN membership_criteria crit ON crit.id = m_mem.criteria_id
LEFT JOIN order_line_item oli   ON oli.membership_id = m_main.id
WHERE m_main.membership_period_id = @ANNUAL_PERIOD_ID
  AND m_main.status IN ('D','P')
GROUP BY main.ID, main_name, main_cell, main_email
ORDER BY main_name;
main_member_id is stored as a string meta value; the CAST ensures a clean equality join. If the query returns family members with NULL family_members, the member persons may have been created without the main_member_id meta populated — acceptable for the first-pass report; list the Family Main alone in that case.

5.5. Phase 5 — Customer report notes

Fixed footnotes to append verbatim to the customer report (keeps tone consistent with April 7):

  • Admin-added members (n): added by staff without an online order. Payment is assumed to have been received via cash, EFT, or at the Visitor Centre. The system does not yet record payment status for admin-created memberships.

  • Family pricing: Family Adult and Family Child memberships are R0 — covered by the Family Main fee (R450).

  • Early Riser period: closed YYYY-MM-DD. The (n) registrations represent (%) of (prior year)'s final total (n).

  • Cross-product pending overlap: many Early Riser members also appear on the Annual Membership pending list — they registered for both but paid for neither. A single follow-up per person covers both.

Do not include internal-report items (stale pending, duplicate orders, sync mismatches, Family pricing bug) in the customer report.

5.6. Phase 6 — Data quality & duplicates (internal report)

5.6.1. Stale pending orders

Memberships whose status = 'A' (proven paid) but which have at least one PENDING or UNPAID order on the same period. These inflate the EMS "pending" counts and their orders are cleanup candidates in duplicate-order-purge.adoc.

SELECT
    mc.name                    AS category,
    COUNT(DISTINCT m.id)       AS memberships_affected,
    COUNT(DISTINCT o.id)       AS stale_order_count,
    SUM(oli.price)             AS stale_order_value_rand
FROM membership m
JOIN membership_criteria mc ON mc.id = m.criteria_id
JOIN order_line_item oli    ON oli.membership_id = m.id
JOIN sales_order o          ON o.id = oli.order_id
WHERE m.membership_period_id IN (@ANNUAL_PERIOD_ID, @ER_PERIOD_ID)
  AND m.status = 'A'                               -- already active
  AND o.status IN ('D','U')                        -- order still open
  AND o.external_id IS NOT NULL AND o.external_id <> 'UNPAID'
GROUP BY mc.id, mc.name
ORDER BY mc.name;

5.6.2. Duplicate pending orders (retry bug — ADO Bug #399)

Memberships with 2+ PENDING / UNPAID orders. Counted at membership level so "outstanding" is not double-counted.

SELECT
    m.id                       AS membership_id,
    mc.name                    AS category,
    COUNT(DISTINCT o.id)       AS pending_order_count,
    GROUP_CONCAT(DISTINCT o.id ORDER BY o.id)       AS order_ids,
    MIN(o.transaction_date_time) AS earliest,
    MAX(o.transaction_date_time) AS latest,
    SUM(oli.price)             AS duplicated_outstanding_rand
FROM membership m
JOIN membership_criteria mc ON mc.id = m.criteria_id
JOIN order_line_item oli    ON oli.membership_id = m.id
JOIN sales_order o          ON o.id = oli.order_id
WHERE m.membership_period_id IN (@ANNUAL_PERIOD_ID, @ER_PERIOD_ID)
  AND m.status IN ('D','P')
  AND o.status IN ('D','U')
GROUP BY m.id, mc.name
HAVING COUNT(DISTINCT o.id) > 1
ORDER BY pending_order_count DESC, m.id;

5.6.3. Family pricing anomaly count

From the recon journal Bug B — counts only, not remediated here:

SELECT
    mc.type_selector,
    COUNT(*)                                          AS line_items,
    SUM(CASE WHEN oli.price = 450 THEN 1 ELSE 0 END)  AS lines_at_r450,
    SUM(CASE WHEN oli.price = 0   THEN 1 ELSE 0 END)  AS lines_at_r0,
    SUM(oli.price) - SUM(CASE WHEN oli.price > 0 AND mc.type_selector = 'FAMILY_MAIN'
                              THEN oli.price ELSE 0 END) AS overstated_rand
FROM membership m
JOIN membership_criteria mc ON mc.id = m.criteria_id
JOIN order_line_item oli    ON oli.membership_id = m.id
JOIN sales_order o          ON o.id = oli.order_id
WHERE m.membership_period_id IN (@ANNUAL_PERIOD_ID, @ER_PERIOD_ID)
  AND mc.type_selector LIKE 'FAMILY%'
  AND o.status = 'P'
GROUP BY mc.type_selector;

5.6.4. Sync mismatch candidates (stub if WC API not available)

Placeholder: if the WC extract step has been run, cross-reference EMS external_id against the fetched WC statuses and tabulate the matrix:

EMS Status WC Status Count Meaning

PENDING

completed

Sync failure — EMS should be PAID. Escalate to cleanup.

PAID

cancelled / failed / refunded

EMS thinks paid, WC disagrees. Investigate.

CANCELLED

pending

Stale WC order — should be trashed.

Use the extraction pattern from duplicate-order-purge.adoc Phase 2.2 (Python script fetching include=<ids>&_fields=id,status,total). Note explicitly in the report if this step was skipped.

5.6.5. Unpaid reachability summary

Of the genuinely-unpaid members surfaced in Phase 4, how many are reachable:

SELECT
    mc.type_selector,
    COUNT(DISTINCT m.id) AS unpaid_members,
    SUM(CASE WHEN COALESCE(um_cell.meta_value,  um_parent_cell.meta_value)  <> '' THEN 1 ELSE 0 END) AS with_cell,
    SUM(CASE WHEN COALESCE(
            um_email.meta_value,
            CASE WHEN u.user_email LIKE '%@member.wpcycling.com' THEN NULL ELSE u.user_email END,
            um_parent_email.meta_value
        ) <> '' THEN 1 ELSE 0 END) AS with_email,
    SUM(CASE WHEN COALESCE(um_cell.meta_value,  um_parent_cell.meta_value)  = ''
             AND  COALESCE(
                    um_email.meta_value,
                    CASE WHEN u.user_email LIKE '%@member.wpcycling.com' THEN NULL ELSE u.user_email END,
                    um_parent_email.meta_value
                 ) = ''
             THEN 1 ELSE 0 END) AS unreachable
FROM membership m
JOIN membership_criteria mc ON mc.id = m.criteria_id
JOIN order_line_item oli    ON oli.membership_id = m.id
JOIN sales_order o          ON o.id = oli.order_id
LEFT JOIN wp_users u        ON u.ID = m.person_id
LEFT JOIN wp_usermeta um_cell         ON um_cell.user_id  = u.ID AND um_cell.meta_key  = 'contact_number'
LEFT JOIN wp_usermeta um_email        ON um_email.user_id = u.ID AND um_email.meta_key = 'person_email'
LEFT JOIN wp_usermeta um_parent_cell  ON um_parent_cell.user_id  = u.ID AND um_parent_cell.meta_key  = 'parent_number'
LEFT JOIN wp_usermeta um_parent_email ON um_parent_email.user_id = u.ID AND um_parent_email.meta_key = 'parent_email'
WHERE m.membership_period_id IN (@ANNUAL_PERIOD_ID, @ER_PERIOD_ID)
  AND m.status IN ('D','P')
  AND o.status IN ('D','U')
  AND o.external_id IS NOT NULL AND o.external_id <> 'UNPAID'
GROUP BY mc.type_selector;

5.7. Phase 7 — Assemble output documents

Paste the query outputs into the report templates. File naming:

# From ~/dev/ems
REPORT_DATE=$(date +%Y-%m-%d)

mkdir -p communication
touch "communication/hnr-membership-sales-${REPORT_DATE}.adoc"       # customer-facing
touch "communication/hnr-membership-internal-${REPORT_DATE}.adoc"    # internal

Each report file carries its own AsciiDoc header (title, report date, report-scope attributes). The customer-facing file is the one that goes into the authoring tool’s preview pane for cut-and-paste to email. The internal file is read in place.

The communication/ directory is the project-wide home for generated, non-design outputs (reports, letters, CSVs that will be sent out or consulted ad-hoc). It is deliberately separate from design-journal/ so the latter stays reserved for design discussions and decisions. Filenames are org-prefixed to keep the directory browseable as it grows.

No updates to design-journal/index.adoc are needed for these report artefacts — they are outputs of this runbook, not design threads. Append a one-liner to this runbook’s Execution Log instead.

5.8. Phase 8 — Verification

Before sending the customer report:

  1. Customer totals row equals sum of category rows (arithmetic cross-check).

  2. total_count on any row equals admin_count + online_count.

  3. online_count equals paid_count + unpaid_count + stale_pending_count — where stale_pending_count comes from the internal report and is not displayed in the customer report.

  4. Revenue + Outstanding equals the sum of all PAID + pending-but-covered-by-this-runbook line-item amounts (sanity against sales_order.payment_amount totals).

  5. Spot-check three pending-list entries against the live DB — names, cell, email correct and current.

  6. Cross-year comparison uses final prior-year counts, not a point-in-time snapshot.

6. Decisions made

Decision Rationale

Two report documents, same pipeline

Customer and internal audiences have incompatible information needs. Generating both from one pass keeps the numbers consistent.

Runbook is read-only

Reporting and remediation are different risk profiles. Mixing them costs the "safe to run anytime" property. Remediation lives in duplicate-order-purge.adoc.

AsciiDoc for report outputs

Matches docs-event conventions and the author’s preview/cut-and-paste tooling. The April 7 Markdown report is retained as an archival reference only.

Family Adult / Family Child contribute R0 to Revenue and Outstanding

Admin-service overstates these line items (recon journal Bug B); the customer report must reflect cash reality. The inflation amount is reported separately on the internal report.

Stale pending orders count as Paid (not Unpaid) in the customer report

Membership ACTIVE is ground truth for "customer has paid us". The stale order is a data-quality artefact, not an outstanding debt. The internal report surfaces it.

WC API cross-reference is optional in the internal report

The report remains useful without it; the WC step can be added when credentials are on hand and a cleanup pass is imminent.

No regeneration of the April 7 report

The earlier report is archival. New reports start from today.

Person-to-user linkage resolved via PersonWrapper, not a data probe

PersonWrapper.getId() returns user.getId() directly — so membership.person_id = wp_users.ID is a code-enforced invariant, not an empirical finding. All contact-detail joins go through wp_users + wp_usermeta using the meta-key constants in PersonWrapper. The person table is not referenced.

@member.wpcycling.com emails treated as "no email"

PersonWrapper.USER_EMAIL_FAKE_POSTFIX — these are synthetic placeholder addresses generated at user-creation and are not deliverable. Falling back to them in a customer report would produce bounce-prone contact lists.

7. Retention

Each dated report file is retained indefinitely in communication/. There are no temporary tables, snapshots, or scratch schemas to clean up — this runbook queries live data only.

8. References

8.1. Design journals

Journal Relevance

design-journal/2026-03/woocommerce-order-reconciliation.adoc

Defines admin-service ↔ WC recon queries (Recon 1–7), product/pricing reference tables, Family pricing bug, WC CSV extraction. Phase 6 internal-report queries derive from Recons 4, 6, 7.

design-journal/2026-03/duplicate-order-purge.adoc

Manual Reconciliation Runbook section provides the Membership variant queries (Phases 1 and 2) reused here. Defines the sales_order / order_line_item / membership schema and status codes.

design-journal/2026-04/hnr-membership-report-2026-04-07.md

The canonical customer-facing layout this runbook reproduces. Archival reference only.

design-journal/2026-04/hnr-pending-early-riser.adoc

HNR-specific billing-window bug (11 April 2026) affecting 2-hour cohort. If any current pending orders trace back to that window, add a footnote in the customer report linking the affected members to the separate communication thread.

8.2. Source code

File Purpose

database/src/main/java/za/co/idealogic/event/domain/Membership.java

Membership entity — status (D/P/A/R/E), criteria, period, person.

database/src/main/java/za/co/idealogic/event/domain/MembershipCriteria.java

Criteria entity — type_selector (ADULT/SENIOR/CHILD/FAMILY_MAIN/FAMILY_ADULT/FAMILY_CHILD/BABY), display name, product link.

database/src/main/java/za/co/idealogic/event/domain/Order.java

sales_order — status (U/D/P/R/C), external_id = WC order ID, payment_amount, transaction_date_time.

database/src/main/java/za/co/idealogic/event/domain/OrderLineItem.java

Line-item entity — links order_idmembership_id, price charged on the line.

database/src/main/java/za/co/idealogic/event/domain/PersonWrapper.java

Person ↔ WordPress User adaptor. Confirms Person.id = wp_users.ID. Defines all wp_usermeta keys used for name, email, cell, parent contact, and family linkage (main_member_id). Encodes the @member.wpcycling.com fake-email rule used by the email-resolution logic.

8.3. Skills

  • ~/dev/ai-skills-develop/skills/devops/mysql-idealogic-prod/SKILL.md — SSH tunnel + MySQL access to wpca_prod.

9. Future direction

This runbook is the manual precursor to in-system reporting dashboards. The aggregations encoded here (per-category Paid/Unpaid matrix, stale-pending tally, duplicate-order de-duplication, reachability breakdown) are the direct candidates for API / UI reporting endpoints. A follow-up design journal will take this runbook’s queries as the requirements input and design the dashboard implementation — likely a ReportingResource in admin-service emitting JSON that both the admin-ui can render and this runbook can replace.

10. Execution log

Append a one-line entry per run in the form | YYYY-MM-DD | report filename(s) | notes.
Date Output Notes

2026-04-24

communication/hnr-membership-sales-2026-04-24.adoc + communication/hnr-membership-internal-2026-04-24.adoc

First run of runbook. 4,287 total memberships (3,596 Annual + 691 Early Riser). 1,750 paid online, 74 genuinely unpaid (R11,140 outstanding), 2,464 admin-added. Four corrective-action cohorts surfaced for operator consideration: van Barkenhuizen Family Children sync failure (2 memberships — trivial manual fix), 70 stale pending orders (R12,820 cleanup value), 108 excess retry-duplicate orders, 19 duplicate person records. WC API cross-reference skipped (no credentials this run).