From 7252be81e06934966d30c63120fa693f6157da8e Mon Sep 17 00:00:00 2001 From: jmgasper Date: Tue, 11 Aug 2026 17:46:43 +1000 Subject: [PATCH] PM-5839: Include full end dates in payment reports What was broken Date-only endDate values were interpreted at midnight, excluding records later on the selected end date and making same-day ranges appear empty. Root cause Timestamp filters used an inclusive <= comparison against a date value cast to midnight. What was changed Use half-open date ranges ending at the start of the following calendar day for payment accrual, SFDC payments, and SFDC BA fee queries. Clarify full-day end-date semantics in API docs. Any added/updated tests Added SQL regression assertions for all four affected query variants, covering next-day exclusive upper bounds and removal of the old midnight cutoff. --- .../payment/member-payment-accrual.sql | 4 ++- sql/reports/sfdc/ba-fees-monthly.sql | 2 +- sql/reports/sfdc/ba-fees.sql | 2 +- sql/reports/sfdc/payments.sql | 2 +- .../payment/dto/member-payment-accrual.dto.ts | 4 +-- .../payment/payment-reports.service.spec.ts | 12 +++++++++ src/reports/sfdc/sfdc-reports.dto.ts | 10 ++++--- src/reports/sfdc/sfdc-reports.service.spec.ts | 27 +++++++++++++++++++ 8 files changed, 53 insertions(+), 10 deletions(-) diff --git a/sql/reports/payment/member-payment-accrual.sql b/sql/reports/payment/member-payment-accrual.sql index 10780f4..f3ec48d 100644 --- a/sql/reports/payment/member-payment-accrual.sql +++ b/sql/reports/payment/member-payment-accrual.sql @@ -50,7 +50,9 @@ recent_payments AS ( JOIN params pr ON TRUE WHERE w.type = 'PAYMENT' AND p.created_at >= pr.start_date - AND p.created_at <= pr.end_date + AND p.created_at < ( + DATE_TRUNC('day', pr.end_date) + INTERVAL '1 day' + ) ), categorized_payments AS ( SELECT diff --git a/sql/reports/sfdc/ba-fees-monthly.sql b/sql/reports/sfdc/ba-fees-monthly.sql index 8545bb1..7501ac9 100644 --- a/sql/reports/sfdc/ba-fees-monthly.sql +++ b/sql/reports/sfdc/ba-fees-monthly.sql @@ -21,7 +21,7 @@ WITH filtered_payments AS ( ON w.winning_id = p.winnings_id WHERE ($1::timestamptz IS NULL OR p.created_at >= $1::timestamptz) - AND ($2::timestamptz IS NULL OR p.created_at <= $2::timestamptz) + AND ($2::timestamptz IS NULL OR p.created_at < (DATE_TRUNC('day', $2::timestamptz) + INTERVAL '1 day')) AND ($3::text[] IS NULL OR p.billing_account = ANY($3::text[])) AND ($4::text[] IS NULL OR p.billing_account != ALL($4::text[])) ), diff --git a/sql/reports/sfdc/ba-fees.sql b/sql/reports/sfdc/ba-fees.sql index 8e8cbe2..5ad9f28 100644 --- a/sql/reports/sfdc/ba-fees.sql +++ b/sql/reports/sfdc/ba-fees.sql @@ -20,7 +20,7 @@ WITH filtered_payments AS ( ON w.winning_id = p.winnings_id WHERE ($1::timestamptz IS NULL OR p.created_at >= $1::timestamptz) - AND ($2::timestamptz IS NULL OR p.created_at <= $2::timestamptz) + AND ($2::timestamptz IS NULL OR p.created_at < (DATE_TRUNC('day', $2::timestamptz) + INTERVAL '1 day')) AND ($3::text[] IS NULL OR p.billing_account = ANY($3::text[])) AND ($4::text[] IS NULL OR p.billing_account != ALL($4::text[])) ), diff --git a/sql/reports/sfdc/payments.sql b/sql/reports/sfdc/payments.sql index a3a0987..bf463c9 100644 --- a/sql/reports/sfdc/payments.sql +++ b/sql/reports/sfdc/payments.sql @@ -74,7 +74,7 @@ WHERE )) AND ($6::text IS NULL OR challenge_name ILIKE '%' || $6 || '%') AND created_at >= COALESCE($7::timestamptz, (NOW() AT TIME ZONE 'UTC') - INTERVAL '45 days') - AND ($8::timestamptz IS NULL OR created_at <= $8::timestamptz) + AND ($8::timestamptz IS NULL OR created_at < (DATE_TRUNC('day', $8::timestamptz) + INTERVAL '1 day')) AND ($9::numeric IS NULL OR total_amount >= $9::numeric) AND ($10::numeric IS NULL OR total_amount <= $10::numeric) AND ($11::text[] IS NULL OR reported_challenge_status::text = ANY($11::text[])) diff --git a/src/reports/payment/dto/member-payment-accrual.dto.ts b/src/reports/payment/dto/member-payment-accrual.dto.ts index e42c412..e96736a 100644 --- a/src/reports/payment/dto/member-payment-accrual.dto.ts +++ b/src/reports/payment/dto/member-payment-accrual.dto.ts @@ -13,8 +13,8 @@ export class MemberPaymentAccrualQueryDto { @ApiPropertyOptional({ description: - "End date (inclusive) for filtering payment creation date in ISO 8601 format", - example: "2024-01-31T23:59:59.000Z", + "End date (inclusive through the full calendar day) for filtering payment creation date in ISO 8601 format", + example: "2024-01-31", }) @IsOptional() @IsDateString() diff --git a/src/reports/payment/payment-reports.service.spec.ts b/src/reports/payment/payment-reports.service.spec.ts index 978c934..b5a2e16 100644 --- a/src/reports/payment/payment-reports.service.spec.ts +++ b/src/reports/payment/payment-reports.service.spec.ts @@ -72,4 +72,16 @@ describe("PaymentReportsService", () => { ["Challenge Payment"], ]); }); + + it("uses the full inclusive end date in the payment SQL", () => { + const paymentSql = new SqlLoaderService().load( + "reports/payment/member-payment-accrual.sql", + ); + + expect(paymentSql).toContain("p.created_at >= pr.start_date"); + expect(paymentSql).toContain( + "DATE_TRUNC('day', pr.end_date) + INTERVAL '1 day'", + ); + expect(paymentSql).not.toContain("p.created_at <= pr.end_date"); + }); }); diff --git a/src/reports/sfdc/sfdc-reports.dto.ts b/src/reports/sfdc/sfdc-reports.dto.ts index 0ad9685..20a8ae8 100644 --- a/src/reports/sfdc/sfdc-reports.dto.ts +++ b/src/reports/sfdc/sfdc-reports.dto.ts @@ -250,8 +250,9 @@ export class PaymentsReportQueryDto { @ApiProperty({ required: false, - description: "End date for the report query in ISO 8601 format", - example: "2023-01-31T23:59:59.000Z", + description: + "End date (inclusive through the full calendar day) for the report query in ISO 8601 format", + example: "2023-01-31", }) @IsOptional() @IsDateString() @@ -862,8 +863,9 @@ export class BaFeesReportQueryDto { @ApiProperty({ required: false, - description: "End date for the report query in ISO 8601 format", - example: "2023-01-31T23:59:59.000Z", + description: + "End date (inclusive through the full calendar day) for the report query in ISO 8601 format", + example: "2023-01-31", }) @IsOptional() @IsDateString() diff --git a/src/reports/sfdc/sfdc-reports.service.spec.ts b/src/reports/sfdc/sfdc-reports.service.spec.ts index f54f947..f9d41ca 100644 --- a/src/reports/sfdc/sfdc-reports.service.spec.ts +++ b/src/reports/sfdc/sfdc-reports.service.spec.ts @@ -331,6 +331,18 @@ describe("SfdcReportsService - getPaymentsReport", () => { ); }); + it("uses the full inclusive end date in the payments SQL", () => { + const paymentsSql = readFileSync( + join(__dirname, "../../../sql/reports/sfdc/payments.sql"), + "utf8", + ); + + expect(paymentsSql).toContain( + "DATE_TRUNC('day', $8::timestamptz) + INTERVAL '1 day'", + ); + expect(paymentsSql).not.toContain("created_at <= $8::timestamptz"); + }); + it("returns mixed challenge, engagement, and unresolved challenge payments successfully", async () => { const result = await service.getPaymentsReport( mockPaymentQueryDto.billingAccount, @@ -1108,6 +1120,21 @@ describe("SfdcReportsService - getBaFeesReport", () => { ); }); + it.each(["ba-fees.sql", "ba-fees-monthly.sql"])( + "uses the full inclusive end date in %s", + (fileName) => { + const baFeesSql = readFileSync( + join(__dirname, `../../../sql/reports/sfdc/${fileName}`), + "utf8", + ); + + expect(baFeesSql).toContain( + "DATE_TRUNC('day', $2::timestamptz) + INTERVAL '1 day'", + ); + expect(baFeesSql).not.toContain("p.created_at <= $2::timestamptz"); + }, + ); + it("runs a basic query successfully", async () => { const result = await service.getBaFeesReport( mockBaFeesQueryDto.byBillingAccount,