Skip to content
4 changes: 2 additions & 2 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ workflows:
- UnitTests
filters:
branches:
only: ['develop', 'connect-performance-testing']
only: ['develop', 'connect-performance-testing', 'feature/get-markup-from-billing-account']
- deployProd:
context : org-global
requires:
Expand All @@ -167,4 +167,4 @@ workflows:
- deployProd
- Connect-Performance-Testing:
requires:
- Hold [Performance-Testing]
- Hold [Performance-Testing]
1 change: 1 addition & 0 deletions src/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,7 @@ export const M2M_SCOPES = {
WRITE: 'write:projects',
READ_USER_BILLING_ACCOUNTS: 'read:user-billing-accounts',
WRITE_PROJECTS_BILLING_ACCOUNTS: 'write:projects-billing-accounts',
READ_PROJECT_BILLING_ACCOUNT_DETAILS: 'read:project-billing-account-details',
},
PROJECT_MEMBERS: {
ALL: 'all:project-members',
Expand Down
21 changes: 20 additions & 1 deletion src/permissions/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,14 @@ const SCOPES_PROJECTS_WRITE = [
*/
const SCOPES_PROJECTS_READ_AVL_BILLING_ACCOUNTS = [
M2M_SCOPES.CONNECT_PROJECT_ADMIN,
M2M_SCOPES.READ_USER_BILLING_ACCOUNTS,
M2M_SCOPES.PROJECTS.READ_USER_BILLING_ACCOUNTS,
];

/**
* M2M scopes to "read" available Billing Accounts for the project
*/
const SCOPES_PROJECTS_READ_BILLING_ACCOUNT_DETAILS = [
M2M_SCOPES.PROJECTS.READ_PROJECT_BILLING_ACCOUNT_DETAILS,
];

/**
Expand Down Expand Up @@ -277,6 +284,18 @@ export const PERMISSION = { // eslint-disable-line import/prefer-default-export
scopes: SCOPES_PROJECTS_READ_AVL_BILLING_ACCOUNTS,
},

/*
* Project Invite
*/
READ_PROJECT_BILLING_ACCOUNT_DETAILS: {
meta: {
title: 'Read details of billing accounts - only allowed to m2m calls',
group: 'Project Billing Accounts',
description: 'Who can view the details of the Billing Account attached to the project',
},
scopes: SCOPES_PROJECTS_READ_BILLING_ACCOUNT_DETAILS,
},

/*
* Project Member
*/
Expand Down
4 changes: 4 additions & 0 deletions src/permissions/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ module.exports = () => {
PERMISSION.READ_AVL_PROJECT_BILLING_ACCOUNTS,
]));

Authorizer.setPolicy('projectBillingAccount.view', generalPermission([
PERMISSION.READ_PROJECT_BILLING_ACCOUNT_DETAILS,
]));

Authorizer.setPolicy('projectMember.create', generalPermission([
PERMISSION.CREATE_PROJECT_MEMBER_OWN,
PERMISSION.CREATE_PROJECT_MEMBER_NOT_OWN,
Expand Down
54 changes: 54 additions & 0 deletions src/routes/billingAccounts/get.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import _ from 'lodash';
import validate from 'express-validation';
import Joi from 'joi';
import { middleware as tcMiddleware } from 'tc-core-library-js';
import SalesforceService from '../../services/salesforceService';
import models from '../../models';

/**
* API to get project attachments.
*
*/

const permissions = tcMiddleware.permissions;

const schema = {
params: {
projectId: Joi.number().integer().positive().required(),
},
};

module.exports = [
validate(schema),
permissions('projectBillingAccount.view'),
async (req, res, next) => {
const projectId = _.parseInt(req.params.projectId);
try {
const project = await models.Project.findOne({
where: { id: projectId },
attributes: ['id', 'billingAccountId'],
raw: true,
});
if (!project) {
const err = new Error(`Project with id "${projectId}" not found`);
err.status = 404;
throw err;
}
const billingAccountId = project.billingAccountId;
if (!billingAccountId) {
const err = new Error('Billing Account not found');
err.status = 404;
throw err;
}
const { accessToken, instanceUrl } = await SalesforceService.authenticate();
// eslint-disable-next-line
const sql = `SELECT TopCoder_Billing_Account_Id__c, Mark_Up__c from Topcoder_Billing_Account__c tba where TopCoder_Billing_Account_Id__c='${billingAccountId}'`;
req.log.debug(sql);
const billingAccount = await SalesforceService.queryBillingAccount(sql, accessToken, instanceUrl, req.log);
res.json(billingAccount);
} catch (error) {
req.log.error(error);
next(error);
}
},
];
167 changes: 167 additions & 0 deletions src/routes/billingAccounts/get.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
/* eslint-disable no-unused-expressions */
import chai from 'chai';
import request from 'supertest';
import sinon from 'sinon';

import models from '../../models';
import server from '../../app';
import testUtil from '../../tests/util';
import SalesforceService from '../../services/salesforceService';

chai.should();

// demo data which might be returned by the `SalesforceService.query`
const billingAccountData = {
tcBillingAccountId: 123123,
markup: 50,
};

describe('Project Billing Accounts list', () => {
let project1;
let project2;
let salesforceAuthenticate;
let salesforceQuery;

beforeEach((done) => {
testUtil.clearDb()
.then(() => testUtil.clearES())
.then(() => models.Project.create({
type: 'generic',
directProjectId: 1,
billingAccountId: 1,
name: 'test1',
description: 'test project1',
status: 'draft',
details: {},
createdBy: 1,
updatedBy: 1,
lastActivityAt: 1,
lastActivityUserId: '1',
}).then((p) => {
project1 = p;
// create members
return models.ProjectMember.create({
userId: testUtil.userIds.copilot,
projectId: project1.id,
role: 'copilot',
isPrimary: true,
createdBy: 1,
updatedBy: 1,
}).then(() => models.ProjectMember.create({
userId: testUtil.userIds.member,
projectId: project1.id,
role: 'customer',
isPrimary: false,
createdBy: 1,
updatedBy: 1,
}));
})).then(() => models.Project.create({
type: 'generic',
directProjectId: 1,
billingAccountId: null, // do not define billingAccountId
name: 'test1',
description: 'test project1',
status: 'draft',
details: {},
createdBy: 1,
updatedBy: 1,
lastActivityAt: 1,
lastActivityUserId: '1',
}).then((p) => {
project2 = p;
// create members
return models.ProjectMember.create({
userId: testUtil.userIds.copilot,
projectId: project2.id,
role: 'copilot',
isPrimary: true,
createdBy: 1,
updatedBy: 1,
}).then(() => models.ProjectMember.create({
userId: testUtil.userIds.member,
projectId: project2.id,
role: 'customer',
isPrimary: false,
createdBy: 1,
updatedBy: 1,
}));
}))
.then(() => {
salesforceAuthenticate = sinon.stub(SalesforceService, 'authenticate', () => Promise.resolve({
accessToken: 'mock',
instanceUrl: 'mock_url',
}));
// eslint-disable-next-line
salesforceQuery = sinon.stub(SalesforceService, 'queryBillingAccount', () => Promise.resolve(billingAccountData));
done();
});
});

afterEach((done) => {
salesforceAuthenticate.restore();
salesforceQuery.restore();
done();
});

after((done) => {
testUtil.clearDb(done);
});

describe('Get /projects/{id}/billingAccounts', () => {
it('should return 403 for anonymous user', (done) => {
request(server)
.get(`/v5/projects/${project1.id}/billingAccount`)
.expect(403, done);
});

it('should return 403 for admin', (done) => {
request(server)
.get(`/v5/projects/${project1.id}/billingAccount`)
.set({
Authorization: `Bearer ${testUtil.jwts.admin}`,
})
.send()
.expect(403, done);
});

it('should return 404 if the project is not found', (done) => {
request(server)
.get('/v5/projects/11223344/billingAccount')
.set({
Authorization: `Bearer ${testUtil.m2m['read:project-billing-account-details']}`,
})
.send()
.expect(404, done);
});

it('should return 404 if billing account is not defined in the project', (done) => {
request(server)
.get(`/v5/projects/${project2.id}/billingAccount`)
.set({
Authorization: `Bearer ${testUtil.m2m['read:project-billing-account-details']}`,
})
.send()
.expect(404, done);
});

it('should return billing account details using M2M token with "read:project-billing-account-details" scope',
(done) => {
request(server)
.get(`/v5/projects/${project1.id}/billingAccount`)
.set({
Authorization: `Bearer ${testUtil.m2m['read:project-billing-account-details']}`,
})
.send()
.expect(200)
.end((err, res) => {
if (err) {
done(err);
} else {
const resJson = res.body;
resJson.should.deep.equal(billingAccountData);
done();
}
});
});
});
});
4 changes: 2 additions & 2 deletions src/routes/billingAccounts/list.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,10 @@ module.exports = [
try {
const { accessToken, instanceUrl } = await SalesforceService.authenticate();
// eslint-disable-next-line
const sql = `SELECT Topcoder_Billing_Account__r.id, Topcoder_Billing_Account__r.TopCoder_Billing_Account_Id__c, Topcoder_Billing_Account__r.Billing_Account_Name__c, Topcoder_Billing_Account__r.Start_Date__c, Topcoder_Billing_Account__r.End_Date__c from Topcoder_Billing_Account_Resource__c tbar where UserID__c='${userId}'`;
const sql = `SELECT Topcoder_Billing_Account__r.id, Topcoder_Billing_Account__r.TopCoder_Billing_Account_Id__c, Topcoder_Billing_Account__r.Billing_Account_Name__c, Topcoder_Billing_Account__r.Start_Date__c, Topcoder_Billing_Account__r.End_Date__c from Topcoder_Billing_Account_Resource__c tbar where Topcoder_Billing_Account__r.Active__c=true AND UserID__c='${userId}'`;
// and Topcoder_Billing_Account__r.TC_Connect_Project_ID__c='${projectId}'
req.log.debug(sql);
const billingAccounts = await SalesforceService.query(sql, accessToken, instanceUrl, req.log);
const billingAccounts = await SalesforceService.queryUserBillingAccounts(sql, accessToken, instanceUrl, req.log);
res.json(billingAccounts);
} catch (error) {
req.log.error(error);
Expand Down
3 changes: 2 additions & 1 deletion src/routes/billingAccounts/list.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,8 @@ describe('Project Billing Accounts list', () => {
accessToken: 'mock',
instanceUrl: 'mock_url',
}));
salesforceQuery = sinon.stub(SalesforceService, 'query', () => Promise.resolve(billingAccountsData));
// eslint-disable-next-line
salesforceQuery = sinon.stub(SalesforceService, 'queryUserBillingAccounts', () => Promise.resolve(billingAccountsData));
done();
});
});
Expand Down
2 changes: 2 additions & 0 deletions src/routes/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,8 @@ router.route('/v5/projects/:projectId(\\d+)/scopeChangeRequests/:requestId(\\d+)

router.route('/v5/projects/:projectId(\\d+)/billingAccounts')
.get(require('./billingAccounts/list'));
router.route('/v5/projects/:projectId(\\d+)/billingAccount')
.get(require('./billingAccounts/get'));

router.route('/v5/projects/:projectId(\\d+)/members')
.get(require('./projectMembers/list'))
Expand Down
31 changes: 30 additions & 1 deletion src/services/salesforceService.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ class SalesforceService {
* @param {Object} logger logger to be used for logging
* @returns {{totalSize: Number, done: Boolean, records: Array}} the result
*/
static query(sql, accessToken, instanceUrl, logger) {
static queryUserBillingAccounts(sql, accessToken, instanceUrl, logger) {
return axios({
url: `${instanceUrl}/services/data/v37.0/query?q=${sql}`,
method: 'get',
Expand All @@ -77,6 +77,35 @@ class SalesforceService {
return billingAccounts;
});
}

/**
* Run the query statement
* @param {String} sql the Saleforce sql statement
* @param {String} accessToken the access token
* @param {String} instanceUrl the salesforce instance url
* @param {Object} logger logger to be used for logging
* @returns {{totalSize: Number, done: Boolean, records: Array}} the result
*/
static queryBillingAccount(sql, accessToken, instanceUrl, logger) {
return axios({
url: `${instanceUrl}/services/data/v37.0/query?q=${sql}`,
method: 'get',
headers: { authorization: `Bearer ${accessToken}` },
}).then((res) => {
if (logger) {
logger.debug(_.get(res, 'data.records', []));
}
const billingAccounts = _.get(res, 'data.records', []).map(o => ({
tcBillingAccountId: util.parseIntStrictly(
_.get(o, 'TopCoder_Billing_Account_Id__c'),
10,
null, // fallback to null if cannot parse
),
markup: _.get(o, 'Mark_Up__c'),
}));
return billingAccounts.length > 0 ? billingAccounts[0] : {};
});
}
}

export default SalesforceService;
1 change: 1 addition & 0 deletions src/tests/util.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.