@@ -8,6 +8,114 @@ import { LockAmountDto } from "./dto/lock-amount.dto";
88import { ConsumeAmountDto } from "./dto/consume-amount.dto" ;
99import { MembersLookupService } from "../common/members-lookup.service" ;
1010import SalesforceService from "../common/salesforce.service" ;
11+ import {
12+ ADMIN_ROLE ,
13+ COPILOT_ROLE ,
14+ PROJECT_MANAGER_ROLE ,
15+ TALENT_MANAGER_ROLE ,
16+ TOPCODER_PROJECT_MANAGER_ROLE ,
17+ TOPCODER_TALENT_MANAGER_ROLE ,
18+ } from "../auth/constants" ;
19+
20+ export interface BillingAccountsAuthUser {
21+ role ?: string ;
22+ roles ?: string [ ] | string ;
23+ userId ?: number | string ;
24+ }
25+
26+ const UNRESTRICTED_BILLING_ACCOUNT_READ_ROLES = [
27+ ADMIN_ROLE ,
28+ COPILOT_ROLE ,
29+ TALENT_MANAGER_ROLE ,
30+ TOPCODER_TALENT_MANAGER_ROLE ,
31+ ] ;
32+
33+ const RESTRICTED_PROJECT_MANAGER_READ_ROLES = [
34+ PROJECT_MANAGER_ROLE ,
35+ TOPCODER_PROJECT_MANAGER_ROLE ,
36+ ] ;
37+
38+ /**
39+ * Normalizes authenticated caller roles for case-insensitive comparisons.
40+ *
41+ * Accepts either array-backed `roles` or the legacy comma-delimited `role`
42+ * payload shapes emitted by different token issuers.
43+ *
44+ * @param authUser Authenticated caller context from `req.authUser`.
45+ * @returns Lower-cased role names.
46+ */
47+ function getNormalizedAuthUserRoles (
48+ authUser ?: BillingAccountsAuthUser ,
49+ ) : string [ ] {
50+ const roles = Array . isArray ( authUser ?. roles )
51+ ? authUser . roles
52+ : String ( authUser ?. roles || authUser ?. role || "" )
53+ . split ( "," )
54+ . map ( ( role ) => role . trim ( ) )
55+ . filter ( Boolean ) ;
56+
57+ return roles . map ( ( role ) => role . toLowerCase ( ) ) ;
58+ }
59+
60+ /**
61+ * Resolves the caller user id as a trimmed string when present.
62+ *
63+ * @param authUser Authenticated caller context from `req.authUser`.
64+ * @returns Normalized user id or `undefined` when missing.
65+ */
66+ function getNormalizedAuthUserId (
67+ authUser ?: BillingAccountsAuthUser ,
68+ ) : string | undefined {
69+ if (
70+ typeof authUser ?. userId === "number" &&
71+ Number . isFinite ( authUser . userId )
72+ ) {
73+ return String ( authUser . userId ) ;
74+ }
75+
76+ if ( typeof authUser ?. userId !== "string" ) {
77+ return undefined ;
78+ }
79+
80+ const normalizedUserId = authUser . userId . trim ( ) ;
81+
82+ return normalizedUserId || undefined ;
83+ }
84+
85+ /**
86+ * Returns the enforced access-grant user id for restricted Project Manager
87+ * billing-account reads.
88+ *
89+ * `undefined` means the caller keeps unrestricted read behavior. `null`
90+ * indicates a restricted Project Manager caller without a usable `userId`,
91+ * which should be treated as no accessible accounts.
92+ *
93+ * @param authUser Authenticated caller context from `req.authUser`.
94+ * @returns Enforced user id, `null`, or `undefined`.
95+ */
96+ function resolveRestrictedProjectManagerUserId (
97+ authUser ?: BillingAccountsAuthUser ,
98+ ) : string | null | undefined {
99+ const normalizedRoles = getNormalizedAuthUserRoles ( authUser ) ;
100+ const hasRestrictedProjectManagerRole =
101+ RESTRICTED_PROJECT_MANAGER_READ_ROLES . some ( ( role ) =>
102+ normalizedRoles . includes ( role . toLowerCase ( ) ) ,
103+ ) ;
104+
105+ if ( ! hasRestrictedProjectManagerRole ) {
106+ return undefined ;
107+ }
108+
109+ const hasUnrestrictedReadRole = UNRESTRICTED_BILLING_ACCOUNT_READ_ROLES . some (
110+ ( role ) => normalizedRoles . includes ( role . toLowerCase ( ) ) ,
111+ ) ;
112+
113+ if ( hasUnrestrictedReadRole ) {
114+ return undefined ;
115+ }
116+
117+ return getNormalizedAuthUserId ( authUser ) ?? null ;
118+ }
11119
12120@Injectable ( )
13121export class BillingAccountsService {
@@ -38,7 +146,17 @@ export class BillingAccountsService {
38146 return res ;
39147 }
40148
41- async list ( q : QueryBillingAccountsDto ) {
149+ /**
150+ * Lists billing accounts with optional filtering, sorting, and pagination.
151+ *
152+ * Project Manager callers are constrained to billing accounts granted to
153+ * their own `userId`, regardless of an explicit `userId` query override.
154+ *
155+ * @param q Query filters and pagination controls.
156+ * @param authUser Authenticated caller context from `req.authUser`.
157+ * @returns Paginated billing-account result set.
158+ */
159+ async list ( q : QueryBillingAccountsDto , authUser ?: BillingAccountsAuthUser ) {
42160 const {
43161 clientId,
44162 userId,
@@ -53,13 +171,27 @@ export class BillingAccountsService {
53171 sortBy,
54172 sortOrder = "asc" ,
55173 } = q ;
174+ const restrictedProjectManagerUserId =
175+ resolveRestrictedProjectManagerUserId ( authUser ) ;
176+
177+ if ( restrictedProjectManagerUserId === null ) {
178+ return {
179+ page,
180+ perPage,
181+ total : 0 ,
182+ totalPages : 0 ,
183+ data : [ ] ,
184+ } ;
185+ }
56186
57187 const where : Prisma . BillingAccountWhereInput = {
58188 ...( clientId ? { clientId } : { } ) ,
59189 ...( status ? { status } : { } ) ,
60190 } ;
61191
62- if ( userId ) {
192+ if ( restrictedProjectManagerUserId ) {
193+ where . accessGrants = { some : { userId : restrictedProjectManagerUserId } } ;
194+ } else if ( userId ) {
63195 where . accessGrants = { some : { userId } } ;
64196 }
65197
@@ -175,15 +307,46 @@ export class BillingAccountsService {
175307 } ) ;
176308 }
177309
178- async get ( billingAccountId : number ) {
179- const ba = await this . prisma . billingAccount . findUnique ( {
180- where : { id : billingAccountId } ,
181- include : {
182- client : true ,
183- lockedAmounts : true ,
184- consumedAmounts : true ,
185- } ,
186- } ) ;
310+ /**
311+ * Fetches a single billing account and its budget aggregates.
312+ *
313+ * Project Manager callers can read only billing accounts granted to their own
314+ * `userId`. Missing access is surfaced as not found to avoid leaking account
315+ * existence.
316+ *
317+ * @param billingAccountId Billing-account identifier.
318+ * @param authUser Authenticated caller context from `req.authUser`.
319+ * @returns Billing-account details with locked, consumed, and remaining
320+ * budget totals.
321+ * @throws NotFoundException When the billing account does not exist or the
322+ * caller does not have access to it.
323+ */
324+ async get ( billingAccountId : number , authUser ?: BillingAccountsAuthUser ) {
325+ const restrictedProjectManagerUserId =
326+ resolveRestrictedProjectManagerUserId ( authUser ) ;
327+ const include = {
328+ client : true ,
329+ lockedAmounts : true ,
330+ consumedAmounts : true ,
331+ } ;
332+ const ba =
333+ restrictedProjectManagerUserId === undefined
334+ ? await this . prisma . billingAccount . findUnique ( {
335+ where : { id : billingAccountId } ,
336+ include,
337+ } )
338+ : restrictedProjectManagerUserId === null
339+ ? null
340+ : await this . prisma . billingAccount . findFirst ( {
341+ where : {
342+ id : billingAccountId ,
343+ accessGrants : {
344+ some : { userId : restrictedProjectManagerUserId } ,
345+ } ,
346+ } ,
347+ include,
348+ } ) ;
349+
187350 if ( ! ba )
188351 throw new NotFoundException (
189352 `Billing account with ID ${ billingAccountId } not found` ,
0 commit comments