From bae871a763d109ab63f4dd5c599af8512891e5e4 Mon Sep 17 00:00:00 2001 From: jmgasper Date: Tue, 4 Aug 2026 06:18:40 +1000 Subject: [PATCH] PM-5719: Include projected member payments What was broken The member payment dashboards omitted owed and on-hold July payments, leaving only an incorrect $51 contest total and no TAAS or engagement values. Root cause Both dashboard queries required PAID status and grouped values by payout date, unlike the established dashboard and Wallet Admin convention of using current non-cancelled payments in their creation month. What was changed Updated both payment-value queries to include latest non-cancelled payment records and group gross member amounts by payment creation month. Updated the dashboard documentation to describe the corrected projection semantics. Any added/updated tests Updated dashboard SQL regression coverage to require creation-month and non-cancelled selection for both payment charts and to reject PAID/date-paid filtering. --- README.md | 9 ++++--- .../dashboard/member-payment-by-customer.sql | 26 ++++++++++--------- .../dashboard/member-payment-by-month.sql | 25 +++++++++--------- .../dashboard/dashboard-reports.sql.spec.ts | 13 +++++++--- 4 files changed, 41 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 7608a85..ac64014 100644 --- a/README.md +++ b/README.md @@ -38,10 +38,11 @@ Dashboard figures use these shared definitions: `PAYMENT` winning. It is grouped by the payment creation month so projected payments that are owed or on hold remain visible. Members are deduplicated within each payment bucket and month. -- Member-payment values use the latest payment version and sum `gross_amount`, - falling back to `total_amount`. The payment-by-customer dashboard ranks the - top five billing-account clients across the selected range and groups all - unnamed or remaining clients under `Other Customers`. +- Member-payment values use the latest non-cancelled finance payment and group + `gross_amount` by payment creation month, falling back to `total_amount`. + The payment-by-customer dashboard ranks the top five billing-account clients + across the selected range and groups all unnamed or remaining clients under + `Other Customers`. - Challenge participation uses the latest actual phase completion month for Challenge, Marathon Match, and First2Finish cohorts. Registrants are Submitter resources, and submitters have a non-deleted submission for the diff --git a/sql/reports/dashboard/member-payment-by-customer.sql b/sql/reports/dashboard/member-payment-by-customer.sql index d506491..d619b60 100644 --- a/sql/reports/dashboard/member-payment-by-customer.sql +++ b/sql/reports/dashboard/member-payment-by-customer.sql @@ -1,11 +1,13 @@ --- Monthly paid-member values split by the selected range's top five clients. +-- Monthly member-payment values split by the selected range's top five clients. -- -- Parameters: -- $1 timestamptz - inclusive reporting range start -- $2 timestamptz - exclusive reporting range end -- --- The same ranked customer series is used for every month. Payments for --- unranked or unnamed clients are grouped under Other Customers. +-- The same ranked customer series is used for every month. The latest +-- non-cancelled payment record is counted in its creation month so projected +-- payments remain visible. Payments for unranked or unnamed clients are +-- grouped under Other Customers. -- Billing-account ids are normalized and compared as text so the historical -- zero sentinel falls back to challenge billing without unsafe integer casts. WITH bounds AS ( @@ -28,9 +30,9 @@ latest_payment_versions AS MATERIALIZED ( FROM finance.payment p GROUP BY p.winnings_id ), -paid_events AS MATERIALIZED ( +payment_events AS MATERIALIZED ( SELECT - COALESCE(p.date_paid, p.created_at) AS paid_at, + p.created_at AS activity_at, COALESCE(p.gross_amount, p.total_amount, 0) AS amount, NULLIF(TRIM(cl.id), '') AS customer_id, NULLIF(TRIM(cl.name), '') AS customer_label @@ -56,18 +58,18 @@ paid_events AS MATERIALIZED ( ) LEFT JOIN "billing-accounts"."Client" cl ON cl.id = COALESCE(payment_ba."clientId", challenge_ba."clientId") - WHERE p.payment_status = 'PAID' + WHERE p.payment_status IS DISTINCT FROM 'CANCELLED' AND w.type = 'PAYMENT' - AND COALESCE(p.date_paid, p.created_at) IS NOT NULL + AND p.created_at IS NOT NULL AND NULLIF(TRIM(w.winner_id), '') IS NOT NULL AND w.category::text IS DISTINCT FROM 'TOPGEAR_PAYMENT' ), selected_events AS ( SELECT pe.* - FROM paid_events pe + FROM payment_events pe CROSS JOIN bounds b - WHERE pe.paid_at >= b.start_at - AND pe.paid_at < b.end_at + WHERE pe.activity_at >= b.start_at + AND pe.activity_at < b.end_at ), customer_totals AS ( SELECT @@ -118,7 +120,7 @@ series AS ( ), monthly_amounts AS ( SELECT - DATE_TRUNC('month', se.paid_at) AS month_start, + DATE_TRUNC('month', se.activity_at) AS month_start, COALESCE( 'customer-' || tc.customer_id, 'other-customers' @@ -129,7 +131,7 @@ monthly_amounts AS ( ON tc.customer_id = se.customer_id AND tc.customer_label = se.customer_label GROUP BY - DATE_TRUNC('month', se.paid_at), + DATE_TRUNC('month', se.activity_at), COALESCE('customer-' || tc.customer_id, 'other-customers') ) SELECT diff --git a/sql/reports/dashboard/member-payment-by-month.sql b/sql/reports/dashboard/member-payment-by-month.sql index 6623be8..97a4c3f 100644 --- a/sql/reports/dashboard/member-payment-by-month.sql +++ b/sql/reports/dashboard/member-payment-by-month.sql @@ -1,11 +1,12 @@ --- Monthly paid-member values split by canonical payment bucket. +-- Monthly member-payment values split by canonical payment bucket. -- -- Parameters: -- $1 timestamptz - inclusive reporting range start -- $2 timestamptz - exclusive reporting range end -- --- Only the latest version of each payment is considered. Gross amount is the --- preferred member-payment value, with total amount used as a fallback. +-- The latest non-cancelled payment record is counted in its creation month so +-- projected payments remain visible. Gross amount is the preferred +-- member-payment value, with total amount used as a fallback. WITH bounds AS ( SELECT $1::timestamptz AT TIME ZONE 'UTC' AS start_at, @@ -26,9 +27,9 @@ latest_payment_versions AS MATERIALIZED ( FROM finance.payment p GROUP BY p.winnings_id ), -paid_events AS MATERIALIZED ( +payment_events AS MATERIALIZED ( SELECT - COALESCE(p.date_paid, p.created_at) AS paid_at, + p.created_at AS activity_at, COALESCE(p.gross_amount, p.total_amount, 0) AS amount, CASE WHEN w.category::text = 'TAAS_PAYMENT' THEN 'taas' @@ -48,15 +49,15 @@ paid_events AS MATERIALIZED ( AND lpv.max_version = p.version JOIN finance.winnings w ON w.winning_id = p.winnings_id - WHERE p.payment_status = 'PAID' + WHERE p.payment_status IS DISTINCT FROM 'CANCELLED' AND w.type = 'PAYMENT' - AND COALESCE(p.date_paid, p.created_at) IS NOT NULL + AND p.created_at IS NOT NULL AND NULLIF(TRIM(w.winner_id), '') IS NOT NULL AND w.category::text IS DISTINCT FROM 'TOPGEAR_PAYMENT' ), selected_months AS ( SELECT - DATE_TRUNC('month', pe.paid_at) AS month_start, + DATE_TRUNC('month', pe.activity_at) AS month_start, COALESCE(SUM(pe.amount) FILTER ( WHERE pe.payment_type = 'taas' ), 0) AS taas, @@ -69,11 +70,11 @@ selected_months AS ( COALESCE(SUM(pe.amount) FILTER ( WHERE pe.payment_type = 'engagement' ), 0) AS engagement - FROM paid_events pe + FROM payment_events pe CROSS JOIN bounds b - WHERE pe.paid_at >= b.start_at - AND pe.paid_at < b.end_at - GROUP BY DATE_TRUNC('month', pe.paid_at) + WHERE pe.activity_at >= b.start_at + AND pe.activity_at < b.end_at + GROUP BY DATE_TRUNC('month', pe.activity_at) ) SELECT TO_CHAR(m.month_start, 'YYYY-MM-01') AS month, diff --git a/src/reports/dashboard/dashboard-reports.sql.spec.ts b/src/reports/dashboard/dashboard-reports.sql.spec.ts index ec5891b..a88c0ef 100644 --- a/src/reports/dashboard/dashboard-reports.sql.spec.ts +++ b/src/reports/dashboard/dashboard-reports.sql.spec.ts @@ -54,14 +54,16 @@ describe("Dashboard report SQL", () => { expect(sql).toMatch(/COUNT\(DISTINCT pe\.member_id\) FILTER/g); }); - it("sums latest paid-member values by canonical payment bucket", () => { + it("sums projected member-payment values by canonical payment bucket", () => { const sql = sqlLoader.load("reports/dashboard/member-payment-by-month.sql"); expect(sql).toContain("MAX(p.version) AS max_version"); expect(sql).toContain("lpv.max_version = p.version"); - expect(sql).toContain("p.payment_status = 'PAID'"); + expect(sql).toContain("p.payment_status IS DISTINCT FROM 'CANCELLED'"); expect(sql).toContain("w.type = 'PAYMENT'"); - expect(sql).toContain("COALESCE(p.date_paid, p.created_at)"); + expect(sql).toContain("p.created_at AS activity_at"); + expect(sql).not.toContain("p.payment_status = 'PAID'"); + expect(sql).not.toContain("p.date_paid"); expect(sql).toContain( "COALESCE(p.gross_amount, p.total_amount, 0) AS amount", ); @@ -80,7 +82,10 @@ describe("Dashboard report SQL", () => { ); expect(sql).toContain("MAX(p.version) AS max_version"); - expect(sql).toContain("p.payment_status = 'PAID'"); + expect(sql).toContain("p.payment_status IS DISTINCT FROM 'CANCELLED'"); + expect(sql).toContain("p.created_at AS activity_at"); + expect(sql).not.toContain("p.payment_status = 'PAID'"); + expect(sql).not.toContain("p.date_paid"); expect(sql).toContain( "COALESCE(p.gross_amount, p.total_amount, 0) AS amount", );