From c3b3248aa749511fc3008b6b0be23e7b540006bf Mon Sep 17 00:00:00 2001 From: jmgasper Date: Fri, 31 Jul 2026 16:48:41 +1000 Subject: [PATCH] PM-5703: correct dashboard payment and participation data What was broken Unique Members Paid omitted projected TaaS, task, challenge, and engagement payments until they reached PAID status, making monthly and all-time metrics disagree with Wallet Admin. Challenge participation counted legacy resource imports in the import month, producing an April 2026 registrant spike. Root cause The payment query filtered to PAID and grouped by payout date. The participation query grouped resource and submission creation timestamps independently, although imported resources do not preserve their historical registration dates. What was changed Count the latest non-cancelled payment record in its creation month while retaining the existing payment buckets and Topgear exclusion. Cohort supported challenges by their latest actual phase completion date, then count unique submitter resources and same-challenge/member non-deleted submissions in that cohort. Any added/updated tests Updated dashboard SQL regression coverage for projected payment status/version/date semantics, supported challenge completion cohorts, linked submission membership, and removal of import/submission timestamp grouping. All 35 dashboard tests, lint, and build pass; the repository-wide suite still has 21 pre-existing failures in unchanged SFDC and report-directory specs. --- README.md | 16 ++++--- .../dashboard/challenge-participation.sql | 47 +++++++++++++++---- sql/reports/dashboard/members-paid.sql | 45 +++++++++++------- .../dashboard/dashboard-reports.sql.spec.ts | 27 ++++++++--- 4 files changed, 94 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 3b8dc07..7608a85 100644 --- a/README.md +++ b/README.md @@ -34,17 +34,19 @@ Dashboard figures use these shared definitions: - Signups come from `identity.user.create_date`. `status = 'A'` is activated; every other current status is not activated. -- Paid-member activity requires a `PAID` finance payment for a `PAYMENT` - winning. Its event timestamp is `date_paid`, falling back to `created_at`. - Members are deduplicated within each payment bucket and month. +- Paid-member activity uses the latest non-cancelled finance payment for a + `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`. -- Registrations are Submitter resource creation events. Submissions are - non-deleted review submission events, using `submittedDate` and falling - back to `createdAt`. Each category is deduplicated independently by member - and month. +- 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 + same challenge and member. Each category is deduplicated by member and + cohort month. - Rates are percentages from 0 through 100. Human access is limited to Administrator and Talent Manager roles. Machine diff --git a/sql/reports/dashboard/challenge-participation.sql b/sql/reports/dashboard/challenge-participation.sql index b48a863..df26712 100644 --- a/sql/reports/dashboard/challenge-participation.sql +++ b/sql/reports/dashboard/challenge-participation.sql @@ -4,8 +4,10 @@ -- $1 timestamptz - inclusive reporting range start -- $2 timestamptz - exclusive reporting range end -- --- Registration and submission are independent activity events. A member is --- counted once in each category per month, regardless of challenge count. +-- The month is the challenge cohort's latest actual phase completion month, +-- matching the Challenge Registrants report. This avoids treating legacy +-- resource import timestamps as registration dates. A member is counted once +-- in each category per month, regardless of challenge count. WITH bounds AS ( SELECT $1::timestamptz AT TIME ZONE 'UTC' AS start_at, @@ -19,11 +21,31 @@ months AS ( ) AS month_start FROM bounds b ), -registration_events AS MATERIALIZED ( +eligible_challenges AS MATERIALIZED ( SELECT + c.id AS challenge_id, + lp."actualEndDate" AS activity_at + FROM challenges."Challenge" c + JOIN challenges."ChallengeType" ct + ON ct.id = c."typeId" + JOIN LATERAL ( + SELECT cp."actualEndDate" + FROM challenges."ChallengePhase" cp + WHERE cp."challengeId" = c.id + ORDER BY cp."scheduledEndDate" DESC + LIMIT 1 + ) lp + ON lp."actualEndDate" IS NOT NULL + WHERE ct.name IN ('Challenge', 'Marathon Match', 'First2Finish') +), +registration_events AS MATERIALIZED ( + SELECT DISTINCT + ec.challenge_id, NULLIF(TRIM(r."memberId"), '') AS member_id, - r."createdAt" AS activity_at - FROM resources."Resource" r + ec.activity_at + FROM eligible_challenges ec + JOIN resources."Resource" r + ON r."challengeId" = ec.challenge_id JOIN resources."ResourceRole" rr ON rr.id = r."roleId" WHERE COALESCE(NULLIF(TRIM(rr."nameLower"), ''), LOWER(rr.name)) = 'submitter' @@ -31,11 +53,16 @@ registration_events AS MATERIALIZED ( ), submission_events AS MATERIALIZED ( SELECT - NULLIF(TRIM(s."memberId"), '') AS member_id, - COALESCE(s."submittedDate", s."createdAt") AS activity_at - FROM reviews.submission s - WHERE s.status <> 'DELETED' - AND NULLIF(TRIM(s."memberId"), '') IS NOT NULL + re.member_id, + re.activity_at + FROM registration_events re + WHERE EXISTS ( + SELECT 1 + FROM reviews.submission s + WHERE s."challengeId" = re.challenge_id + AND s."memberId" = re.member_id + AND s.status <> 'DELETED' + ) ), selected_registrations AS ( SELECT diff --git a/sql/reports/dashboard/members-paid.sql b/sql/reports/dashboard/members-paid.sql index a37aef9..9ddcd20 100644 --- a/sql/reports/dashboard/members-paid.sql +++ b/sql/reports/dashboard/members-paid.sql @@ -4,11 +4,10 @@ -- $1 timestamptz - inclusive reporting range start -- $2 timestamptz - exclusive reporting range end -- --- A paid event is a PAYMENT winning whose current payment status is PAID. --- date_paid is authoritative when present; created_at supports migrated paid rows --- that do not have a paid timestamp. TOPGEAR_PAYMENT is intentionally excluded --- because it is a separate canonical accrual bucket and is not one of the four --- dashboard categories. +-- The report is a financial projection, so the latest non-cancelled payment +-- record is counted in its creation month even when payout is still on hold or +-- owed. TOPGEAR_PAYMENT is intentionally excluded because it is a separate +-- canonical accrual bucket and is not one of the four dashboard categories. WITH bounds AS ( SELECT $1::timestamptz AT TIME ZONE 'UTC' AS start_at, @@ -22,10 +21,17 @@ months AS ( ) AS month_start FROM bounds b ), -paid_events AS MATERIALIZED ( +latest_payment_versions AS MATERIALIZED ( + SELECT + p.winnings_id, + MAX(p.version) AS max_version + FROM finance.payment p + GROUP BY p.winnings_id +), +payment_events AS MATERIALIZED ( SELECT NULLIF(TRIM(w.winner_id), '') AS member_id, - COALESCE(p.date_paid, p.created_at) AS paid_at, + p.created_at AS activity_at, CASE WHEN w.category::text = 'TAAS_PAYMENT' THEN 'taas' WHEN w.category::text = 'ENGAGEMENT_PAYMENT' THEN 'engagement' @@ -39,17 +45,20 @@ paid_events AS MATERIALIZED ( ELSE 'challenge' END AS payment_type FROM finance.payment p + JOIN latest_payment_versions lpv + ON lpv.winnings_id = p.winnings_id + 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, COUNT(DISTINCT pe.member_id) FILTER ( WHERE pe.payment_type = 'taas' ) AS taas, @@ -62,18 +71,18 @@ selected_months AS ( COUNT(DISTINCT pe.member_id) FILTER ( WHERE pe.payment_type = 'engagement' ) 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) ), all_time_months AS ( SELECT - DATE_TRUNC('month', pe.paid_at) AS month_start, + DATE_TRUNC('month', pe.activity_at) AS month_start, COUNT(DISTINCT pe.member_id) AS unique_members - FROM paid_events pe - GROUP BY DATE_TRUNC('month', pe.paid_at) + FROM payment_events pe + GROUP BY DATE_TRUNC('month', pe.activity_at) ), peak_month AS ( SELECT @@ -98,7 +107,7 @@ all_time_summary AS ( COUNT(DISTINCT pe.member_id) FILTER ( WHERE pe.payment_type = 'engagement' ) AS engagement_unique_members - FROM paid_events pe + FROM payment_events pe ) 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 daffcb7..ec5891b 100644 --- a/src/reports/dashboard/dashboard-reports.sql.spec.ts +++ b/src/reports/dashboard/dashboard-reports.sql.spec.ts @@ -34,12 +34,16 @@ describe("Dashboard report SQL", () => { expect(sql).toContain("AS peak_month_signups"); }); - it("counts paid members once per canonical payment bucket and month", () => { + it("counts projected members once per canonical payment bucket and month", () => { const sql = sqlLoader.load("reports/dashboard/members-paid.sql"); - expect(sql).toContain("p.payment_status = 'PAID'"); + expect(sql).toContain("MAX(p.version) AS max_version"); + expect(sql).toContain("lpv.max_version = p.version"); + 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("w.category::text = 'TAAS_PAYMENT'"); expect(sql).toContain("w.category::text = 'ENGAGEMENT_PAYMENT'"); expect(sql).toContain("'TASK_REVIEW_PAYMENT'"); @@ -104,15 +108,26 @@ describe("Dashboard report SQL", () => { expect(sql).toContain("COALESCE(ma.amount, 0) AS amount"); }); - it("counts registration and submission activity independently", () => { + it("counts registrants and linked submitters by challenge completion cohort", () => { const sql = sqlLoader.load("reports/dashboard/challenge-participation.sql"); - expect(sql).toContain('FROM resources."Resource" r'); + expect(sql).toContain('FROM challenges."Challenge" c'); + expect(sql).toContain('JOIN challenges."ChallengeType" ct'); + expect(sql).toContain( + "ct.name IN ('Challenge', 'Marathon Match', 'First2Finish')", + ); + expect(sql).toContain('FROM challenges."ChallengePhase" cp'); + expect(sql).toContain('lp."actualEndDate" AS activity_at'); + expect(sql).toContain('ORDER BY cp."scheduledEndDate" DESC'); + expect(sql).toContain('JOIN resources."Resource" r'); expect(sql).toContain('JOIN resources."ResourceRole" rr'); expect(sql).toContain("= 'submitter'"); expect(sql).toContain("FROM reviews.submission s"); + expect(sql).toContain('s."challengeId" = re.challenge_id'); + expect(sql).toContain('s."memberId" = re.member_id'); expect(sql).toContain("s.status <> 'DELETED'"); - expect(sql).toContain('COALESCE(s."submittedDate", s."createdAt")'); + expect(sql).not.toContain('r."createdAt" AS activity_at'); + expect(sql).not.toContain('COALESCE(s."submittedDate", s."createdAt")'); expect(sql).toContain("COUNT(DISTINCT re.member_id)"); expect(sql).toContain("COUNT(DISTINCT se.member_id)"); expect(sql).toContain("LEAST(");