Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion src/billing-accounts/billing-accounts.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
}),
Expand Down
155 changes: 130 additions & 25 deletions src/billing-accounts/billing-accounts.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ interface BudgetAmountLineItem {
updatedAt: Date;
}

type BudgetLineItemStatus = "locked" | "consumed";

interface BillingAccountBudgetLockRow {
budget: Prisma.Decimal;
}
Expand Down Expand Up @@ -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`.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, number>,
): 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<string, number>,
): 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(
Expand Down Expand Up @@ -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,
Expand All @@ -1333,6 +1434,10 @@ export class BillingAccountsService {
}

return lineItems.map((lineItem) => {
if (lineItem.memberPaymentAmount !== undefined) {
return lineItem;
}

const memberPaymentAmount = this.calculateMemberPaymentAmount(
lineItem.amount,
markup,
Expand Down
89 changes: 89 additions & 0 deletions src/billing-accounts/external-budget-entry-lookup.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<Map<string, number>> {
const result = new Map<string, number>();
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<ChallengeBillingMarkupRow[]>(
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<ChallengeBillingMarkupRow[]>(
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.
*
Expand Down Expand Up @@ -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<string, number>,
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.
*
Expand Down
Loading