Skip to content

Commit 32e0867

Browse files
authored
Merge pull request #15 from topcoder-platform/PM-4952-2
PM-4952: Allow PM project billing detail fallback
2 parents 1a744f1 + 4c5882d commit 32e0867

5 files changed

Lines changed: 124 additions & 48 deletions

File tree

README.md

Lines changed: 10 additions & 6 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 Manager, 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`, `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`)
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)
@@ -27,16 +27,20 @@
2727
- Billing-account management endpoints accept `administrator`, `Talent Manager`,
2828
and `Topcoder Talent Manager` JWT roles; read-only billing-account lookups
2929
also continue to allow `copilot`, `Project Manager`, and `Topcoder Project Manager`.
30-
Project Managers are restricted to billing accounts granted to their own
31-
user id on `GET /billing-accounts`. On `GET /billing-accounts/:billingAccountId`,
32-
they can read billing accounts granted to their own user id or assigned to
33-
projects they belong to.
30+
The detail endpoint additionally accepts `Topcoder User` so project-member
31+
callers from Work can be authorized by project membership.
32+
Project-scoped billing-account readers are restricted to billing accounts
33+
granted to their own user id on `GET /billing-accounts`. On
34+
`GET /billing-accounts/:billingAccountId`, Project Manager callers can read
35+
billing accounts granted to their own user id or assigned to active projects
36+
they belong to. Plain `Topcoder User` project-member callers need a
37+
management or copilot project role for the project fallback.
3438
Copilot-only callers receive billing-account responses with `markup` omitted
3539
and `memberPaymentsRemaining` derived server-side as total remaining minus
3640
the total remaining markup amount. Billing-account detail
3741
responses for copilots also include per-line-item `memberPaymentAmount` values
3842
that remove the markup fee from locked and consumed amounts. On detail
39-
responses, copilot, Project Manager, and Talent
43+
responses, copilot, project-scoped, and Talent
4044
Manager callers only receive locked/consumed line items whose challenge or
4145
engagement project maps to an active `project_members` row for their user id.
4246
Line items with unresolved project access are omitted for those callers.

src/auth/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,5 +15,6 @@ export const ADMIN_ROLE = "administrator";
1515
export const COPILOT_ROLE = "copilot";
1616
export const PROJECT_MANAGER_ROLE = "Project Manager";
1717
export const TOPCODER_PROJECT_MANAGER_ROLE = "Topcoder Project Manager";
18+
export const TOPCODER_USER_ROLE = "Topcoder User";
1819
export const TALENT_MANAGER_ROLE = "Talent Manager";
1920
export const TOPCODER_TALENT_MANAGER_ROLE = "Topcoder Talent Manager";

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

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
PROJECT_MANAGER_ROLE,
3131
TALENT_MANAGER_ROLE,
3232
TOPCODER_PROJECT_MANAGER_ROLE,
33+
TOPCODER_USER_ROLE,
3334
TOPCODER_TALENT_MANAGER_ROLE,
3435
} from "../auth/constants";
3536
import type { Request } from "express";
@@ -58,6 +59,11 @@ const BILLING_ACCOUNT_PROJECT_READ_ROLES = [
5859
TOPCODER_PROJECT_MANAGER_ROLE,
5960
];
6061

62+
const BILLING_ACCOUNT_DETAIL_READ_ROLES = [
63+
...BILLING_ACCOUNT_PROJECT_READ_ROLES,
64+
TOPCODER_USER_ROLE,
65+
];
66+
6167
const BILLING_ACCOUNT_MANAGE_ROLES = [
6268
ADMIN_ROLE,
6369
TALENT_MANAGER_ROLE,
@@ -167,14 +173,14 @@ export class BillingAccountsController {
167173

168174
@Get(":billingAccountId")
169175
@UseGuards(RolesGuard, ScopesGuard)
170-
@Roles(...BILLING_ACCOUNT_PROJECT_READ_ROLES)
176+
@Roles(...BILLING_ACCOUNT_DETAIL_READ_ROLES)
171177
@Scopes(SCOPES.READ_BA, SCOPES.ALL_BA)
172178
@ApiOperation(
173179
buildOperationDoc({
174180
summary: "Get a billing account",
175181
description:
176-
"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 projects they belong to. Copilot, Project Manager, 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.",
177-
jwtRoles: BILLING_ACCOUNT_PROJECT_READ_ROLES,
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 projects they actively belong to; 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.",
183+
jwtRoles: BILLING_ACCOUNT_DETAIL_READ_ROLES,
178184
m2mScopes: [SCOPES.READ_BA, SCOPES.ALL_BA],
179185
}),
180186
)

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

Lines changed: 74 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
PROJECT_MANAGER_ROLE,
3030
TALENT_MANAGER_ROLE,
3131
TOPCODER_PROJECT_MANAGER_ROLE,
32+
TOPCODER_USER_ROLE,
3233
TOPCODER_TALENT_MANAGER_ROLE,
3334
} from "../auth/constants";
3435

@@ -50,7 +51,13 @@ const UNRESTRICTED_BILLING_ACCOUNT_READ_ROLES = [
5051
TOPCODER_TALENT_MANAGER_ROLE,
5152
];
5253

53-
const RESTRICTED_PROJECT_MANAGER_READ_ROLES = [
54+
const PROJECT_SCOPED_BILLING_ACCOUNT_READ_ROLES = [
55+
PROJECT_MANAGER_ROLE,
56+
TOPCODER_PROJECT_MANAGER_ROLE,
57+
TOPCODER_USER_ROLE,
58+
];
59+
60+
const PROJECT_BILLING_ACCOUNT_TOPCODER_DETAIL_ROLES = [
5461
PROJECT_MANAGER_ROLE,
5562
TOPCODER_PROJECT_MANAGER_ROLE,
5663
];
@@ -59,6 +66,7 @@ const PROJECT_ACCESS_FILTERED_LINE_ITEM_ROLES = [
5966
COPILOT_ROLE,
6067
PROJECT_MANAGER_ROLE,
6168
TOPCODER_PROJECT_MANAGER_ROLE,
69+
TOPCODER_USER_ROLE,
6270
TALENT_MANAGER_ROLE,
6371
TOPCODER_TALENT_MANAGER_ROLE,
6472
];
@@ -187,26 +195,25 @@ function getNormalizedAuthUserId(
187195
}
188196

189197
/**
190-
* Returns the enforced user id for restricted Project Manager billing-account
191-
* reads.
198+
* Returns the enforced user id for project-scoped billing-account reads.
192199
*
193200
* `undefined` means the caller keeps unrestricted read behavior. `null`
194-
* indicates a restricted Project Manager caller without a usable `userId`,
201+
* indicates a restricted project-scoped caller without a usable `userId`,
195202
* which should be treated as no accessible accounts.
196203
*
197204
* @param authUser Authenticated caller context from `req.authUser`.
198205
* @returns Enforced user id, `null`, or `undefined`.
199206
*/
200-
function resolveRestrictedProjectManagerUserId(
207+
function resolveProjectScopedBillingAccountReadUserId(
201208
authUser?: BillingAccountsAuthUser,
202209
): string | null | undefined {
203210
const normalizedRoles = getNormalizedAuthUserRoles(authUser);
204-
const hasRestrictedProjectManagerRole =
205-
RESTRICTED_PROJECT_MANAGER_READ_ROLES.some((role) =>
211+
const hasProjectScopedBillingAccountReadRole =
212+
PROJECT_SCOPED_BILLING_ACCOUNT_READ_ROLES.some((role) =>
206213
normalizedRoles.includes(role.toLowerCase()),
207214
);
208215

209-
if (!hasRestrictedProjectManagerRole) {
216+
if (!hasProjectScopedBillingAccountReadRole) {
210217
return undefined;
211218
}
212219

@@ -221,13 +228,36 @@ function resolveRestrictedProjectManagerUserId(
221228
return getNormalizedAuthUserId(authUser) ?? null;
222229
}
223230

231+
/**
232+
* Returns whether project fallback access should enforce a management/copilot
233+
* project member role.
234+
*
235+
* Global Project Manager tokens already satisfy Projects API billing-account
236+
* detail permission. They still need active membership on a project using the
237+
* billing account, but the specific project member role does not need to carry
238+
* the detail permission. Plain `Topcoder User` tokens must keep the stricter
239+
* project-role check.
240+
*
241+
* @param authUser Authenticated caller context from `req.authUser`.
242+
* @returns `true` when the fallback must require an allowed project role.
243+
*/
244+
function shouldRequireProjectBillingAccountRoleForFallback(
245+
authUser?: BillingAccountsAuthUser,
246+
): boolean {
247+
const normalizedRoles = getNormalizedAuthUserRoles(authUser);
248+
249+
return !PROJECT_BILLING_ACCOUNT_TOPCODER_DETAIL_ROLES.some((role) =>
250+
normalizedRoles.includes(role.toLowerCase()),
251+
);
252+
}
253+
224254
/**
225255
* Returns the user id to use for project-level line-item filtering.
226256
*
227-
* Administrators keep full line-item visibility. Copilots, Project Managers,
228-
* and Talent Managers only receive line items whose underlying challenge or
229-
* engagement project can be resolved to an active project membership for their
230-
* user id.
257+
* Administrators keep full line-item visibility. Copilots, project-scoped
258+
* billing-account readers, and Talent Managers only receive line items whose
259+
* underlying challenge or engagement project can be resolved to an active
260+
* project membership for their user id.
231261
*
232262
* @param authUser Authenticated caller context from `req.authUser`.
233263
* @returns User id to filter by, `null` when missing, or `undefined` when no
@@ -313,7 +343,7 @@ export class BillingAccountsService {
313343
/**
314344
* Lists billing accounts with optional filtering, sorting, and pagination.
315345
*
316-
* Project Manager callers are constrained to billing accounts granted to
346+
* Project-scoped callers are constrained to billing accounts granted to
317347
* their own `userId`, regardless of an explicit `userId` query override.
318348
*
319349
* @param q Query filters and pagination controls.
@@ -335,10 +365,10 @@ export class BillingAccountsService {
335365
sortBy,
336366
sortOrder = "asc",
337367
} = q;
338-
const restrictedProjectManagerUserId =
339-
resolveRestrictedProjectManagerUserId(authUser);
368+
const projectScopedBillingAccountReadUserId =
369+
resolveProjectScopedBillingAccountReadUserId(authUser);
340370

341-
if (restrictedProjectManagerUserId === null) {
371+
if (projectScopedBillingAccountReadUserId === null) {
342372
return {
343373
page,
344374
perPage,
@@ -353,8 +383,10 @@ export class BillingAccountsService {
353383
...(status ? { status } : {}),
354384
};
355385

356-
if (restrictedProjectManagerUserId) {
357-
where.accessGrants = { some: { userId: restrictedProjectManagerUserId } };
386+
if (projectScopedBillingAccountReadUserId) {
387+
where.accessGrants = {
388+
some: { userId: projectScopedBillingAccountReadUserId },
389+
};
358390
} else if (userId) {
359391
where.accessGrants = { some: { userId } };
360392
}
@@ -477,16 +509,19 @@ export class BillingAccountsService {
477509
* Fetches a single billing account, its normalized budget line items, and
478510
* budget aggregates.
479511
*
480-
* Project Manager callers can read billing accounts granted to their own
481-
* `userId`, or billing accounts assigned to a project they belong to. Missing
482-
* access is surfaced as not found to avoid leaking account existence. Locked
483-
* and consumed line items expose `amount`, `date`, `externalId`,
484-
* `externalType`, and `externalName`; challenge rows also expose the
485-
* deprecated `challengeId` compatibility alias. Copilot-only callers also
486-
* receive `memberPaymentAmount` on each line item so the UI can show the
487-
* payment value without exposing markup. Copilot, Project Manager, and
488-
* Talent Manager callers only receive line items for projects they belong to;
489-
* unresolved project access hides the line item.
512+
* Project-scoped callers can read billing accounts granted to their own
513+
* `userId`, or billing accounts assigned to an active project they belong to.
514+
* Plain `Topcoder User` callers need an allowed management/copilot project
515+
* role for that project fallback, while global Project Manager callers only
516+
* need active project membership. Missing access is surfaced as not found to
517+
* avoid leaking account existence. Locked and consumed line items expose
518+
* `amount`, `date`, `externalId`, `externalType`, and `externalName`;
519+
* challenge rows also expose the deprecated `challengeId` compatibility
520+
* alias. Copilot-only callers also receive `memberPaymentAmount` on each
521+
* line item so the UI can show the payment value without exposing markup.
522+
* Copilot, project-scoped, and Talent Manager callers only receive line
523+
* items for projects they belong to; unresolved project access hides the
524+
* line item.
490525
*
491526
* @param billingAccountId Billing-account identifier.
492527
* @param authUser Authenticated caller context from `req.authUser`.
@@ -496,36 +531,40 @@ export class BillingAccountsService {
496531
* caller does not have access to it.
497532
*/
498533
async get(billingAccountId: number, authUser?: BillingAccountsAuthUser) {
499-
const restrictedProjectManagerUserId =
500-
resolveRestrictedProjectManagerUserId(authUser);
534+
const projectScopedBillingAccountReadUserId =
535+
resolveProjectScopedBillingAccountReadUserId(authUser);
501536
const include = {
502537
client: true,
503538
lockedAmounts: true,
504539
consumedAmounts: true,
505540
};
506541
let ba =
507-
restrictedProjectManagerUserId === undefined
542+
projectScopedBillingAccountReadUserId === undefined
508543
? await this.prisma.billingAccount.findUnique({
509544
where: { id: billingAccountId },
510545
include,
511546
})
512-
: restrictedProjectManagerUserId === null
547+
: projectScopedBillingAccountReadUserId === null
513548
? null
514549
: await this.prisma.billingAccount.findFirst({
515550
where: {
516551
id: billingAccountId,
517552
accessGrants: {
518-
some: { userId: restrictedProjectManagerUserId },
553+
some: { userId: projectScopedBillingAccountReadUserId },
519554
},
520555
},
521556
include,
522557
});
523558

524-
if (!ba && restrictedProjectManagerUserId) {
559+
if (!ba && projectScopedBillingAccountReadUserId) {
525560
const hasProjectBillingAccountAccess =
526561
await this.externalBudgetEntryLookup.hasProjectBillingAccountAccess(
527562
billingAccountId,
528-
restrictedProjectManagerUserId,
563+
projectScopedBillingAccountReadUserId,
564+
{
565+
requireAllowedProjectRole:
566+
shouldRequireProjectBillingAccountRoleForFallback(authUser),
567+
},
529568
);
530569

531570
if (hasProjectBillingAccountAccess) {

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

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,20 @@ interface ProjectBillingAccountAccessRow {
3636
hasAccess: boolean;
3737
}
3838

39+
interface ProjectBillingAccountAccessOptions {
40+
requireAllowedProjectRole?: boolean;
41+
}
42+
43+
const PROJECT_BILLING_ACCOUNT_DETAIL_ROLES = [
44+
"manager",
45+
"account_manager",
46+
"account_executive",
47+
"project_manager",
48+
"program_manager",
49+
"solution_architect",
50+
"copilot",
51+
];
52+
3953
/**
4054
* Resolves budget-entry external metadata from service-owned persistence stores.
4155
*
@@ -193,19 +207,23 @@ export class ExternalBudgetEntryLookupService implements OnModuleDestroy {
193207
/**
194208
* Checks whether a user belongs to a project using a billing account.
195209
*
196-
* Project Managers may open billing-account details from Work project pages
197-
* even when the legacy billing-account resource grant was not imported for
198-
* that account. This lookup validates access against non-deleted projects
199-
* and non-deleted project membership in projects-api-v6.
210+
* Project-scoped Work users may open billing-account details from project
211+
* pages even when the legacy billing-account resource grant was not imported
212+
* for that account. This lookup validates access against non-deleted
213+
* projects and non-deleted project membership in projects-api-v6. Callers
214+
* without a billing-account Topcoder role can require the membership to be a
215+
* management/copilot project role.
200216
*
201217
* @param billingAccountId Topcoder billing-account id from the detail route.
202218
* @param userId Topcoder user id from the authenticated caller.
219+
* @param options Access options for project-role enforcement.
203220
* @returns `true` when the user has non-deleted project membership for a
204221
* project assigned to the billing account; otherwise `false`.
205222
*/
206223
async hasProjectBillingAccountAccess(
207224
billingAccountId: number,
208225
userId: string,
226+
options: ProjectBillingAccountAccessOptions = {},
209227
): Promise<boolean> {
210228
const normalizedBillingAccountId =
211229
this.normalizeNumericTextId(billingAccountId);
@@ -221,6 +239,13 @@ export class ExternalBudgetEntryLookupService implements OnModuleDestroy {
221239
return false;
222240
}
223241

242+
const projectRoleFilter =
243+
options.requireAllowedProjectRole === false
244+
? Prisma.empty
245+
: Prisma.sql`AND project_member."role"::text IN (${Prisma.join(
246+
PROJECT_BILLING_ACCOUNT_DETAIL_ROLES,
247+
)})`;
248+
224249
try {
225250
const rows = await client.$queryRaw<ProjectBillingAccountAccessRow[]>(
226251
Prisma.sql`
@@ -233,6 +258,7 @@ export class ExternalBudgetEntryLookupService implements OnModuleDestroy {
233258
)}
234259
AND project."deletedAt" IS NULL
235260
AND project_member."userId" = ${BigInt(normalizedUserId)}
261+
${projectRoleFilter}
236262
AND project_member."deletedAt" IS NULL
237263
LIMIT 1
238264
`,

0 commit comments

Comments
 (0)