Skip to content

Commit f142b4d

Browse files
committed
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.
1 parent e1531ff commit f142b4d

4 files changed

Lines changed: 225 additions & 31 deletions

File tree

README.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
- Endpoints:
88
- `GET /billing-accounts`
99
- `POST /billing-accounts`
10-
- `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`)
10+
- `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`)
1111
- `GET /billing-accounts/users/:userId` (list billing accounts accessible by the given Topcoder user ID — resolved via Salesforce resource object)
1212
- `PATCH /billing-accounts/:billingAccountId`
1313
- `PATCH /billing-accounts/:billingAccountId/lock-amount` (challenge-only typed reference; non-negative amount; 0 amount = unlock; rejects insufficient remaining funds)
@@ -37,10 +37,10 @@
3737
management or copilot project role for the project fallback.
3838
Copilot-only callers receive billing-account responses with `markup` omitted
3939
and `memberPaymentsRemaining` derived server-side as total remaining minus
40-
the total remaining markup amount. Billing-account detail
41-
responses for copilots also include per-line-item `memberPaymentAmount` values
42-
that remove the markup fee from locked and consumed amounts. On detail
43-
responses, copilot, project-scoped, and Talent
40+
the total remaining markup amount. Billing-account detail responses include
41+
per-line-item `memberPaymentAmount` values that separate member-payment
42+
subtotals from ledger amounts that include markup. On detail responses,
43+
copilot, project-scoped, and Talent
4444
Manager callers only receive locked/consumed line items whose challenge or
4545
engagement project maps to an active `project_members` row for their user id.
4646
Line items with unresolved project access are omitted for those callers.

src/billing-accounts/billing-accounts.controller.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ export class BillingAccountsController {
179179
buildOperationDoc({
180180
summary: "Get a billing account",
181181
description:
182-
"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.",
182+
"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.",
183183
jwtRoles: BILLING_ACCOUNT_DETAIL_READ_ROLES,
184184
m2mScopes: [SCOPES.READ_BA, SCOPES.ALL_BA],
185185
}),

src/billing-accounts/billing-accounts.service.ts

Lines changed: 130 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,8 @@ interface BudgetAmountLineItem {
9191
updatedAt: Date;
9292
}
9393

94+
type BudgetLineItemStatus = "locked" | "consumed";
95+
9496
interface BillingAccountBudgetLockRow {
9597
budget: Prisma.Decimal;
9698
}
@@ -526,13 +528,14 @@ export class BillingAccountsService {
526528
* for that project fallback, while global Project Manager callers can use
527529
* the project assignment directly. Missing access is surfaced as not found
528530
* to avoid leaking account existence. Locked and consumed line items expose
529-
* `amount`, `date`, `externalId`, `externalType`, and `externalName`;
530-
* challenge rows also expose the deprecated `challengeId` compatibility
531-
* alias. Copilot-only callers also receive `memberPaymentAmount` on each
532-
* line item so the UI can show the payment value without exposing markup.
533-
* Copilot, project-scoped, and Talent Manager callers only receive line
534-
* items for projects they belong to; unresolved project access hides the
535-
* line item.
531+
* `amount`, `memberPaymentAmount`, `date`, `externalId`, `externalType`,
532+
* and `externalName`; challenge rows also expose the deprecated
533+
* `challengeId` compatibility alias. `memberPaymentAmount` lets the UI show
534+
* the member-payment subtotal separately from the ledger charge that includes
535+
* billing markup. Copilot-only callers receive the same line-item subtotals
536+
* while raw `markup` is hidden. Copilot, project-scoped, and Talent Manager
537+
* callers only receive line items for projects they belong to; unresolved
538+
* project access hides the line item.
536539
*
537540
* @param billingAccountId Billing-account identifier.
538541
* @param authUser Authenticated caller context from `req.authUser`.
@@ -615,24 +618,41 @@ export class BillingAccountsService {
615618
},
616619
authUser,
617620
);
618-
const externalNames = await this.externalBudgetEntryLookup.getExternalNames(
619-
[
620-
...lockedAmounts.map((lineItem) =>
621-
this.toBudgetEntryReference(lineItem),
622-
),
623-
...consumedAmounts.map((lineItem) =>
624-
this.toBudgetEntryReference(lineItem),
625-
),
626-
],
621+
const budgetEntryReferences = [
622+
...lockedAmounts.map((lineItem) => this.toBudgetEntryReference(lineItem)),
623+
...consumedAmounts.map((lineItem) =>
624+
this.toBudgetEntryReference(lineItem),
625+
),
626+
];
627+
const challengeReferenceIds = budgetEntryReferences
628+
.filter((reference) => reference.externalType === "CHALLENGE")
629+
.map((reference) => reference.externalId);
630+
const [externalNames, challengeBillingMarkups] = await Promise.all([
631+
this.externalBudgetEntryLookup.getExternalNames(budgetEntryReferences),
632+
this.externalBudgetEntryLookup.getChallengeBillingMarkupsByIds(
633+
challengeReferenceIds,
634+
),
635+
]);
636+
const serializedLockedAmounts = lockedAmounts.map((lineItem) =>
637+
this.serializeBudgetLineItem(lineItem, externalNames),
638+
);
639+
const serializedConsumedAmounts = consumedAmounts.map((lineItem) =>
640+
this.serializeBudgetLineItem(lineItem, externalNames),
627641
);
628642

629643
const response = {
630644
...ba,
631-
lockedAmounts: lockedAmounts.map((lineItem) =>
632-
this.serializeBudgetLineItem(lineItem, externalNames),
645+
lockedAmounts: this.addMemberPaymentAmountsToLineItems(
646+
"locked",
647+
serializedLockedAmounts,
648+
ba.markup,
649+
challengeBillingMarkups,
633650
),
634-
consumedAmounts: consumedAmounts.map((lineItem) =>
635-
this.serializeBudgetLineItem(lineItem, externalNames),
651+
consumedAmounts: this.addMemberPaymentAmountsToLineItems(
652+
"consumed",
653+
serializedConsumedAmounts,
654+
ba.markup,
655+
challengeBillingMarkups,
636656
),
637657
lockedBudget: locked,
638658
consumedBudget: consumed,
@@ -1250,15 +1270,93 @@ export class BillingAccountsService {
12501270
: serializedLineItem;
12511271
}
12521272

1273+
/**
1274+
* Rounds a stored ledger amount for display-oriented member-payment fields.
1275+
*
1276+
* @param amount Stored billing-account amount.
1277+
* @returns Amount rounded to cents, or `undefined` when it is invalid.
1278+
*/
1279+
private roundMemberPaymentAmount(amount: unknown): number | undefined {
1280+
const normalizedAmount = Number(amount);
1281+
1282+
return Number.isFinite(normalizedAmount)
1283+
? Number(normalizedAmount.toFixed(2))
1284+
: undefined;
1285+
}
1286+
1287+
/**
1288+
* Resolves the member-payment subtotal for one detail line item.
1289+
*
1290+
* @param status Source budget bucket for the row.
1291+
* @param lineItem Serialized billing-account line item.
1292+
* @param billingMarkup Billing-account markup from persistence.
1293+
* @param challengeBillingMarkups Challenge-specific markups keyed by external id.
1294+
* @returns Member-payment subtotal rounded to cents, or `undefined` when it cannot be derived.
1295+
* @remarks Locked challenge rows already store the member-payment subtotal.
1296+
* Consumed challenge rows use challenge-specific markup when available so
1297+
* zero-markup challenges do not inherit the billing-account default.
1298+
*/
1299+
private getLineItemMemberPaymentAmount(
1300+
status: BudgetLineItemStatus,
1301+
lineItem: BudgetLineItemResponse,
1302+
billingMarkup: unknown,
1303+
challengeBillingMarkups: Map<string, number>,
1304+
): number | undefined {
1305+
if (lineItem.externalType === "CHALLENGE") {
1306+
if (status === "locked") {
1307+
return this.roundMemberPaymentAmount(lineItem.amount);
1308+
}
1309+
1310+
return this.calculateMemberPaymentAmount(
1311+
lineItem.amount,
1312+
challengeBillingMarkups.get(lineItem.externalId) ?? billingMarkup,
1313+
);
1314+
}
1315+
1316+
return this.calculateMemberPaymentAmount(lineItem.amount, billingMarkup);
1317+
}
1318+
1319+
/**
1320+
* Adds member-payment subtotals to billing-account detail line items.
1321+
*
1322+
* @param status Source budget bucket for the line items.
1323+
* @param lineItems Serialized locked or consumed line items.
1324+
* @param billingMarkup Billing-account markup from persistence.
1325+
* @param challengeBillingMarkups Challenge-specific markups keyed by external id.
1326+
* @returns Line items with `memberPaymentAmount` when it can be calculated.
1327+
*/
1328+
private addMemberPaymentAmountsToLineItems(
1329+
status: BudgetLineItemStatus,
1330+
lineItems: BudgetLineItemResponse[],
1331+
billingMarkup: unknown,
1332+
challengeBillingMarkups: Map<string, number>,
1333+
): BudgetLineItemResponse[] {
1334+
return lineItems.map((lineItem) => {
1335+
const memberPaymentAmount = this.getLineItemMemberPaymentAmount(
1336+
status,
1337+
lineItem,
1338+
billingMarkup,
1339+
challengeBillingMarkups,
1340+
);
1341+
1342+
return memberPaymentAmount === undefined
1343+
? lineItem
1344+
: {
1345+
...lineItem,
1346+
memberPaymentAmount,
1347+
};
1348+
});
1349+
}
1350+
12531351
/**
12541352
* Calculates a copilot-safe member-payment amount from a billing ledger amount.
12551353
*
1256-
* Billing locked and consumed rows store the member payment plus its markup
1257-
* fee. This reverses that ledger amount while keeping the raw billing markup
1258-
* on the server. A zero markup means the full amount is a member payment.
1354+
* Billing rows that store member payments plus their markup fee can be
1355+
* reversed with this helper while keeping raw markup math on the server. A
1356+
* zero markup means the full amount is a member payment.
12591357
*
12601358
* @param billingAccountAmount Billing ledger amount that includes markup.
1261-
* @param markup Billing-account markup from persistence.
1359+
* @param markup Billing or challenge markup from persistence.
12621360
* @returns Rounded member-payment amount, or `undefined` when inputs are invalid.
12631361
*/
12641362
private calculateMemberPaymentAmount(
@@ -1318,11 +1416,14 @@ export class BillingAccountsService {
13181416
}
13191417

13201418
/**
1321-
* Adds copilot-safe member-payment display amounts to budget line items.
1419+
* Ensures copilot-safe member-payment display amounts exist on budget line items.
13221420
*
13231421
* @param lineItems Serialized locked or consumed line items.
13241422
* @param markup Billing-account markup from persistence.
13251423
* @returns Line items with `memberPaymentAmount` when it can be calculated.
1424+
* @remarks Detail responses precompute member-payment subtotals with
1425+
* challenge-specific markup. This fallback preserves those exact values and
1426+
* only calculates missing values for older response shapes.
13261427
*/
13271428
private serializeLineItemsForCopilot(
13281429
lineItems: BudgetLineItemResponse[] | undefined,
@@ -1333,6 +1434,10 @@ export class BillingAccountsService {
13331434
}
13341435

13351436
return lineItems.map((lineItem) => {
1437+
if (lineItem.memberPaymentAmount !== undefined) {
1438+
return lineItem;
1439+
}
1440+
13361441
const memberPaymentAmount = this.calculateMemberPaymentAmount(
13371442
lineItem.amount,
13381443
markup,

src/billing-accounts/external-budget-entry-lookup.service.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@ interface ChallengeProjectRow {
1818
projectId: number | bigint | string | null;
1919
}
2020

21+
interface ChallengeBillingMarkupRow {
22+
id: string;
23+
legacyId: number | null;
24+
markup: number | null;
25+
}
26+
2127
interface EngagementNameRow {
2228
id: string;
2329
title: string | null;
@@ -147,6 +153,70 @@ export class ExternalBudgetEntryLookupService implements OnModuleDestroy {
147153
return result;
148154
}
149155

156+
/**
157+
* Resolve challenge billing markups by current challenge ids and numeric legacy ids.
158+
*
159+
* @param externalIds Challenge ids stored on budget entries.
160+
* @returns Map of each matched challenge id or legacy id to billing markup.
161+
*/
162+
async getChallengeBillingMarkupsByIds(
163+
externalIds: string[],
164+
): Promise<Map<string, number>> {
165+
const result = new Map<string, number>();
166+
const uniqueExternalIds = [...new Set(externalIds.filter(Boolean))];
167+
168+
if (uniqueExternalIds.length === 0) {
169+
return result;
170+
}
171+
172+
const client = this.getChallengeClient();
173+
174+
if (!client) {
175+
return result;
176+
}
177+
178+
try {
179+
const idRows = await client.$queryRaw<ChallengeBillingMarkupRow[]>(
180+
Prisma.sql`
181+
SELECT c."id", c."legacyId", cb."markup"
182+
FROM "Challenge" c
183+
LEFT JOIN "ChallengeBilling" cb
184+
ON cb."challengeId" = c."id"
185+
WHERE c."id" IN (${Prisma.join(uniqueExternalIds)})
186+
`,
187+
);
188+
189+
for (const row of idRows) {
190+
this.addChallengeBillingMarkup(result, row, row.id);
191+
}
192+
193+
const legacyIds = this.getNumericLegacyIds(uniqueExternalIds);
194+
if (legacyIds.length > 0) {
195+
const legacyRows = await client.$queryRaw<ChallengeBillingMarkupRow[]>(
196+
Prisma.sql`
197+
SELECT c."id", c."legacyId", cb."markup"
198+
FROM "Challenge" c
199+
LEFT JOIN "ChallengeBilling" cb
200+
ON cb."challengeId" = c."id"
201+
WHERE c."legacyId" IN (${Prisma.join(legacyIds)})
202+
`,
203+
);
204+
205+
for (const row of legacyRows) {
206+
if (row.legacyId !== null) {
207+
this.addChallengeBillingMarkup(result, row, String(row.legacyId));
208+
}
209+
}
210+
}
211+
} catch (error) {
212+
this.logger.warn(
213+
`Failed to resolve challenge billing markups for billing-account entries: ${this.getErrorMessage(error)}`,
214+
);
215+
}
216+
217+
return result;
218+
}
219+
150220
/**
151221
* Resolve which budget-entry references belong to projects accessible by a user.
152222
*
@@ -688,6 +758,25 @@ export class ExternalBudgetEntryLookupService implements OnModuleDestroy {
688758
}
689759
}
690760

761+
/**
762+
* Adds a finite challenge billing markup to the lookup result.
763+
*
764+
* @param result Mutable challenge markup map.
765+
* @param row Challenge billing row returned by the challenge database.
766+
* @param externalId Budget-entry external id that should resolve to the markup.
767+
*/
768+
private addChallengeBillingMarkup(
769+
result: Map<string, number>,
770+
row: ChallengeBillingMarkupRow,
771+
externalId: string,
772+
): void {
773+
const markup = Number(row.markup);
774+
775+
if (Number.isFinite(markup)) {
776+
result.set(externalId, markup);
777+
}
778+
}
779+
691780
/**
692781
* Converts string ids that can represent challenge legacy ids into numbers.
693782
*

0 commit comments

Comments
 (0)