diff --git a/.circleci/config.yml b/.circleci/config.yml index 10c4841..6a40302 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -69,6 +69,7 @@ workflows: - PM-4490 - PM-4491-fix - PM-3497_talent-search + - PM-4886 # Production builds are exectuted only on tagged commits to the # master branch. diff --git a/README.md b/README.md index 9f2673e..d53e594 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ ENGAGEMENTS_DB_URL="postgresql://user:password@localhost:5432/engagements" # The same report also reads member/profile/project data from the main # DATABASE_URL connection, including members.member, members.memberAddress, -# members.memberPhone, identity.country, and projects.projects. +# members.memberPhone, identity.country, lookups.Country, and projects.projects. # Old tc-payments database URL (used by member-tax CSV export script) OLD_PAYMENTS_DATABASE_URL="postgresql://user:password@localhost:5432/tc_payments?schema=public" diff --git a/TESTING.md b/TESTING.md index 39a0327..c6a2fab 100644 --- a/TESTING.md +++ b/TESTING.md @@ -60,6 +60,7 @@ it('passes all filters in correct order', async () => { await service.getPaymentsReport({ billingAccountIds: ['12345'], challengeIds: ['uuid1'], + engagementIds: ['engagement-uuid1'], handles: ['user1'], challengeName: 'Task', startDate: '2023-01-01', @@ -73,12 +74,13 @@ it('passes all filters in correct order', async () => { ['12345'], // include billing accounts undefined, // exclude billing accounts ['uuid1'], // challenge IDs - ['user1'], // handles - 'Task', // challenge name + ['engagement-uuid1'], // engagement IDs + ['user1'], // handles + 'Task', // challenge name '2023-01-01', // start date '2023-12-31', // end date - 100, // min amount - 1000, // max amount + 100, // min amount + 1000, // max amount ['COMPLETED'] // challenge status ]); }); diff --git a/sql/reports/sfdc/payments.sql b/sql/reports/sfdc/payments.sql index b854c89..a3a0987 100644 --- a/sql/reports/sfdc/payments.sql +++ b/sql/reports/sfdc/payments.sql @@ -1,44 +1,82 @@ +WITH resolved_payment_references AS ( + SELECT + p.payment_id, + p.created_at, + p.billing_account, + p.payment_status, + p.challenge_fee, + p.total_amount, + w.external_id, + w.winner_id, + w.category, + CASE + WHEN w.category IS DISTINCT FROM 'ENGAGEMENT_PAYMENT' THEN w.external_id + END AS challenge_filter_id, + CASE + WHEN w.category = 'ENGAGEMENT_PAYMENT' THEN ea."engagementId" + END AS engagement_filter_id, + CASE + WHEN w.category = 'ENGAGEMENT_PAYMENT' THEN e.title + ELSE c.name + END AS challenge_name, + c.status AS challenge_status, + CASE + WHEN w.category = 'ENGAGEMENT_PAYMENT' THEN 'COMPLETED' + ELSE c.status + END AS reported_challenge_status, + m.handle, + m."userId", + m."firstName", + m."lastName" + FROM finance.payment p + LEFT JOIN finance.winnings w + ON w.winning_id = p.winnings_id + LEFT JOIN challenges."Challenge" c + ON c.id = w.external_id + AND w.category IS DISTINCT FROM 'ENGAGEMENT_PAYMENT' + LEFT JOIN engagements."EngagementAssignment" ea + ON ea.id = w.external_id + AND w.category = 'ENGAGEMENT_PAYMENT' + LEFT JOIN engagements."Engagement" e + ON e.id = ea."engagementId" + LEFT JOIN members.member m + ON m."userId" = w.winner_id::bigint +) SELECT - p.payment_id as "paymentId", - p.created_at AT TIME ZONE 'America/New_York' as "paymentDate", - p.billing_account as "billingAccountId", - p.payment_status as "paymentStatus", - p.challenge_fee as "challengeFee", - p.total_amount as "paymentAmount", -- Looker cost_transaction.member_payments - w.external_id as "challengeId", - w.category, - (w.category = 'TASK_PAYMENT') AS "isTask", - c.name AS "challengeName", - c.status AS "challengeStatus", - m.handle AS "winnerHandle", - m."userId" as "winnerId", - m."firstName" as "winnerFirstName", - m."lastName" as "winnerLastName" -FROM finance.payment p -LEFT JOIN finance.winnings w - ON w.winning_id = p.winnings_id -LEFT JOIN challenges."Challenge" c - ON c.id = w.external_id -LEFT JOIN members.member m - ON m."userId" = w.winner_id::bigint + payment_id as "paymentId", + created_at AT TIME ZONE 'America/New_York' as "paymentDate", + billing_account as "billingAccountId", + payment_status as "paymentStatus", + challenge_fee as "challengeFee", + total_amount as "paymentAmount", -- Looker cost_transaction.member_payments + external_id as "challengeId", + category, + (category = 'TASK_PAYMENT') AS "isTask", + challenge_name AS "challengeName", + reported_challenge_status AS "challengeStatus", + handle AS "winnerHandle", + "userId" as "winnerId", + "firstName" as "winnerFirstName", + "lastName" as "winnerLastName" +FROM resolved_payment_references WHERE - ($1::text[] IS NULL OR p.billing_account = ANY($1::text[])) - AND ($2::text[] IS NULL OR p.billing_account <> ANY($2::text[])) - AND ($3::text[] IS NULL OR c.id = ANY($3::text[])) - AND ($4::text[] IS NULL OR w.winner_id::text IN ( + ($1::text[] IS NULL OR billing_account = ANY($1::text[])) + AND ($2::text[] IS NULL OR billing_account <> ANY($2::text[])) + AND ( + ($3::text[] IS NULL AND $4::text[] IS NULL) + OR ($3::text[] IS NOT NULL AND challenge_filter_id = ANY($3::text[])) + OR ($4::text[] IS NOT NULL AND engagement_filter_id = ANY($4::text[])) + ) + AND ($5::text[] IS NULL OR winner_id::text IN ( SELECT m2."userId"::text FROM members.member m2 - WHERE m2.handle = ANY($4::text[]) + WHERE m2.handle = ANY($5::text[]) )) - AND ($5::text IS NULL OR w.external_id IN ( - SELECT c2.id - FROM challenges."Challenge" c2 - WHERE c2.name ILIKE '%' || $5 || '%' - )) - AND p.created_at >= COALESCE($6::timestamptz, (NOW() AT TIME ZONE 'UTC') - INTERVAL '45 days') - AND ($7::timestamptz IS NULL OR p.created_at <= $7::timestamptz) - AND ($8::numeric IS NULL OR p.total_amount >= $8::numeric) - AND ($9::numeric IS NULL OR p.total_amount <= $9::numeric) - AND ($10::text[] IS NULL OR c.status::text = ANY($10::text[])) - AND ($11::text[] IS NULL OR p.payment_status::text = ANY($11::text[])) -ORDER BY p.created_at DESC + 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 ($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[])) + AND ($12::text[] IS NULL OR payment_status::text = ANY($12::text[])) +ORDER BY created_at DESC diff --git a/sql/reports/topcoder/engagement-data-members.sql b/sql/reports/topcoder/engagement-data-members.sql index 383e7a5..c057ec1 100644 --- a/sql/reports/topcoder/engagement-data-members.sql +++ b/sql/reports/topcoder/engagement-data-members.sql @@ -4,8 +4,22 @@ SELECT NULLIF(BTRIM(m."firstName"), '') AS first_name, NULLIF(BTRIM(m."lastName"), '') AS last_name, NULLIF(BTRIM(m.email), '') AS email, - COALESCE(NULLIF(BTRIM(c.country_name), ''), NULLIF(BTRIM(m.country), '')) - AS country, + COALESCE( + NULLIF(BTRIM(home_lookup_code.name), ''), + NULLIF(BTRIM(home_lookup_id.name), ''), + NULLIF(BTRIM(home_identity_alpha3.country_name), ''), + NULLIF(BTRIM(home_identity_code.country_name), ''), + NULLIF(BTRIM(home_identity_alpha2.country_name), '') + ) AS home_country, + COALESCE( + NULLIF(BTRIM(comp_lookup_code.name), ''), + NULLIF(BTRIM(comp_lookup_id.name), ''), + NULLIF(BTRIM(comp_identity_alpha3.country_name), ''), + NULLIF(BTRIM(comp_identity_code.country_name), ''), + NULLIF(BTRIM(comp_identity_alpha2.country_name), '') + ) AS competition_country, + NULLIF(BTRIM(m."homeCountryCode"), '') AS home_country_code, + NULLIF(BTRIM(m."competitionCountryCode"), '') AS competition_country_code, preferred_address.street_addr_1, preferred_address.street_addr_2, preferred_address.city, @@ -13,8 +27,26 @@ SELECT preferred_address.zip, preferred_phone.phone_number FROM members.member m -LEFT JOIN identity.country c - ON c.iso_alpha3_code = m."competitionCountryCode" +LEFT JOIN lookups."Country" AS home_lookup_code + ON UPPER(home_lookup_code."countryCode") = UPPER(BTRIM(m."homeCountryCode")) +LEFT JOIN lookups."Country" AS home_lookup_id + ON UPPER(home_lookup_id.id) = UPPER(BTRIM(m."homeCountryCode")) +LEFT JOIN identity.country AS home_identity_alpha3 + ON UPPER(home_identity_alpha3.iso_alpha3_code) = UPPER(BTRIM(m."homeCountryCode")) +LEFT JOIN identity.country AS home_identity_code + ON UPPER(home_identity_code.country_code) = UPPER(BTRIM(m."homeCountryCode")) +LEFT JOIN identity.country AS home_identity_alpha2 + ON UPPER(home_identity_alpha2.iso_alpha2_code) = UPPER(BTRIM(m."homeCountryCode")) +LEFT JOIN lookups."Country" AS comp_lookup_code + ON UPPER(comp_lookup_code."countryCode") = UPPER(BTRIM(m."competitionCountryCode")) +LEFT JOIN lookups."Country" AS comp_lookup_id + ON UPPER(comp_lookup_id.id) = UPPER(BTRIM(m."competitionCountryCode")) +LEFT JOIN identity.country AS comp_identity_alpha3 + ON UPPER(comp_identity_alpha3.iso_alpha3_code) = UPPER(BTRIM(m."competitionCountryCode")) +LEFT JOIN identity.country AS comp_identity_code + ON UPPER(comp_identity_code.country_code) = UPPER(BTRIM(m."competitionCountryCode")) +LEFT JOIN identity.country AS comp_identity_alpha2 + ON UPPER(comp_identity_alpha2.iso_alpha2_code) = UPPER(BTRIM(m."competitionCountryCode")) LEFT JOIN LATERAL ( SELECT NULLIF(BTRIM(a."streetAddr1"), '') AS street_addr_1, diff --git a/src/reports/member/dto/member-search.dto.ts b/src/reports/member/dto/member-search.dto.ts index e0f36fa..d84e299 100644 --- a/src/reports/member/dto/member-search.dto.ts +++ b/src/reports/member/dto/member-search.dto.ts @@ -77,12 +77,14 @@ export class MemberSearchBodyDto { @ApiPropertyOptional({ description: - "Filter by country name or code as stored in the member location (case-insensitive).", - example: "Australia", + "Filter by multiple country names or country codes (case-insensitive).", + type: [String], + example: ["US", "Australia"], }) @IsOptional() - @IsString() - country?: string; + @IsArray() + @IsString({ each: true }) + countries?: string[]; @ApiPropertyOptional({ description: diff --git a/src/reports/member/member-search.controller.spec.ts b/src/reports/member/member-search.controller.spec.ts index 1a79ef4..0c1b3f0 100644 --- a/src/reports/member/member-search.controller.spec.ts +++ b/src/reports/member/member-search.controller.spec.ts @@ -33,7 +33,7 @@ describe("MemberSearchController", () => { it("delegates search requests to the service and returns response", async () => { const body = { - country: "Australia", + countries: ["Australia"], sortBy: "handle" as const, sortOrder: "asc" as const, page: 2, diff --git a/src/reports/member/member-search.service.spec.ts b/src/reports/member/member-search.service.spec.ts index faf35c6..d83ae52 100644 --- a/src/reports/member/member-search.service.spec.ts +++ b/src/reports/member/member-search.service.spec.ts @@ -92,7 +92,7 @@ describe("MemberSearchService", () => { .mockResolvedValueOnce([{ total: 0 }]); await service.search({ - country: "us", + countries: ["us"], page: 2, limit: 5, sortBy: "handle", @@ -106,8 +106,43 @@ describe("MemberSearchService", () => { expect(dataSql).toContain( 'ORDER BY m.handle ASC, "matchIndex" DESC NULLS LAST', ); - expect(dataParams).toEqual(["us", 5, 5]); - expect(countParams).toEqual(["us"]); + expect(dataSql).toContain('LOWER(m."homeCountryCode") = ANY($1::text[])'); + expect(dataParams).toEqual([["us"], 5, 5]); + expect(countParams).toEqual([["us"]]); + }); + + it("treats empty countries as no country filter", async () => { + mockDbService.query + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ total: 0 }]); + + await service.search({ countries: [] }); + + const dataSql = mockDbService.query.mock.calls[0][0] as string; + const countParams = mockDbService.query.mock.calls[1][1] as unknown[]; + + expect(dataSql).not.toContain('LOWER(m."homeCountryCode") = ANY('); + expect(countParams).toEqual([]); + }); + + it("does not apply boolean filters when explicitly false", async () => { + mockDbService.query + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ total: 0 }]); + + await service.search({ + openToWork: false, + recentlyActive: false, + verifiedProfile: false, + }); + + const dataSql = mockDbService.query.mock.calls[0][0] as string; + + expect(dataSql).not.toContain('m."availableForGigs" = true'); + expect(dataSql).not.toContain( + 'EXISTS (SELECT 1 FROM recently_active ra WHERE ra.user_id = m."userId")', + ); + expect(dataSql).not.toContain("COALESCE(m.verified, false) = true"); }); it("deduplicates skills and keeps last wins value when building skill query", async () => { diff --git a/src/reports/member/member-search.service.ts b/src/reports/member/member-search.service.ts index 2470f8a..1d9e38c 100644 --- a/src/reports/member/member-search.service.ts +++ b/src/reports/member/member-search.service.ts @@ -1,4 +1,5 @@ import { Injectable, NotFoundException } from "@nestjs/common"; +import { alpha3ToCountryName } from "../../common/country.util"; import { DbService } from "../../db/db.service"; import { MemberSearchBodyDto } from "./dto/member-search.dto"; import { @@ -20,6 +21,26 @@ type RawMemberRow = { matchIndex: number; }; +function formatLocation(location: string): string { + const normalizedLocation = String(location || "").trim(); + if (!normalizedLocation) { + return ""; + } + + const parts = normalizedLocation.split(/\s+/); + const lastPart = parts[parts.length - 1]; + const mappedCountryName = alpha3ToCountryName(lastPart); + if (!mappedCountryName) { + return normalizedLocation; + } + + if (parts.length === 1) { + return mappedCountryName; + } + + return `${parts.slice(0, -1).join(" ")}, ${mappedCountryName}`; +} + @Injectable() export class MemberSearchService { constructor(private readonly db: DbService) {} @@ -31,7 +52,7 @@ export class MemberSearchService { openToWork, recentlyActive, verifiedProfile, - country, + countries, sortBy = "matchIndex", sortOrder = "desc", page = 1, @@ -185,26 +206,40 @@ member_address AS ( // ------------------------------------------------- dynamic WHERE const where: string[] = [`m.status = 'ACTIVE'`]; - if (typeof openToWork === "boolean") { + if (openToWork === true) { where.push(`m."availableForGigs" = true`); } - if (typeof recentlyActive === "boolean") { + if (recentlyActive === true) { where.push( `EXISTS (SELECT 1 FROM recently_active ra WHERE ra.user_id = m."userId")`, ); } - if (typeof verifiedProfile === "boolean") { + if (verifiedProfile === true) { where.push( `(COALESCE(m.verified, false) = true OR EXISTS (SELECT 1 FROM verified_via_trolley vt WHERE vt.user_id = m."userId"))`, ); } - if (country) { - const pCountry = p(country); + const normalizedCountries = Array.isArray(countries) + ? [ + ...new Set( + countries + .map((value) => String(value).trim().toLowerCase()) + .filter(Boolean), + ), + ] + : []; + + if (normalizedCountries.length > 0) { + const pCountries = p(normalizedCountries); where.push( - `(LOWER(m."homeCountryCode") = LOWER(${pCountry}) OR LOWER(m."competitionCountryCode") = LOWER(${pCountry}) OR LOWER(m.country) = LOWER(${pCountry}))`, + `( + LOWER(m."homeCountryCode") = ANY(${pCountries}::text[]) + OR LOWER(m."competitionCountryCode") = ANY(${pCountries}::text[]) + OR LOWER(m.country) = ANY(${pCountries}::text[]) + )`, ); } @@ -274,7 +309,7 @@ WHERE ${whereClause}`; isRecentlyActive: row.isRecentlyActive ?? false, isVerified: row.isVerified ?? false, openToWork: row.openToWork ?? false, - location: row.location || "", + location: formatLocation(row.location), matchedSkills: row.matchedSkills ?? [], matchIndex: row.matchIndex ?? 0, })); diff --git a/src/reports/report-directory.data.ts b/src/reports/report-directory.data.ts index bc12790..27a7e99 100644 --- a/src/reports/report-directory.data.ts +++ b/src/reports/report-directory.data.ts @@ -225,7 +225,14 @@ const challengeNameParam: ReportParameter = { const challengeIdsParam: ReportParameter = { name: "challengeIds", type: "string[]", - description: "List of challenge IDs", + description: "List of challenge IDs for challenge-backed payments", + location: "query", +}; + +const engagementIdsParam: ReportParameter = { + name: "engagementIds", + type: "string[]", + description: "List of engagement IDs for engagement-backed payments", location: "query", }; @@ -270,6 +277,7 @@ const paymentsFilters = [ billingAccountIdsParam, challengeNameParam, challengeIdsParam, + engagementIdsParam, paymentsStartDateParam, paymentsEndDateParam, handlesParam, diff --git a/src/reports/sfdc/sfdc-reports.controller.spec.ts b/src/reports/sfdc/sfdc-reports.controller.spec.ts index 54e3935..2ae4c69 100644 --- a/src/reports/sfdc/sfdc-reports.controller.spec.ts +++ b/src/reports/sfdc/sfdc-reports.controller.spec.ts @@ -168,6 +168,7 @@ describe("SfdcReportsController", () => { const dto = plainToInstance(PaymentsReportQueryDto, { billingAccountIds: "80001012,80002012", challengeIds: "e74c3e37-73c9-474e-a838-a38dd4738906", + engagementIds: "3cf4ec0b-47e5-4d96-b4c3-ef6af5b0f954", handles: "user_01", challengeStatus: "COMPLETED", status: "ON_HOLD", @@ -179,6 +180,7 @@ describe("SfdcReportsController", () => { expect.objectContaining({ billingAccountIds: ["80001012", "80002012"], challengeIds: ["e74c3e37-73c9-474e-a838-a38dd4738906"], + engagementIds: ["3cf4ec0b-47e5-4d96-b4c3-ef6af5b0f954"], handles: ["user_01"], challengeStatus: ["COMPLETED"], status: ["ON_HOLD"], diff --git a/src/reports/sfdc/sfdc-reports.controller.ts b/src/reports/sfdc/sfdc-reports.controller.ts index 4884f88..d922967 100644 --- a/src/reports/sfdc/sfdc-reports.controller.ts +++ b/src/reports/sfdc/sfdc-reports.controller.ts @@ -60,7 +60,8 @@ export class SfdcReportsController { @ApiBearerAuth() @ApiOperation({ summary: "SFDC Payments report", - description: "", + description: + "Retrieve SFDC payments with billing account, challenge, engagement, handle, payment status, and challenge status filters.", }) @ApiResponse({ status: 200, diff --git a/src/reports/sfdc/sfdc-reports.dto.spec.ts b/src/reports/sfdc/sfdc-reports.dto.spec.ts index 43be0da..8274e30 100644 --- a/src/reports/sfdc/sfdc-reports.dto.spec.ts +++ b/src/reports/sfdc/sfdc-reports.dto.spec.ts @@ -232,6 +232,7 @@ describe("PaymentsReportQueryDto validation", () => { const { errors } = await validatePaymentDto({ billingAccountIds: ["80001012", "!90000000"], challengeIds: ["e74c3e37-73c9-474e-a838-a38dd4738906"], + engagementIds: ["3cf4ec0b-47e5-4d96-b4c3-ef6af5b0f954"], handles: ["user_01", "user_02"], challengeName: "Task Payment for member", startDate: "2023-01-01T00:00:00.000Z", @@ -297,6 +298,28 @@ describe("PaymentsReportQueryDto validation", () => { expect(dto.challengeIds).toEqual(["uuid1"]); }); + it("accepts valid engagementIds", async () => { + const { errors } = await validatePaymentDto({ + engagementIds: ["uuid1", "uuid2"], + }); + expect(errors).toHaveLength(0); + }); + + it("drops empty engagementIds entries during transform", async () => { + const { dto, errors } = await validatePaymentDto({ engagementIds: [""] }); + expect(errors).toHaveLength(0); + expect(dto.engagementIds).toEqual([]); + }); + + it("transforms single engagementId into array", async () => { + const { dto, errors } = await validatePaymentDto({ + // @ts-expect-error intentional single value for transform check + engagementIds: "uuid1", + }); + expect(errors).toHaveLength(0); + expect(dto.engagementIds).toEqual(["uuid1"]); + }); + it("accepts valid handles", async () => { const { errors } = await validatePaymentDto({ handles: ["user1", "user2"], diff --git a/src/reports/sfdc/sfdc-reports.dto.ts b/src/reports/sfdc/sfdc-reports.dto.ts index 5c44c44..3f1d908 100644 --- a/src/reports/sfdc/sfdc-reports.dto.ts +++ b/src/reports/sfdc/sfdc-reports.dto.ts @@ -206,8 +206,9 @@ export class PaymentsReportQueryDto { @ApiProperty({ required: false, - description: "Challenge name to search for", - example: "Task Payment for member", + description: + "Display name search across challenge names and engagement titles.", + example: "Customer Support Engagement", }) @IsOptional() @IsString() @@ -216,7 +217,8 @@ export class PaymentsReportQueryDto { @ApiProperty({ required: false, - description: "List of challenge IDs", + description: + "List of challenge IDs for challenge-backed payments only. Use engagementIds to filter ENGAGEMENT_PAYMENT rows by engagement.", example: ["e74c3e37-73c9-474e-a838-a38dd4738906"], }) @IsOptional() @@ -225,6 +227,18 @@ export class PaymentsReportQueryDto { @Transform(transformArray) challengeIds?: string[]; + @ApiProperty({ + required: false, + description: + 'List of engagement IDs for ENGAGEMENT_PAYMENT rows only. This matches engagements."EngagementAssignment"."engagementId", not finance.winnings.external_id.', + example: ["3cf4ec0b-47e5-4d96-b4c3-ef6af5b0f954"], + }) + @IsOptional() + @IsString({ each: true }) + @IsNotEmpty({ each: true }) + @Transform(transformArray) + engagementIds?: string[]; + @ApiProperty({ required: false, description: "Start date for the report query in ISO 8601 format", @@ -276,7 +290,8 @@ export class PaymentsReportQueryDto { @ApiProperty({ required: false, - description: "List of challenge statuses to filter payments", + description: + "List of challenge statuses for challenge-backed payments only. Engagement payment rows have no challenge status and are excluded when this filter is supplied.", example: ["COMPLETED", "ACTIVE"], }) @IsOptional() @@ -300,7 +315,17 @@ export class PaymentsReportQueryDto { export class PaymentsReportResponse { billingAccountId: string; - challengeName: string; + @ApiProperty({ + description: + 'Resolved display name from challenges."Challenge".name for challenge payments or engagements."Engagement".title for engagement payments. Null when the external reference cannot be resolved.', + nullable: true, + type: String, + }) + challengeName: string | null; + @ApiProperty({ + description: + "Reference from finance.winnings.external_id. This is a challenge ID for challenge payments and an engagement assignment ID for ENGAGEMENT_PAYMENT rows.", + }) challengeId: string; @ApiProperty({ description: "Winnings category from finance.winnings.category", @@ -317,9 +342,12 @@ export class PaymentsReportResponse { challengeFee: number; paymentAmount: number; @ApiProperty({ - description: "Challenge status from challenges.Challenge.status", + description: + "Normalized challenge status label for challenge-backed payments. ENGAGEMENT_PAYMENT rows are always reported as Completed; challenge-backed rows whose external reference cannot be resolved remain null.", + nullable: true, + type: String, }) - challengeStatus: string; + challengeStatus: string | null; } export class TaasJobsReportQueryDto { diff --git a/src/reports/sfdc/sfdc-reports.service.spec.ts b/src/reports/sfdc/sfdc-reports.service.spec.ts index c40f856..f54f947 100644 --- a/src/reports/sfdc/sfdc-reports.service.spec.ts +++ b/src/reports/sfdc/sfdc-reports.service.spec.ts @@ -1,5 +1,7 @@ import { BadRequestException } from "@nestjs/common"; import { Test, TestingModule } from "@nestjs/testing"; +import { readFileSync } from "fs"; +import { join } from "path"; import { SqlLoaderService } from "src/common/sql-loader.service"; import { DbService } from "../../db/db.service"; import { @@ -300,7 +302,36 @@ describe("SfdcReportsService - getPaymentsReport", () => { ); }); - it("runs a basic query successfully", async () => { + it("uses dedicated challenge and engagement filter IDs in the payments SQL", () => { + const paymentsSql = readFileSync( + join(__dirname, "../../../sql/reports/sfdc/payments.sql"), + "utf8", + ); + + expect(paymentsSql).toContain( + "WHEN w.category IS DISTINCT FROM 'ENGAGEMENT_PAYMENT' THEN w.external_id", + ); + expect(paymentsSql).toContain( + "WHEN w.category = 'ENGAGEMENT_PAYMENT' THEN ea.\"engagementId\"", + ); + expect(paymentsSql).toContain( + "WHEN w.category = 'ENGAGEMENT_PAYMENT' THEN 'COMPLETED'", + ); + expect(paymentsSql).toContain( + "reported_challenge_status::text = ANY($11::text[])", + ); + expect(paymentsSql).toContain( + "($3::text[] IS NULL AND $4::text[] IS NULL)", + ); + expect(paymentsSql).toContain( + "($3::text[] IS NOT NULL AND challenge_filter_id = ANY($3::text[]))", + ); + expect(paymentsSql).toContain( + "($4::text[] IS NOT NULL AND engagement_filter_id = ANY($4::text[]))", + ); + }); + + it("returns mixed challenge, engagement, and unresolved challenge payments successfully", async () => { const result = await service.getPaymentsReport( mockPaymentQueryDto.billingAccount, ); @@ -317,8 +348,27 @@ describe("SfdcReportsService - getPaymentsReport", () => { undefined, undefined, undefined, + undefined, ]); expect(result).toEqual(normalizedPaymentData); + expect(result).toEqual([ + expect.objectContaining({ + category: "CHALLENGE_PAYMENT", + challengeName: "Sample Challenge 1", + challengeStatus: "Completed", + }), + expect.objectContaining({ + category: "ENGAGEMENT_PAYMENT", + challengeName: "Customer Support Engagement", + challengeStatus: "Completed", + }), + expect.objectContaining({ + category: "CHALLENGE_PAYMENT", + challengeId: "6bc7a37d-37ad-4c52-a2f0-71fa2c84e6e3", + challengeName: null, + challengeStatus: null, + }), + ]); }); it("splits include/exclude billing account filters", async () => { @@ -338,6 +388,7 @@ describe("SfdcReportsService - getPaymentsReport", () => { undefined, undefined, undefined, + undefined, ]); }); @@ -348,8 +399,9 @@ describe("SfdcReportsService - getPaymentsReport", () => { ["80001012"], ["90000000"], ["e74c3e37-73c9-474e-a838-a38dd4738906"], + ["3cf4ec0b-47e5-4d96-b4c3-ef6af5b0f954"], ["user_01", "user_02"], - "Task Payment for member", + "Customer Support Engagement", "2023-01-01T00:00:00.000Z", "2023-03-01T00:00:00.000Z", 100, @@ -359,6 +411,25 @@ describe("SfdcReportsService - getPaymentsReport", () => { ]); }); + it("passes engagement-backed payment filters to the engagementIds parameter", async () => { + await service.getPaymentsReport(mockPaymentQueryDto.engagement); + + expect(mockDbService.query).toHaveBeenCalledWith(mockSqlQuery, [ + undefined, + undefined, + undefined, + ["3cf4ec0b-47e5-4d96-b4c3-ef6af5b0f954"], + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + ]); + }); + it("handles challengeStatus filter for cancel payment checks", async () => { await service.getPaymentsReport(mockPaymentQueryDto.challengeStatus); @@ -372,17 +443,20 @@ describe("SfdcReportsService - getPaymentsReport", () => { undefined, undefined, undefined, + undefined, ["COMPLETED"], undefined, ]); }); - it("handles payment status filter", async () => { - await service.getPaymentsReport(mockPaymentQueryDto.paymentStatus); + it("passes orphaned challenge-backed external IDs to the challengeIds filter", async () => { + await service.getPaymentsReport(mockPaymentQueryDto.orphanedChallenge); expect(mockDbService.query).toHaveBeenCalledWith(mockSqlQuery, [ undefined, undefined, + ["6bc7a37d-37ad-4c52-a2f0-71fa2c84e6e3"], + undefined, undefined, undefined, undefined, @@ -391,12 +465,11 @@ describe("SfdcReportsService - getPaymentsReport", () => { undefined, undefined, undefined, - ["ON_HOLD"], ]); }); - it("handles null challengeStatus for cancellable payments", async () => { - await service.getPaymentsReport(mockPaymentQueryDto.nullChallengeStatus); + it("handles payment status filter", async () => { + await service.getPaymentsReport(mockPaymentQueryDto.paymentStatus); expect(mockDbService.query).toHaveBeenCalledWith(mockSqlQuery, [ undefined, @@ -408,8 +481,9 @@ describe("SfdcReportsService - getPaymentsReport", () => { undefined, undefined, undefined, - [null as unknown as string], undefined, + undefined, + ["ON_HOLD"], ]); }); @@ -450,6 +524,7 @@ describe("SfdcReportsService - getPaymentsReport", () => { undefined, undefined, undefined, + undefined, ]); }); @@ -468,6 +543,7 @@ describe("SfdcReportsService - getPaymentsReport", () => { undefined, undefined, undefined, + undefined, ]); }); }); diff --git a/src/reports/sfdc/sfdc-reports.service.ts b/src/reports/sfdc/sfdc-reports.service.ts index 59cd80b..51e2a5d 100644 --- a/src/reports/sfdc/sfdc-reports.service.ts +++ b/src/reports/sfdc/sfdc-reports.service.ts @@ -115,6 +115,7 @@ export class SfdcReportsService { billingAccountIds.length ? billingAccountIds : undefined, excludeBillingAccountIds.length ? excludeBillingAccountIds : undefined, filters.challengeIds, + filters.engagementIds, filters.handles, filters.challengeName, filters.startDate, @@ -129,7 +130,10 @@ export class SfdcReportsService { return payments.map((payment) => ({ ...payment, - challengeStatus: normalizeChallengeStatus(payment.challengeStatus), + challengeStatus: + payment.category === "ENGAGEMENT_PAYMENT" + ? "Completed" + : normalizeChallengeStatus(payment.challengeStatus), })); } diff --git a/src/reports/sfdc/test-helpers/mock-data.ts b/src/reports/sfdc/test-helpers/mock-data.ts index 1875c7b..6cf3aeb 100644 --- a/src/reports/sfdc/test-helpers/mock-data.ts +++ b/src/reports/sfdc/test-helpers/mock-data.ts @@ -83,7 +83,7 @@ export const mockPaymentData: PaymentsReportResponse[] = [ challengeFee: 150.75, paymentAmount: 500, challengeId: "e74c3e37-73c9-474e-a838-a38dd4738906", - category: "Challenge Prizes", + category: "CHALLENGE_PAYMENT", isTask: false, challengeName: "Sample Challenge 1", challengeStatus: "COMPLETED", @@ -99,11 +99,11 @@ export const mockPaymentData: PaymentsReportResponse[] = [ paymentStatus: "Owed", challengeFee: 90.25, paymentAmount: 350, - challengeId: "8d4a1ce9-6a58-4bf2-8c3b-c57a8b3a3f92", - category: "Task Payments", - isTask: true, - challengeName: "Task Payment for member", - challengeStatus: "ACTIVE", + challengeId: "915739d5-d6c8-4699-9e58-bbb2118f8c4e", + category: "ENGAGEMENT_PAYMENT", + isTask: false, + challengeName: "Customer Support Engagement", + challengeStatus: null, winnerHandle: "user_02", winnerId: "654321", winnerFirstName: "Jane", @@ -117,11 +117,11 @@ export const mockPaymentData: PaymentsReportResponse[] = [ challengeFee: 120, paymentAmount: 425.5, challengeId: "6bc7a37d-37ad-4c52-a2f0-71fa2c84e6e3", - category: "Marathon Match", + category: "CHALLENGE_PAYMENT", isTask: false, - challengeName: "Algo Payment", - challengeStatus: null as unknown as string, - winnerHandle: "user_cancel", + challengeName: null, + challengeStatus: null, + winnerHandle: "user_orphaned_challenge", winnerId: "789012", winnerFirstName: "Alex", winnerLastName: "Johnson", @@ -136,11 +136,14 @@ export const mockPaymentQueryDto: Record = { withExclusions: { billingAccountIds: ["80001012", "!90000000"], }, + engagement: { + engagementIds: ["3cf4ec0b-47e5-4d96-b4c3-ef6af5b0f954"], + }, challengeStatus: { challengeStatus: ["COMPLETED"], }, - nullChallengeStatus: { - challengeStatus: [null as unknown as string], + orphanedChallenge: { + challengeIds: ["6bc7a37d-37ad-4c52-a2f0-71fa2c84e6e3"], }, paymentStatus: { status: ["ON_HOLD"], @@ -148,8 +151,9 @@ export const mockPaymentQueryDto: Record = { full: { billingAccountIds: ["80001012", "!90000000"], challengeIds: ["e74c3e37-73c9-474e-a838-a38dd4738906"], + engagementIds: ["3cf4ec0b-47e5-4d96-b4c3-ef6af5b0f954"], handles: ["user_01", "user_02"], - challengeName: "Task Payment for member", + challengeName: "Customer Support Engagement", startDate: "2023-01-01T00:00:00.000Z", endDate: "2023-03-01T00:00:00.000Z", minPaymentAmount: 100, @@ -168,7 +172,10 @@ export const normalizedChallengeData = mockChallengeData.map((challenge) => ({ export const normalizedPaymentData = mockPaymentData.map((payment) => ({ ...payment, - challengeStatus: normalizeChallengeStatus(payment.challengeStatus) as string, + challengeStatus: + payment.category === "ENGAGEMENT_PAYMENT" + ? "Completed" + : normalizeChallengeStatus(payment.challengeStatus), })); export const mockBaFeesData: BaFeesReportResponse[] = [ diff --git a/src/reports/topcoder/topcoder-reports.service.spec.ts b/src/reports/topcoder/topcoder-reports.service.spec.ts index b5a871c..442ee44 100644 --- a/src/reports/topcoder/topcoder-reports.service.spec.ts +++ b/src/reports/topcoder/topcoder-reports.service.spec.ts @@ -21,7 +21,10 @@ describe("TopcoderReportsService", () => { first_name: "Ada", last_name: "Lovelace", email: "ada@example.com", - country: "United States", + home_country: null, + competition_country: null, + home_country_code: "JPN", + competition_country_code: null, street_addr_1: "1 Main St", street_addr_2: null, city: "New York", @@ -35,7 +38,10 @@ describe("TopcoderReportsService", () => { first_name: null, last_name: null, email: null, - country: "Canada", + home_country: null, + competition_country: "Sri Lanka", + home_country_code: null, + competition_country_code: "LKA", street_addr_1: null, street_addr_2: null, city: null, @@ -114,13 +120,13 @@ describe("TopcoderReportsService", () => { jest.clearAllMocks(); }); - it("builds engagement data rows with DB-backed enrichment, fallbacks, and project names", async () => { + it("builds engagement data rows with profile-style country resolution, fallbacks, and project names", async () => { await expect(service.getEngagementData()).resolves.toEqual([ { handle: "assigned_user", firstName: "Ada", lastName: "Lovelace", - country: "United States", + country: "Japan", emailId: "ada@example.com", phoneNumber: "+1 555 0101", address: "1 Main St, New York, NY, 10001", @@ -131,7 +137,7 @@ describe("TopcoderReportsService", () => { handle: "applicant_user", firstName: "Grace", lastName: "Hopper", - country: "Canada", + country: "Sri Lanka", emailId: "applicant@example.com", phoneNumber: "222-222-2222", address: "Applicant Address", diff --git a/src/reports/topcoder/topcoder-reports.service.ts b/src/reports/topcoder/topcoder-reports.service.ts index 81beb45..eb884db 100644 --- a/src/reports/topcoder/topcoder-reports.service.ts +++ b/src/reports/topcoder/topcoder-reports.service.ts @@ -134,7 +134,10 @@ type EngagementMemberRow = { first_name: string | null; last_name: string | null; email: string | null; - country: string | null; + home_country: string | null; + competition_country: string | null; + home_country_code: string | null; + competition_country_code: string | null; street_addr_1: string | null; street_addr_2: string | null; city: string | null; @@ -645,7 +648,9 @@ export class TopcoderReportsService implements OnModuleDestroy { * * The base member list comes from the engagements database, while member * profile/contact fields and project names are resolved directly from the - * main reports database so the export stays DB-only. + * main reports database so the export stays DB-only. Country resolution + * follows the same home-country-first profile fields used by the profile UI + * and avoids the stale legacy members.member.country fallback. * * @returns One row per member with the engagement experience summary fields. * @throws Error when the engagements database URL is not configured. @@ -701,7 +706,7 @@ export class TopcoderReportsService implements OnModuleDestroy { this.toOptionalString(member?.first_name) ?? parsedName.firstName, lastName: this.toOptionalString(member?.last_name) ?? parsedName.lastName, - country: this.toOptionalString(member?.country), + country: this.resolveEngagementMemberCountry(member), emailId: this.toOptionalString(member?.email) ?? row.application_email ?? null, phoneNumber: @@ -944,6 +949,24 @@ export class TopcoderReportsService implements OnModuleDestroy { return membersById; } + /** + * Resolves the report country using the same home-country-first fields the + * profile UI uses for member location display. + * + * @param member DB-backed member enrichment row for the engagement report. + * @returns Resolved country name or null when the profile has no known country. + */ + private resolveEngagementMemberCountry( + member?: EngagementMemberRow, + ): string | null { + return ( + this.toOptionalString(member?.home_country) ?? + alpha3ToCountryName(member?.home_country_code) ?? + this.toOptionalString(member?.competition_country) ?? + alpha3ToCountryName(member?.competition_country_code) + ); + } + /** * Resolves project names for the assigned project ids included in the report. * diff --git a/src/reports/topcoder/topcoder-reports.sql.spec.ts b/src/reports/topcoder/topcoder-reports.sql.spec.ts new file mode 100644 index 0000000..1c7df50 --- /dev/null +++ b/src/reports/topcoder/topcoder-reports.sql.spec.ts @@ -0,0 +1,17 @@ +import { SqlLoaderService } from "src/common/sql-loader.service"; + +describe("Topcoder report SQL", () => { + const sqlLoader = new SqlLoaderService(); + + it("resolves engagement data countries from profile location codes instead of the stale legacy field", () => { + const sql = sqlLoader.load("reports/topcoder/engagement-data-members.sql"); + + expect(sql).toMatch( + /COALESCE\(\s*NULLIF\(BTRIM\(home_lookup_code\.name\), ''\),\s*NULLIF\(BTRIM\(home_lookup_id\.name\), ''\),\s*NULLIF\(BTRIM\(home_identity_alpha3\.country_name\), ''\),\s*NULLIF\(BTRIM\(home_identity_code\.country_name\), ''\),\s*NULLIF\(BTRIM\(home_identity_alpha2\.country_name\), ''\)\s*\)\s+AS home_country/, + ); + expect(sql).toMatch( + /COALESCE\(\s*NULLIF\(BTRIM\(comp_lookup_code\.name\), ''\),\s*NULLIF\(BTRIM\(comp_lookup_id\.name\), ''\),\s*NULLIF\(BTRIM\(comp_identity_alpha3\.country_name\), ''\),\s*NULLIF\(BTRIM\(comp_identity_code\.country_name\), ''\),\s*NULLIF\(BTRIM\(comp_identity_alpha2\.country_name\), ''\)\s*\)\s+AS competition_country/, + ); + expect(sql).not.toMatch(/NULLIF\(BTRIM\(m\.country\), ''\)/); + }); +});