From f142b4d758c50b8278f08f158dcde987619e88fc Mon Sep 17 00:00:00 2001 From: jmgasper Date: Wed, 20 May 2026 12:12:52 +1000 Subject: [PATCH] PM-5043: Provide billing line item member subtotals What was broken The prior platform-ui fixes could use memberPaymentAmount when the billing account API returned it, but the API only guaranteed those subtotals for copilot-only responses. PM/TM views could still receive consumed challenge ledger amounts without a reliable member-payment subtotal. Root cause Billing-account detail serialization exposed the ledger amount, which includes markup, while memberPaymentAmount was added only during copilot sanitization and used billing-account markup for every row. Consumed challenge rows need the challenge-specific markup when available, and locked challenge rows already store the member-payment subtotal. What was changed Billing-account detail responses now add memberPaymentAmount to locked and consumed line items before auth-specific sanitization. Challenge markup is resolved from the challenge database, consumed challenge rows use that markup to reverse the ledger amount, locked challenge rows keep their stored amount, and copilot sanitization preserves precomputed subtotals. Any added/updated tests No automated tests were added because this service does not define a test script or include a test runner. Validation was covered by lint and build. --- README.md | 10 +- .../billing-accounts.controller.ts | 2 +- .../billing-accounts.service.ts | 155 +++++++++++++++--- .../external-budget-entry-lookup.service.ts | 89 ++++++++++ 4 files changed, 225 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index a10074e..3435193 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ - Endpoints: - `GET /billing-accounts` - `POST /billing-accounts` - - `GET /billing-accounts/:billingAccountId` (includes locked/consumed arrays + budget totals; line items expose `amount`, `date`, `externalId`, `externalType`, `externalName`, and `challengeId` only for challenge compatibility; copilot, project-scoped, and Talent Manager callers only receive line items for projects they belong to; copilot-only callers receive `memberPaymentsRemaining` and per-line-item `memberPaymentAmount` instead of raw `markup`) + - `GET /billing-accounts/:billingAccountId` (includes locked/consumed arrays + budget totals; line items expose `amount`, `memberPaymentAmount`, `date`, `externalId`, `externalType`, `externalName`, and `challengeId` only for challenge compatibility; copilot, project-scoped, and Talent Manager callers only receive line items for projects they belong to; copilot-only callers receive `memberPaymentsRemaining` instead of raw `markup`) - `GET /billing-accounts/users/:userId` (list billing accounts accessible by the given Topcoder user ID — resolved via Salesforce resource object) - `PATCH /billing-accounts/:billingAccountId` - `PATCH /billing-accounts/:billingAccountId/lock-amount` (challenge-only typed reference; non-negative amount; 0 amount = unlock; rejects insufficient remaining funds) @@ -37,10 +37,10 @@ management or copilot project role for the project fallback. Copilot-only callers receive billing-account responses with `markup` omitted and `memberPaymentsRemaining` derived server-side as total remaining minus - the total remaining markup amount. Billing-account detail - responses for copilots also include per-line-item `memberPaymentAmount` values - that remove the markup fee from locked and consumed amounts. On detail - responses, copilot, project-scoped, and Talent + the total remaining markup amount. Billing-account detail responses include + per-line-item `memberPaymentAmount` values that separate member-payment + subtotals from ledger amounts that include markup. On detail responses, + copilot, project-scoped, and Talent Manager callers only receive locked/consumed line items whose challenge or engagement project maps to an active `project_members` row for their user id. Line items with unresolved project access are omitted for those callers. diff --git a/src/billing-accounts/billing-accounts.controller.ts b/src/billing-accounts/billing-accounts.controller.ts index d042d75..3afa827 100644 --- a/src/billing-accounts/billing-accounts.controller.ts +++ b/src/billing-accounts/billing-accounts.controller.ts @@ -179,7 +179,7 @@ export class BillingAccountsController { buildOperationDoc({ summary: "Get a billing account", description: - "Fetch a billing account by its identifier, including budget, client data, and normalized locked/consumed line items. Line items include amount, date, externalId, externalType, externalName, and challengeId only for legacy challenge compatibility. Project Managers can read billing accounts granted to them or assigned to non-deleted projects; plain Topcoder User project members need an allowed project billing-account role. Copilot, Project Manager, Topcoder User, and Talent Manager callers only receive locked/consumed line items for projects they belong to. Copilot-only callers receive copilot-safe budget data without the raw markup field, including memberPaymentsRemaining and per-line-item memberPaymentAmount values.", + "Fetch a billing account by its identifier, including budget, client data, and normalized locked/consumed line items. Line items include amount, memberPaymentAmount, date, externalId, externalType, externalName, and challengeId only for legacy challenge compatibility. Project Managers can read billing accounts granted to them or assigned to non-deleted projects; plain Topcoder User project members need an allowed project billing-account role. Copilot, Project Manager, Topcoder User, and Talent Manager callers only receive locked/consumed line items for projects they belong to. Copilot-only callers receive copilot-safe budget data without the raw markup field, including memberPaymentsRemaining.", jwtRoles: BILLING_ACCOUNT_DETAIL_READ_ROLES, m2mScopes: [SCOPES.READ_BA, SCOPES.ALL_BA], }), diff --git a/src/billing-accounts/billing-accounts.service.ts b/src/billing-accounts/billing-accounts.service.ts index 595c138..82de77b 100644 --- a/src/billing-accounts/billing-accounts.service.ts +++ b/src/billing-accounts/billing-accounts.service.ts @@ -91,6 +91,8 @@ interface BudgetAmountLineItem { updatedAt: Date; } +type BudgetLineItemStatus = "locked" | "consumed"; + interface BillingAccountBudgetLockRow { budget: Prisma.Decimal; } @@ -526,13 +528,14 @@ export class BillingAccountsService { * for that project fallback, while global Project Manager callers can use * the project assignment directly. Missing access is surfaced as not found * to avoid leaking account existence. Locked and consumed line items expose - * `amount`, `date`, `externalId`, `externalType`, and `externalName`; - * challenge rows also expose the deprecated `challengeId` compatibility - * alias. Copilot-only callers also receive `memberPaymentAmount` on each - * line item so the UI can show the payment value without exposing markup. - * Copilot, project-scoped, and Talent Manager callers only receive line - * items for projects they belong to; unresolved project access hides the - * line item. + * `amount`, `memberPaymentAmount`, `date`, `externalId`, `externalType`, + * and `externalName`; challenge rows also expose the deprecated + * `challengeId` compatibility alias. `memberPaymentAmount` lets the UI show + * the member-payment subtotal separately from the ledger charge that includes + * billing markup. Copilot-only callers receive the same line-item subtotals + * while raw `markup` is hidden. Copilot, project-scoped, and Talent Manager + * callers only receive line items for projects they belong to; unresolved + * project access hides the line item. * * @param billingAccountId Billing-account identifier. * @param authUser Authenticated caller context from `req.authUser`. @@ -615,24 +618,41 @@ export class BillingAccountsService { }, authUser, ); - const externalNames = await this.externalBudgetEntryLookup.getExternalNames( - [ - ...lockedAmounts.map((lineItem) => - this.toBudgetEntryReference(lineItem), - ), - ...consumedAmounts.map((lineItem) => - this.toBudgetEntryReference(lineItem), - ), - ], + const budgetEntryReferences = [ + ...lockedAmounts.map((lineItem) => this.toBudgetEntryReference(lineItem)), + ...consumedAmounts.map((lineItem) => + this.toBudgetEntryReference(lineItem), + ), + ]; + const challengeReferenceIds = budgetEntryReferences + .filter((reference) => reference.externalType === "CHALLENGE") + .map((reference) => reference.externalId); + const [externalNames, challengeBillingMarkups] = await Promise.all([ + this.externalBudgetEntryLookup.getExternalNames(budgetEntryReferences), + this.externalBudgetEntryLookup.getChallengeBillingMarkupsByIds( + challengeReferenceIds, + ), + ]); + const serializedLockedAmounts = lockedAmounts.map((lineItem) => + this.serializeBudgetLineItem(lineItem, externalNames), + ); + const serializedConsumedAmounts = consumedAmounts.map((lineItem) => + this.serializeBudgetLineItem(lineItem, externalNames), ); const response = { ...ba, - lockedAmounts: lockedAmounts.map((lineItem) => - this.serializeBudgetLineItem(lineItem, externalNames), + lockedAmounts: this.addMemberPaymentAmountsToLineItems( + "locked", + serializedLockedAmounts, + ba.markup, + challengeBillingMarkups, ), - consumedAmounts: consumedAmounts.map((lineItem) => - this.serializeBudgetLineItem(lineItem, externalNames), + consumedAmounts: this.addMemberPaymentAmountsToLineItems( + "consumed", + serializedConsumedAmounts, + ba.markup, + challengeBillingMarkups, ), lockedBudget: locked, consumedBudget: consumed, @@ -1250,15 +1270,93 @@ export class BillingAccountsService { : serializedLineItem; } + /** + * Rounds a stored ledger amount for display-oriented member-payment fields. + * + * @param amount Stored billing-account amount. + * @returns Amount rounded to cents, or `undefined` when it is invalid. + */ + private roundMemberPaymentAmount(amount: unknown): number | undefined { + const normalizedAmount = Number(amount); + + return Number.isFinite(normalizedAmount) + ? Number(normalizedAmount.toFixed(2)) + : undefined; + } + + /** + * Resolves the member-payment subtotal for one detail line item. + * + * @param status Source budget bucket for the row. + * @param lineItem Serialized billing-account line item. + * @param billingMarkup Billing-account markup from persistence. + * @param challengeBillingMarkups Challenge-specific markups keyed by external id. + * @returns Member-payment subtotal rounded to cents, or `undefined` when it cannot be derived. + * @remarks Locked challenge rows already store the member-payment subtotal. + * Consumed challenge rows use challenge-specific markup when available so + * zero-markup challenges do not inherit the billing-account default. + */ + private getLineItemMemberPaymentAmount( + status: BudgetLineItemStatus, + lineItem: BudgetLineItemResponse, + billingMarkup: unknown, + challengeBillingMarkups: Map, + ): number | undefined { + if (lineItem.externalType === "CHALLENGE") { + if (status === "locked") { + return this.roundMemberPaymentAmount(lineItem.amount); + } + + return this.calculateMemberPaymentAmount( + lineItem.amount, + challengeBillingMarkups.get(lineItem.externalId) ?? billingMarkup, + ); + } + + return this.calculateMemberPaymentAmount(lineItem.amount, billingMarkup); + } + + /** + * Adds member-payment subtotals to billing-account detail line items. + * + * @param status Source budget bucket for the line items. + * @param lineItems Serialized locked or consumed line items. + * @param billingMarkup Billing-account markup from persistence. + * @param challengeBillingMarkups Challenge-specific markups keyed by external id. + * @returns Line items with `memberPaymentAmount` when it can be calculated. + */ + private addMemberPaymentAmountsToLineItems( + status: BudgetLineItemStatus, + lineItems: BudgetLineItemResponse[], + billingMarkup: unknown, + challengeBillingMarkups: Map, + ): BudgetLineItemResponse[] { + return lineItems.map((lineItem) => { + const memberPaymentAmount = this.getLineItemMemberPaymentAmount( + status, + lineItem, + billingMarkup, + challengeBillingMarkups, + ); + + return memberPaymentAmount === undefined + ? lineItem + : { + ...lineItem, + memberPaymentAmount, + }; + }); + } + /** * Calculates a copilot-safe member-payment amount from a billing ledger amount. * - * Billing locked and consumed rows store the member payment plus its markup - * fee. This reverses that ledger amount while keeping the raw billing markup - * on the server. A zero markup means the full amount is a member payment. + * Billing rows that store member payments plus their markup fee can be + * reversed with this helper while keeping raw markup math on the server. A + * zero markup means the full amount is a member payment. * * @param billingAccountAmount Billing ledger amount that includes markup. - * @param markup Billing-account markup from persistence. + * @param markup Billing or challenge markup from persistence. * @returns Rounded member-payment amount, or `undefined` when inputs are invalid. */ private calculateMemberPaymentAmount( @@ -1318,11 +1416,14 @@ export class BillingAccountsService { } /** - * Adds copilot-safe member-payment display amounts to budget line items. + * Ensures copilot-safe member-payment display amounts exist on budget line items. * * @param lineItems Serialized locked or consumed line items. * @param markup Billing-account markup from persistence. * @returns Line items with `memberPaymentAmount` when it can be calculated. + * @remarks Detail responses precompute member-payment subtotals with + * challenge-specific markup. This fallback preserves those exact values and + * only calculates missing values for older response shapes. */ private serializeLineItemsForCopilot( lineItems: BudgetLineItemResponse[] | undefined, @@ -1333,6 +1434,10 @@ export class BillingAccountsService { } return lineItems.map((lineItem) => { + if (lineItem.memberPaymentAmount !== undefined) { + return lineItem; + } + const memberPaymentAmount = this.calculateMemberPaymentAmount( lineItem.amount, markup, diff --git a/src/billing-accounts/external-budget-entry-lookup.service.ts b/src/billing-accounts/external-budget-entry-lookup.service.ts index c3878b3..6394504 100644 --- a/src/billing-accounts/external-budget-entry-lookup.service.ts +++ b/src/billing-accounts/external-budget-entry-lookup.service.ts @@ -18,6 +18,12 @@ interface ChallengeProjectRow { projectId: number | bigint | string | null; } +interface ChallengeBillingMarkupRow { + id: string; + legacyId: number | null; + markup: number | null; +} + interface EngagementNameRow { id: string; title: string | null; @@ -147,6 +153,70 @@ export class ExternalBudgetEntryLookupService implements OnModuleDestroy { return result; } + /** + * Resolve challenge billing markups by current challenge ids and numeric legacy ids. + * + * @param externalIds Challenge ids stored on budget entries. + * @returns Map of each matched challenge id or legacy id to billing markup. + */ + async getChallengeBillingMarkupsByIds( + externalIds: string[], + ): Promise> { + const result = new Map(); + const uniqueExternalIds = [...new Set(externalIds.filter(Boolean))]; + + if (uniqueExternalIds.length === 0) { + return result; + } + + const client = this.getChallengeClient(); + + if (!client) { + return result; + } + + try { + const idRows = await client.$queryRaw( + Prisma.sql` + SELECT c."id", c."legacyId", cb."markup" + FROM "Challenge" c + LEFT JOIN "ChallengeBilling" cb + ON cb."challengeId" = c."id" + WHERE c."id" IN (${Prisma.join(uniqueExternalIds)}) + `, + ); + + for (const row of idRows) { + this.addChallengeBillingMarkup(result, row, row.id); + } + + const legacyIds = this.getNumericLegacyIds(uniqueExternalIds); + if (legacyIds.length > 0) { + const legacyRows = await client.$queryRaw( + Prisma.sql` + SELECT c."id", c."legacyId", cb."markup" + FROM "Challenge" c + LEFT JOIN "ChallengeBilling" cb + ON cb."challengeId" = c."id" + WHERE c."legacyId" IN (${Prisma.join(legacyIds)}) + `, + ); + + for (const row of legacyRows) { + if (row.legacyId !== null) { + this.addChallengeBillingMarkup(result, row, String(row.legacyId)); + } + } + } + } catch (error) { + this.logger.warn( + `Failed to resolve challenge billing markups for billing-account entries: ${this.getErrorMessage(error)}`, + ); + } + + return result; + } + /** * Resolve which budget-entry references belong to projects accessible by a user. * @@ -688,6 +758,25 @@ export class ExternalBudgetEntryLookupService implements OnModuleDestroy { } } + /** + * Adds a finite challenge billing markup to the lookup result. + * + * @param result Mutable challenge markup map. + * @param row Challenge billing row returned by the challenge database. + * @param externalId Budget-entry external id that should resolve to the markup. + */ + private addChallengeBillingMarkup( + result: Map, + row: ChallengeBillingMarkupRow, + externalId: string, + ): void { + const markup = Number(row.markup); + + if (Number.isFinite(markup)) { + result.set(externalId, markup); + } + } + /** * Converts string ids that can represent challenge legacy ids into numbers. *