Runbook: HNR Membership Sales & Metrics Reporting
|
This runbook is read-only. It extracts, aggregates, and formats data from |
1. Context
1.1. What this runbook produces
Two AsciiDoc reports, generated from the same dataset in a single execution:
-
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. -
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.
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.adocand 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 |
|---|---|---|
|
e.g. |
Today’s date |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Earliest |
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:
-
Title + Report date + Period descriptions
-
Registration & Payment Overview table (7 columns: Category / Total / Admin / Online / Paid / Unpaid / Revenue / Outstanding) with per-category rows + TOTAL row
-
Column definitions block (copied verbatim from the April 7 template)
-
Key Figures summary table
-
Year-on-Year Comparison table (Annual and Early Riser vs prior year’s final numbers)
-
Pending Payments — Annual Membership drill-down per category (Adult, Senior, Child, Family) with contact details (name, cell, email, amount owed)
-
Pending Payments — Early Riser drill-down
-
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:
-
Title + Report date + Parameter snapshot (period IDs, cut-off, counts)
-
Reconciliation Snapshot — same per-category matrix as the customer report, annotated with stale-pending counts per row and Family-pricing delta column
-
Stale Pending Orders — memberships ACTIVE on a different order, but this order is still UNPAID / PENDING in admin-service. Count + rand value + sample list.
-
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.)
-
Duplicate Pending Orders — memberships appearing on 2+ PENDING orders (retry bug symptom, ADO Bug #399). Count per member, worst-case, total duplicated outstanding.
-
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.
-
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.
-
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(NULLand'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.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 |
|
|
Last name |
|
|
Email (primary) |
|
|
Email (fallback) |
|
Used only if it does not contain the fake postfix |
Contact number |
|
|
Parent contact name |
|
For children without own contact details |
Parent contact email |
|
|
Parent contact number |
|
|
Main-member link |
|
|
Display name (computed) |
|
Kept in sync with first/last by |
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:
-
Customer totals row equals sum of category rows (arithmetic cross-check).
-
total_counton any row equalsadmin_count + online_count. -
online_countequalspaid_count + unpaid_count + stale_pending_count— wherestale_pending_countcomes from the internal report and is not displayed in the customer report. -
Revenue + Outstanding equals the sum of all PAID + pending-but-covered-by-this-runbook line-item amounts (sanity against
sales_order.payment_amounttotals). -
Spot-check three pending-list entries against the live DB — names, cell, email correct and current.
-
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 |
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 |
|
|
|
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 |
|---|---|
|
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. |
|
Manual Reconciliation Runbook section provides the Membership variant queries (Phases 1 and 2) reused here. Defines the |
|
The canonical customer-facing layout this runbook reproduces. Archival reference only. |
|
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 |
|---|---|
|
Membership entity — status ( |
|
Criteria entity — |
|
sales_order — status ( |
|
Line-item entity — links |
|
Person ↔ WordPress User adaptor. Confirms |
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 |
|
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). |