diff --git a/README.md b/README.md index 9a8c2b7..ddc2f8b 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ All reports will return JSON data with the expected fields for the individual re Currently, an M2M token is required to pull any report, and each report has its own scope associated with it that must be applied to the M2M token client ID +The report directory (list of endpoints and parameters) is available at `GET /v6/reports/directory` and uses the same authorization rules as other endpoints. The service accepts bearer tokens from the standard `Authorization` header, and also from proxies that forward the token in `X-Authorization`/`X-Forwarded-Authorization`. + ## Layout Each report will be a separate SQL query, potentially with a few parameters (like a start and an end date, for example). The individual SQL queries can be found in the `sql` folder and should be able to be run against the `topcoder-services` RDS database in dev or prod, with minimal changes to replace the parameters. diff --git a/sql/reports/topcoder/30-day-payments.sql b/sql/reports/topcoder/30-day-payments.sql deleted file mode 100644 index 36b5852..0000000 --- a/sql/reports/topcoder/30-day-payments.sql +++ /dev/null @@ -1,86 +0,0 @@ -WITH latest_payment AS ( - SELECT - p.winnings_id, - MAX(p.version) AS max_version - FROM finance.payment p - GROUP BY p.winnings_id -), -recent_payments AS ( - SELECT - w.winning_id, - w.winner_id, - w.category, - w.external_id AS challenge_id, - w.created_at AS winning_created_at, - p.payment_id, - p.payment_status, - p.installment_number, - p.billing_account, - p.total_amount, - p.gross_amount, - p.challenge_fee, - p.challenge_markup, - p.date_paid, - p.created_at AS payment_created_at - FROM finance.winnings w - JOIN finance.payment p - ON p.winnings_id = w.winning_id - JOIN latest_payment lp - ON lp.winnings_id = p.winnings_id - AND lp.max_version = p.version - WHERE w.type = 'PAYMENT' - AND p.installment_number = 1 - AND p.payment_status = 'PAID' - AND p.created_at >= (CURRENT_DATE - INTERVAL '3 months') -) -SELECT - cl."name" AS customer, - cl."codeName" AS client_codename, - COALESCE( - NULLIF(TRIM(proj.details::jsonb #>> '{taasDefinition,oppurtunityDetails,customerName}'), ''), - NULLIF(TRIM(proj.details::jsonb #>> '{project_data,group_customer_name}'), ''), - ba."name" - ) AS customer_name, - COALESCE(c."projectId"::text, ba."projectId") AS project_id, - proj.name AS project_name, - ba.id::text AS billing_account_id, - ba."name" AS billing_account_name, - COALESCE( - NULLIF(TRIM(proj.details::jsonb #>> '{taasDefinition,oppurtunityDetails,customerName}'), ''), - NULLIF(TRIM(proj.details::jsonb #>> '{project_data,group_customer_name}'), ''), - ba."name" - ) AS customer_name, - rp.challenge_id, - c."name" AS challenge_name, - c."createdAt" AS challenge_created_at, - rp.winner_id AS member_id, - mem.handle AS member_handle, - CASE - WHEN rp.category::text ILIKE '%REVIEW%' THEN 'review' - WHEN rp.category::text ILIKE '%COPILOT%' THEN 'copilot' - ELSE 'prize' - END AS payment_type, - COALESCE(rp.gross_amount, rp.total_amount) AS member_payment, - COALESCE( - rp.challenge_fee, - COALESCE(rp.gross_amount, rp.total_amount) * (rp.challenge_markup / 100.0) - ) AS fee, - rp.payment_created_at AS payment_created_at, - rp.date_paid AS paid_date -FROM recent_payments rp -LEFT JOIN challenges."Challenge" c - ON c."id" = rp.challenge_id -LEFT JOIN challenges."ChallengeBilling" cb - ON cb."challengeId" = c."id" -LEFT JOIN "billing-accounts"."BillingAccount" ba - ON ba."id" = COALESCE( - NULLIF(rp.billing_account, '')::int, - NULLIF(cb."billingAccountId", '')::int - ) -LEFT JOIN "billing-accounts"."Client" cl - ON cl."id" = ba."clientId" -LEFT JOIN projects.projects proj - ON proj.id = c."projectId"::bigint -LEFT JOIN members.member mem - ON mem."userId"::text = rp.winner_id -ORDER BY payment_created_at DESC; diff --git a/sql/reports/topcoder/member-payment-accrual.sql b/sql/reports/topcoder/member-payment-accrual.sql new file mode 100644 index 0000000..aec6c3a --- /dev/null +++ b/sql/reports/topcoder/member-payment-accrual.sql @@ -0,0 +1,87 @@ +WITH provided_dates AS ( + SELECT + NULLIF($1, '')::timestamptz AS start_date, + NULLIF($2, '')::timestamptz AS end_date +), +params AS ( + SELECT + COALESCE( + pd.start_date, + CASE + WHEN pd.end_date IS NOT NULL THEN pd.end_date - INTERVAL '3 months' + ELSE CURRENT_DATE - INTERVAL '3 months' + END + ) AS start_date, + COALESCE(pd.end_date, CURRENT_DATE) AS end_date + FROM provided_dates pd +), +latest_payment AS ( + SELECT + p.winnings_id, + MAX(p.version) AS max_version + FROM finance.payment p + GROUP BY p.winnings_id +), +recent_payments AS ( + SELECT + w.winning_id, + w.winner_id, + w.type, + w.description, + w.category, + w.external_id AS challenge_id, + w.created_at AS winning_created_at, + p.payment_id, + p.payment_status, + p.payment_method_id, + p.installment_number, + p.billing_account, + p.total_amount, + p.gross_amount, + p.challenge_fee, + p.challenge_markup, + p.date_paid, + p.created_at AS payment_created_at + FROM finance.winnings w + JOIN finance.payment p + ON p.winnings_id = w.winning_id + JOIN latest_payment lp + ON lp.winnings_id = p.winnings_id + AND lp.max_version = p.version + JOIN params pr ON TRUE + WHERE w.type = 'PAYMENT' + AND p.created_at >= pr.start_date + AND p.created_at <= pr.end_date +) +SELECT + rp.payment_created_at AS payment_created_at, + rp.payment_id, + rp.description AS payment_description, + rp.challenge_id, + rp.payment_status, + rp.type AS payment_type, + mem.handle AS payee_handle, + pm.name AS payment_method, + ba."name" AS billing_account_name, + cl."name" AS customer_name, + ba."subcontractingEndCustomer" AS reporting_account_name, + rp.winner_id AS member_id, + to_char(c."createdAt", 'YYYY-MM-DD') AS challenge_created_date, + rp.gross_amount AS user_payment_gross_amount +FROM recent_payments rp +LEFT JOIN challenges."Challenge" c + ON c."id" = rp.challenge_id +LEFT JOIN challenges."ChallengeBilling" cb + ON cb."challengeId" = c."id" +LEFT JOIN "billing-accounts"."BillingAccount" ba + ON ba."id" = COALESCE( + NULLIF(rp.billing_account, '')::int, + NULLIF(cb."billingAccountId", '')::int + ) +LEFT JOIN "billing-accounts"."Client" cl + ON cl."id" = ba."clientId" +LEFT JOIN finance.payment_method pm + ON pm.payment_method_id = rp.payment_method_id +LEFT JOIN members.member mem + ON mem."userId"::text = rp.winner_id +ORDER BY payment_created_at DESC; diff --git a/sql/reports/topgear/hourly.sql b/sql/reports/topgear/hourly.sql index c35471a..9953bd7 100644 --- a/sql/reports/topgear/hourly.sql +++ b/sql/reports/topgear/hourly.sql @@ -10,7 +10,7 @@ WITH base_challenges AS ( WHERE cb."challengeId" = c.id AND cb."billingAccountId" = '80000062' ) ba ON TRUE - WHERE c."createdAt" >= now() - interval '4 months' + WHERE c."updatedAt" >= now() - interval '100 days' AND ba.billing_account_id IS NOT NULL ), project_details AS ( @@ -288,5 +288,6 @@ LEFT JOIN LATERAL ( AND bc."createdAt" > '2025-01-01T00:00:00Z' ) cp ON TRUE WHERE bc.billing_account_id = '80000062' - AND bc."createdAt" >= now() - interval '4 months' -ORDER BY bc."createdAt" DESC; + AND (pd.latest_actual_end_date >= now() - interval '100 days' + OR bc.status='ACTIVE') +ORDER BY bc."updatedAt" DESC; diff --git a/src/auth/auth.middleware.ts b/src/auth/auth.middleware.ts index 26f072b..39945f5 100644 --- a/src/auth/auth.middleware.ts +++ b/src/auth/auth.middleware.ts @@ -11,6 +11,32 @@ const { jwtAuthenticator: authenticator } = middleware; const logger = new Logger("AuthMiddleware"); +function resolveAuthorizationHeader(headers: Record): string { + const headerCandidates = [ + headers["authorization"], + headers["x-authorization"], + headers["x-forwarded-authorization"], + headers["x-original-authorization"], + ]; + + for (const value of headerCandidates) { + if (!value) { + continue; + } + + if (Array.isArray(value)) { + const first = value.find(Boolean); + if (typeof first === "string") { + return first; + } + } else if (typeof value === "string") { + return value; + } + } + + return ""; +} + function decodeTokenPayload(token: string): Record | null { try { const parts = token.split("."); @@ -55,7 +81,10 @@ export class AuthMiddleware implements NestMiddleware { } use(req: any, res: Response, next: NextFunction) { - if (req.headers.authorization) { + const authorizationHeader = resolveAuthorizationHeader(req.headers ?? {}); + + if (authorizationHeader) { + req.headers.authorization = authorizationHeader; this.jwtAuthenticator(req, res, (err) => { if (err) { const token = req.headers.authorization?.replace(/^Bearer\s+/i, ""); diff --git a/src/reports/report-directory.data.ts b/src/reports/report-directory.data.ts index af1684a..1547d0b 100644 --- a/src/reports/report-directory.data.ts +++ b/src/reports/report-directory.data.ts @@ -449,9 +449,10 @@ export const REPORTS_DIRECTORY: ReportsDirectory = { "Weekly distinct registrants and submitters for the last five weeks", ), report( - "30 Day Payments", - "/topcoder/30-day-payments", - "Member payments for the last 30 days", + "Member Payment Accrual", + "/topcoder/member-payment-accrual", + "Member payment accruals for the provided date range (defaults to last 3 months)", + [paymentsStartDateParam, paymentsEndDateParam], ), report( "90 Day Member Spend", diff --git a/src/reports/reports.controller.ts b/src/reports/reports.controller.ts index 7093fec..493ec76 100644 --- a/src/reports/reports.controller.ts +++ b/src/reports/reports.controller.ts @@ -18,4 +18,16 @@ export class ReportsController { getReports(): ReportsDirectory { return REPORTS_DIRECTORY; } + + @Get("/directory") + @UseGuards(PermissionsGuard) + @Scopes(AppScopes.AllReports) + @ApiBearerAuth() + @ApiOperation({ + summary: + "List available report endpoints grouped by sub-path (alias for /v6/reports)", + }) + getReportsDirectory(): ReportsDirectory { + return REPORTS_DIRECTORY; + } } diff --git a/src/reports/topcoder/dto/member-payment-accrual.dto.ts b/src/reports/topcoder/dto/member-payment-accrual.dto.ts new file mode 100644 index 0000000..e42c412 --- /dev/null +++ b/src/reports/topcoder/dto/member-payment-accrual.dto.ts @@ -0,0 +1,22 @@ +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { IsDateString, IsOptional } from "class-validator"; + +export class MemberPaymentAccrualQueryDto { + @ApiPropertyOptional({ + description: + "Start date (inclusive) for filtering payment creation date in ISO 8601 format", + example: "2024-01-01T00:00:00.000Z", + }) + @IsOptional() + @IsDateString() + startDate?: string; + + @ApiPropertyOptional({ + description: + "End date (inclusive) for filtering payment creation date in ISO 8601 format", + example: "2024-01-31T23:59:59.000Z", + }) + @IsOptional() + @IsDateString() + endDate?: string; +} diff --git a/src/reports/topcoder/topcoder-reports.controller.ts b/src/reports/topcoder/topcoder-reports.controller.ts index 45ba657..f5edaa5 100644 --- a/src/reports/topcoder/topcoder-reports.controller.ts +++ b/src/reports/topcoder/topcoder-reports.controller.ts @@ -9,6 +9,7 @@ import { import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; import { TopcoderReportsService } from "./topcoder-reports.service"; import { RegistrantCountriesQueryDto } from "./dto/registrant-countries.dto"; +import { MemberPaymentAccrualQueryDto } from "./dto/member-payment-accrual.dto"; import { TopcoderReportsGuard } from "../../auth/guards/topcoder-reports.guard"; import { CsvResponseInterceptor } from "../../common/interceptors/csv-response.interceptor"; @@ -67,12 +68,14 @@ export class TopcoderReportsController { return this.reports.getWeeklyMemberParticipation(); } - @Get("/30-day-payments") + @Get("/member-payment-accrual") @ApiOperation({ - summary: "Member payments for the last 30 days", + summary: + "Member payment accruals for the provided date range (defaults to last 3 months)", }) - get30DayPayments() { - return this.reports.get30DayPayments(); + getMemberPaymentAccrual(@Query() query: MemberPaymentAccrualQueryDto) { + const { startDate, endDate } = query; + return this.reports.getMemberPaymentAccrual(startDate, endDate); } @Get("/90-day-member-spend") diff --git a/src/reports/topcoder/topcoder-reports.service.ts b/src/reports/topcoder/topcoder-reports.service.ts index c30b517..8181113 100644 --- a/src/reports/topcoder/topcoder-reports.service.ts +++ b/src/reports/topcoder/topcoder-reports.service.ts @@ -25,22 +25,21 @@ type MarathonMatchStatsRow = { marathon_submission_rate: string | number | null; }; -type ThirtyDayPaymentRow = { - customer: string | null; - client_codename: string | null; - project_id: string | null; - project_name: string | null; - billing_account_id: string | null; - billing_account_name: string | null; +type MemberPaymentAccrualRow = { + payment_created_at: Date | string | null; + payment_id: string | null; + payment_description: string | null; challenge_id: string | null; - challenge_name: string | null; - challenge_created_at: Date | string | null; - member_id: string | null; - member_handle: string | null; + payment_status: string | null; payment_type: string | null; - member_payment: string | number | null; - fee: string | number | null; - payment_date: Date | string | null; + payee_handle: string | null; + payment_method: string | null; + billing_account_name: string | null; + customer_name: string | null; + reporting_account_name: string | null; + member_id: string | null; + challenge_created_date: string | null; + user_payment_gross_amount: string | number | null; }; @Injectable() @@ -427,25 +426,32 @@ export class TopcoderReportsService { })); } - async get30DayPayments() { - const query = this.sql.load("reports/topcoder/30-day-payments.sql"); - const rows = await this.db.query(query); + async getMemberPaymentAccrual( + startDate?: string, + endDate?: string, + ) { + const query = this.sql.load("reports/topcoder/member-payment-accrual.sql"); + const rows = await this.db.query(query, [ + startDate ?? null, + endDate ?? null, + ]); return rows.map((row) => ({ - customer: row.customer ?? null, - clientCodeName: row.client_codename ?? null, - projectId: row.project_id ?? null, - projectName: row.project_name ?? null, - billingAccountId: row.billing_account_id ?? null, - billingAccountName: row.billing_account_name ?? null, + paymentCreatedAt: this.normalizeDate(row.payment_created_at), + paymentId: row.payment_id ?? null, + paymentDescription: row.payment_description ?? null, challengeId: row.challenge_id ?? null, - challengeName: row.challenge_name ?? null, - challengeCreatedAt: this.normalizeDate(row.challenge_created_at), - memberId: row.member_id ?? null, - memberHandle: row.member_handle ?? null, + paymentStatus: row.payment_status ?? null, paymentType: row.payment_type ?? null, - memberPayment: this.toNullableNumber(row.member_payment), - fee: this.toNullableNumber(row.fee), - paymentDate: this.normalizeDate(row.payment_date), + payeeHandle: row.payee_handle ?? null, + payeePaymentMethod: row.payment_method ?? null, + billingAccountName: row.billing_account_name ?? null, + customerName: row.customer_name ?? null, + reportingAccountName: row.reporting_account_name ?? null, + memberId: row.member_id ?? null, + challengeCreatedAt: row.challenge_created_date ?? null, + userPaymentGrossAmount: this.toNullableNumber( + row.user_payment_gross_amount, + ), })); }