Skip to content

Commit 7c3e000

Browse files
authored
Merge pull request #16 from topcoder-platform/PM-4952-3
PM-4952: Allow PM billing detail by project assignment
2 parents 32e0867 + b0fd840 commit 7c3e000

4 files changed

Lines changed: 98 additions & 28 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,8 @@
3232
Project-scoped billing-account readers are restricted to billing accounts
3333
granted to their own user id on `GET /billing-accounts`. On
3434
`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
35+
billing accounts granted to their own user id or assigned to non-deleted
36+
projects. Plain `Topcoder User` project-member callers need a
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

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 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.",
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.",
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: 35 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -229,28 +229,39 @@ function resolveProjectScopedBillingAccountReadUserId(
229229
}
230230

231231
/**
232-
* Returns whether project fallback access should enforce a management/copilot
233-
* project member role.
232+
* Returns whether the caller has a Topcoder role that can read project billing
233+
* account details in Projects API.
234234
*
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.
235+
* These global roles can read a project billing account when the account is
236+
* assigned to a non-deleted project. Plain `Topcoder User` project members
237+
* must keep the stricter project membership role check.
240238
*
241239
* @param authUser Authenticated caller context from `req.authUser`.
242-
* @returns `true` when the fallback must require an allowed project role.
240+
* @returns `true` when the caller can use project-assigned billing-account access.
243241
*/
244-
function shouldRequireProjectBillingAccountRoleForFallback(
242+
function hasProjectBillingAccountTopcoderDetailRole(
245243
authUser?: BillingAccountsAuthUser,
246244
): boolean {
247245
const normalizedRoles = getNormalizedAuthUserRoles(authUser);
248246

249-
return !PROJECT_BILLING_ACCOUNT_TOPCODER_DETAIL_ROLES.some((role) =>
247+
return PROJECT_BILLING_ACCOUNT_TOPCODER_DETAIL_ROLES.some((role) =>
250248
normalizedRoles.includes(role.toLowerCase()),
251249
);
252250
}
253251

252+
/**
253+
* Returns whether project fallback access should enforce a management/copilot
254+
* project member role.
255+
*
256+
* @param authUser Authenticated caller context from `req.authUser`.
257+
* @returns `true` when the fallback must require an allowed project role.
258+
*/
259+
function shouldRequireProjectBillingAccountRoleForFallback(
260+
authUser?: BillingAccountsAuthUser,
261+
): boolean {
262+
return !hasProjectBillingAccountTopcoderDetailRole(authUser);
263+
}
264+
254265
/**
255266
* Returns the user id to use for project-level line-item filtering.
256267
*
@@ -510,11 +521,11 @@ export class BillingAccountsService {
510521
* budget aggregates.
511522
*
512523
* 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
524+
* `userId`, or billing accounts assigned to a non-deleted project. Plain
525+
* `Topcoder User` callers need an allowed management/copilot project role
526+
* for that project fallback, while global Project Manager callers can use
527+
* the project assignment directly. Missing access is surfaced as not found
528+
* to avoid leaking account existence. Locked and consumed line items expose
518529
* `amount`, `date`, `externalId`, `externalType`, and `externalName`;
519530
* challenge rows also expose the deprecated `challengeId` compatibility
520531
* alias. Copilot-only callers also receive `memberPaymentAmount` on each
@@ -533,6 +544,8 @@ export class BillingAccountsService {
533544
async get(billingAccountId: number, authUser?: BillingAccountsAuthUser) {
534545
const projectScopedBillingAccountReadUserId =
535546
resolveProjectScopedBillingAccountReadUserId(authUser);
547+
const hasTopcoderProjectBillingDetailRole =
548+
hasProjectBillingAccountTopcoderDetailRole(authUser);
536549
const include = {
537550
client: true,
538551
lockedAmounts: true,
@@ -556,12 +569,17 @@ export class BillingAccountsService {
556569
include,
557570
});
558571

559-
if (!ba && projectScopedBillingAccountReadUserId) {
572+
if (
573+
!ba &&
574+
(projectScopedBillingAccountReadUserId ||
575+
hasTopcoderProjectBillingDetailRole)
576+
) {
560577
const hasProjectBillingAccountAccess =
561578
await this.externalBudgetEntryLookup.hasProjectBillingAccountAccess(
562579
billingAccountId,
563-
projectScopedBillingAccountReadUserId,
580+
projectScopedBillingAccountReadUserId ?? undefined,
564581
{
582+
allowAnyAssignedProject: hasTopcoderProjectBillingDetailRole,
565583
requireAllowedProjectRole:
566584
shouldRequireProjectBillingAccountRoleForFallback(authUser),
567585
},

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

Lines changed: 60 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ interface ProjectBillingAccountAccessRow {
3737
}
3838

3939
interface ProjectBillingAccountAccessOptions {
40+
allowAnyAssignedProject?: boolean;
4041
requireAllowedProjectRole?: boolean;
4142
}
4243

@@ -210,26 +211,34 @@ export class ExternalBudgetEntryLookupService implements OnModuleDestroy {
210211
* Project-scoped Work users may open billing-account details from project
211212
* pages even when the legacy billing-account resource grant was not imported
212213
* 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.
214+
* projects in projects-api-v6. Global billing-account Topcoder roles can
215+
* allow any non-deleted project assigned to the account, while plain project
216+
* members require non-deleted membership. Callers without a billing-account
217+
* Topcoder role can require the membership to be a management/copilot
218+
* project role.
216219
*
217220
* @param billingAccountId Topcoder billing-account id from the detail route.
218-
* @param userId Topcoder user id from the authenticated caller.
221+
* @param userId Topcoder user id from the authenticated caller; optional
222+
* when global role access allows any assigned project.
219223
* @param options Access options for project-role enforcement.
220224
* @returns `true` when the user has non-deleted project membership for a
221-
* project assigned to the billing account; otherwise `false`.
225+
* project assigned to the billing account, or when any assigned project is
226+
* allowed and exists; otherwise `false`.
222227
*/
223228
async hasProjectBillingAccountAccess(
224229
billingAccountId: number,
225-
userId: string,
230+
userId?: string,
226231
options: ProjectBillingAccountAccessOptions = {},
227232
): Promise<boolean> {
228233
const normalizedBillingAccountId =
229234
this.normalizeNumericTextId(billingAccountId);
230235
const normalizedUserId = this.normalizeNumericTextId(userId);
236+
const allowAnyAssignedProject = options.allowAnyAssignedProject === true;
231237

232-
if (!normalizedBillingAccountId || !normalizedUserId) {
238+
if (
239+
!normalizedBillingAccountId ||
240+
(!allowAnyAssignedProject && !normalizedUserId)
241+
) {
233242
return false;
234243
}
235244

@@ -239,6 +248,18 @@ export class ExternalBudgetEntryLookupService implements OnModuleDestroy {
239248
return false;
240249
}
241250

251+
if (allowAnyAssignedProject) {
252+
return this.hasBillingAccountAssignedProject(
253+
client,
254+
normalizedBillingAccountId,
255+
);
256+
}
257+
258+
const membershipUserId = normalizedUserId;
259+
if (!membershipUserId) {
260+
return false;
261+
}
262+
242263
const projectRoleFilter =
243264
options.requireAllowedProjectRole === false
244265
? Prisma.empty
@@ -257,7 +278,7 @@ export class ExternalBudgetEntryLookupService implements OnModuleDestroy {
257278
normalizedBillingAccountId,
258279
)}
259280
AND project."deletedAt" IS NULL
260-
AND project_member."userId" = ${BigInt(normalizedUserId)}
281+
AND project_member."userId" = ${BigInt(membershipUserId)}
261282
${projectRoleFilter}
262283
AND project_member."deletedAt" IS NULL
263284
LIMIT 1
@@ -273,6 +294,37 @@ export class ExternalBudgetEntryLookupService implements OnModuleDestroy {
273294
}
274295
}
275296

297+
/**
298+
* Checks whether a billing account is assigned to any non-deleted project.
299+
*
300+
* @param client Projects API Prisma client.
301+
* @param billingAccountId Normalized numeric billing-account id.
302+
* @returns `true` when at least one non-deleted project uses the account.
303+
*/
304+
private async hasBillingAccountAssignedProject(
305+
client: PrismaClient,
306+
billingAccountId: string,
307+
): Promise<boolean> {
308+
try {
309+
const rows = await client.$queryRaw<ProjectBillingAccountAccessRow[]>(
310+
Prisma.sql`
311+
SELECT true AS "hasAccess"
312+
FROM projects project
313+
WHERE project."billingAccountId" = ${BigInt(billingAccountId)}
314+
AND project."deletedAt" IS NULL
315+
LIMIT 1
316+
`,
317+
);
318+
319+
return rows.some((row) => row.hasAccess);
320+
} catch (error) {
321+
this.logger.warn(
322+
`Failed to resolve project billing-account assignment: ${this.getErrorMessage(error)}`,
323+
);
324+
return false;
325+
}
326+
}
327+
276328
/**
277329
* Disconnects optional lookup clients created for external stores.
278330
*

0 commit comments

Comments
 (0)