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: 7 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,17 @@ DATABASE_URL="postgresql://postgres:postgres@localhost:5432/topcoder-services?sc
# Example: postgresql://postgres:postgres@localhost:5432/topcoder-services?schema=members
MEMBER_DB_URL=""

# Optional lookup stores used to resolve billing-account line-item names.
# If unset, line items are still returned with externalName omitted.
# Optional lookup stores used to resolve billing-account line-item names and
# project access for line-item filtering.
# If name lookups are unset, line items are still returned with externalName omitted.
# If PROJECTS_DB_URL or the relevant source DB is unset for a project-filtered
# caller, line items whose access cannot be resolved are omitted.
CHALLENGE_DB_URL="postgresql://postgres:postgres@localhost:5432/topcoder-services?schema=challenges"
ENGAGEMENTS_DB_URL="postgresql://postgres:postgres@localhost:5432/engagements"

# Historical engagement-payment backfill sources.
# FINANCE_DB_URL points to tc-finance-api, PROJECTS_DB_URL points to projects-api-v6.
# FINANCE_DB_URL points to tc-finance-api. PROJECTS_DB_URL points to
# projects-api-v6 and is also used by detail line-item access filtering.
FINANCE_DB_URL="postgresql://postgres:postgres@localhost:5432/topcoder-services?schema=finance"
PROJECTS_DB_URL="postgresql://postgres:postgres@localhost:5432/topcoder-services?schema=projects"

Expand Down
6 changes: 5 additions & 1 deletion 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)
- `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 Manager, and Talent Manager callers only receive line items for projects they belong to)
- `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 All @@ -29,6 +29,10 @@
also continue to allow `copilot`, `Project Manager`, and `Topcoder Project Manager`.
Project Managers are restricted to billing accounts granted to their own
user id on `GET /billing-accounts` and `GET /billing-accounts/:billingAccountId`.
On billing-account detail responses, copilot, Project Manager, 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.
Role checks are case-insensitive so mixed token casing does not block access.
- Configure env: `AUTH_SECRET` or `AUTH0_URL/AUDIENCE/ISSUER` as needed.

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 @@ -173,7 +173,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 only billing accounts granted to them.",
"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 only billing accounts granted to them. Copilot, Project Manager, and Talent Manager callers only receive locked/consumed line items for projects they belong to.",
jwtRoles: BILLING_ACCOUNT_PROJECT_READ_ROLES,
m2mScopes: [SCOPES.READ_BA, SCOPES.ALL_BA],
}),
Expand Down
118 changes: 113 additions & 5 deletions src/billing-accounts/billing-accounts.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,14 @@ const RESTRICTED_PROJECT_MANAGER_READ_ROLES = [
TOPCODER_PROJECT_MANAGER_ROLE,
];

const PROJECT_ACCESS_FILTERED_LINE_ITEM_ROLES = [
COPILOT_ROLE,
PROJECT_MANAGER_ROLE,
TOPCODER_PROJECT_MANAGER_ROLE,
TALENT_MANAGER_ROLE,
TOPCODER_TALENT_MANAGER_ROLE,
];

const BUDGET_AMOUNT_DECIMAL_PLACES = 4;

interface BudgetAmountLineItem {
Expand Down Expand Up @@ -92,6 +100,11 @@ interface NormalizedEngagementConsume {
externalType: "ENGAGEMENT";
}

interface ProjectAccessFilteredLineItems {
lockedAmounts: BudgetAmountLineItem[];
consumedAmounts: BudgetAmountLineItem[];
}

/**
* Normalizes authenticated caller roles for case-insensitive comparisons.
*
Expand Down Expand Up @@ -183,6 +196,40 @@ function resolveRestrictedProjectManagerUserId(
return getNormalizedAuthUserId(authUser) ?? null;
}

/**
* Returns the user id to use for project-level line-item filtering.
*
* Administrators keep full line-item visibility. Copilots, Project Managers,
* and Talent Managers only receive line items whose underlying challenge or
* engagement project can be resolved to an active project membership for their
* user id.
*
* @param authUser Authenticated caller context from `req.authUser`.
* @returns User id to filter by, `null` when missing, or `undefined` when no
* filtering is required.
*/
function resolveProjectAccessFilteredLineItemUserId(
authUser?: BillingAccountsAuthUser,
): string | null | undefined {
const normalizedRoles = getNormalizedAuthUserRoles(authUser);
const hasAdminRole = normalizedRoles.includes(ADMIN_ROLE.toLowerCase());

if (hasAdminRole) {
return undefined;
}

const requiresProjectAccessFiltering =
PROJECT_ACCESS_FILTERED_LINE_ITEM_ROLES.some((role) =>
normalizedRoles.includes(role.toLowerCase()),
);

if (!requiresProjectAccessFiltering) {
return undefined;
}

return getNormalizedAuthUserId(authUser) ?? null;
}

@Injectable()
export class BillingAccountsService {
constructor(
Expand Down Expand Up @@ -382,7 +429,9 @@ export class BillingAccountsService {
* `userId`. 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.
* the deprecated `challengeId` compatibility alias. Copilot, Project Manager,
* 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 @@ -431,23 +480,31 @@ export class BillingAccountsService {
0,
);
const remaining = Number(ba.budget) - consumed - locked;
const { lockedAmounts, consumedAmounts } =
await this.filterBudgetLineItemsForProjectAccess(
{
lockedAmounts: ba.lockedAmounts,
consumedAmounts: ba.consumedAmounts,
},
authUser,
);
const externalNames = await this.externalBudgetEntryLookup.getExternalNames(
[
...ba.lockedAmounts.map((lineItem) =>
...lockedAmounts.map((lineItem) =>
this.toBudgetEntryReference(lineItem),
),
...ba.consumedAmounts.map((lineItem) =>
...consumedAmounts.map((lineItem) =>
this.toBudgetEntryReference(lineItem),
),
],
);

return {
...ba,
lockedAmounts: ba.lockedAmounts.map((lineItem) =>
lockedAmounts: lockedAmounts.map((lineItem) =>
this.serializeBudgetLineItem(lineItem, externalNames),
),
consumedAmounts: ba.consumedAmounts.map((lineItem) =>
consumedAmounts: consumedAmounts.map((lineItem) =>
this.serializeBudgetLineItem(lineItem, externalNames),
),
lockedBudget: locked,
Expand Down Expand Up @@ -983,6 +1040,57 @@ export class BillingAccountsService {
};
}

/**
* Filters locked and consumed detail rows by project access when required.
*
* Caller roles that can view billing accounts across multiple projects still
* need project-level protection on individual payment rows. This helper keeps
* unrestricted callers unchanged, hides all rows when a restricted caller has
* no usable user id, and otherwise keeps only references whose project access
* is proven by the external lookup service.
*
* @param lineItems Locked and consumed budget rows from one billing account.
* @param authUser Authenticated caller context from `req.authUser`.
* @returns Filtered locked and consumed rows for the response detail arrays.
*/
private async filterBudgetLineItemsForProjectAccess(
lineItems: ProjectAccessFilteredLineItems,
authUser?: BillingAccountsAuthUser,
): Promise<ProjectAccessFilteredLineItems> {
const userId = resolveProjectAccessFilteredLineItemUserId(authUser);

if (userId === undefined) {
return lineItems;
}

if (userId === null) {
return { lockedAmounts: [], consumedAmounts: [] };
}

const accessibleReferenceKeys =
await this.externalBudgetEntryLookup.getProjectAccessibleReferenceKeys(
[
...lineItems.lockedAmounts.map((lineItem) =>
this.toBudgetEntryReference(lineItem),
),
...lineItems.consumedAmounts.map((lineItem) =>
this.toBudgetEntryReference(lineItem),
),
],
userId,
);

const canViewLineItem = (lineItem: BudgetAmountLineItem) =>
accessibleReferenceKeys.has(
getBudgetEntryReferenceKey(this.toBudgetEntryReference(lineItem)),
);

return {
lockedAmounts: lineItems.lockedAmounts.filter(canViewLineItem),
consumedAmounts: lineItems.consumedAmounts.filter(canViewLineItem),
};
}

/**
* Shapes a budget row for API details responses.
*
Expand Down
Loading
Loading