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", );