Skip to content

Commit

Permalink
feat(FN-3163): Create new DTFS exporters in Salesforce (#4021)
Browse files Browse the repository at this point in the history
Co-authored-by: Nat Dean-Lewis <ndlewis@ukexportfinance.gov.uk>
  • Loading branch information
natdeanlewissoftwire and Nat Dean-Lewis authored Dec 20, 2024
1 parent 6d11d69 commit 5b07601
Show file tree
Hide file tree
Showing 13 changed files with 438 additions and 31 deletions.
1 change: 1 addition & 0 deletions .env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -119,4 +119,5 @@ FF_TFM_DEAL_CANCELLATION_ENABLED=false
FF_PORTAL_FACILITY_AMENDMENTS_ENABLED=false
FF_TFM_SSO_ENABLED=false
FF_FEE_RECORD_CORRECTION_ENABLED=false
AUTOMATIC_SALESFORCE_CUSTOMER_CREATION_ENABLED=false
GEF_DEAL_VERSION=0
2 changes: 2 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ services:
- DELETION_AUDIT_LOGS_TTL_SECONDS
- FF_TFM_FACILITY_END_DATE_ENABLED
- FF_TFM_DEAL_CANCELLATION_ENABLED
- AUTOMATIC_SALESFORCE_CUSTOMER_CREATION_ENABLED
- ENTRA_ID_CLIENT_ID
- ENTRA_ID_CLOUD_INSTANCE
- ENTRA_ID_TENANT_ID
Expand Down Expand Up @@ -320,6 +321,7 @@ services:
- NODE_ENV
- RATE_LIMIT_THRESHOLD
- ESTORE_CRON_MANAGER_SCHEDULE
- AUTOMATIC_SALESFORCE_CUSTOMER_CREATION_ENABLED
entrypoint: npx ts-node external-api/src/index.ts

gef-ui:
Expand Down
63 changes: 58 additions & 5 deletions external-api/api-tests/v1/party-db.api-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,72 @@

import MockAdapter from 'axios-mock-adapter';
import axios, { HttpStatusCode } from 'axios';
import { MOCK_COMPANY_REGISTRATION_NUMBERS } from '@ukef/dtfs2-common';
import { MOCK_COMPANY_REGISTRATION_NUMBERS, isSalesforceCustomerCreationEnabled } from '@ukef/dtfs2-common';
import { app } from '../../src/createApp';
import { api } from '../api';

const { APIM_MDM_URL } = process.env;
const { VALID, VALID_WITH_LETTERS } = MOCK_COMPANY_REGISTRATION_NUMBERS;
const { get } = api(app);
let axiosMock: MockAdapter;

const axiosMock = new MockAdapter(axios);
axiosMock.onGet(`${APIM_MDM_URL}customers?companyReg=${VALID}`).reply(HttpStatusCode.Ok, {});
axiosMock.onGet(`${APIM_MDM_URL}customers?companyReg=${VALID_WITH_LETTERS}`).reply(HttpStatusCode.Ok, {});
jest.mock('@ukef/dtfs2-common', () => ({
...jest.requireActual('@ukef/dtfs2-common'),
isSalesforceCustomerCreationEnabled: jest.fn(),
}));

beforeEach(() => {
axiosMock = new MockAdapter(axios);

axiosMock.onGet(`${APIM_MDM_URL}customers?companyReg=${VALID}`).reply(HttpStatusCode.Ok, {});
axiosMock.onGet(`${APIM_MDM_URL}customers?companyReg=${VALID_WITH_LETTERS}`).reply(HttpStatusCode.Ok, {});
});

afterEach(() => {
axiosMock.resetHistory();
});

describe('when automatic Salesforce customer creation feature flag is disabled', () => {
beforeEach(() => {
jest.mocked(isSalesforceCustomerCreationEnabled).mockReturnValue(false);
});

describe('/party-db', () => {
describe('GET /party-db', () => {
it(`returns a ${HttpStatusCode.Ok} response with a valid companies house number`, async () => {
const { status } = await get(`/party-db/${VALID}`);

expect(status).toEqual(HttpStatusCode.Ok);
});

it(`returns a ${HttpStatusCode.Ok} response with a valid companies house number`, async () => {
const { status } = await get(`/party-db/${VALID_WITH_LETTERS}`);

expect(status).toEqual(HttpStatusCode.Ok);
});
});

const invalidCompaniesHouseNumberTestCases = [['ABC22'], ['127.0.0.1'], ['{}'], ['[]']];

describe('when company house number is invalid', () => {
test.each(invalidCompaniesHouseNumberTestCases)(
`returns a ${HttpStatusCode.BadRequest} if you provide an invalid company house number %s`,
async (companyHouseNumber) => {
const { status, body } = await get(`/party-db/${companyHouseNumber}`);

expect(status).toEqual(HttpStatusCode.BadRequest);
expect(body).toMatchObject({ data: 'Invalid company registration number', status: HttpStatusCode.BadRequest });
},
);
});
});
});

describe('when automatic Salesforce customer creation feature flag is enabled', () => {
beforeEach(() => {
jest.mocked(isSalesforceCustomerCreationEnabled).mockReturnValue(true);
});

describe('/party-db', () => {
describe('GET /party-db', () => {
it(`returns a ${HttpStatusCode.Ok} response with a valid companies house number`, async () => {
const { status } = await get(`/party-db/${VALID}`);
Expand Down
88 changes: 73 additions & 15 deletions external-api/src/v1/controllers/party-db.controller.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { HEADERS, isValidCompanyRegistrationNumber } from '@ukef/dtfs2-common';
import { CustomExpressRequest, HEADERS, isValidCompanyRegistrationNumber } from '@ukef/dtfs2-common';
import axios, { AxiosError, HttpStatusCode } from 'axios';
import * as dotenv from 'dotenv';
import { Request, Response } from 'express';
Expand All @@ -12,23 +12,81 @@ const headers = {
};

export const lookup = async (req: Request, res: Response) => {
const { partyDbCompanyRegistrationNumber: companyReg } = req.params;
try {
const { partyDbCompanyRegistrationNumber: companyReg } = req.params;

if (!isValidCompanyRegistrationNumber(companyReg)) {
console.error('Invalid company registration number provided %s', companyReg);
return res.status(HttpStatusCode.BadRequest).send({ status: HttpStatusCode.BadRequest, data: 'Invalid company registration number' });
}
if (!isValidCompanyRegistrationNumber(companyReg)) {
console.error('Invalid company registration number provided %s', companyReg);
return res.status(HttpStatusCode.BadRequest).send({ status: HttpStatusCode.BadRequest, data: 'Invalid company registration number' });
}

const response: { status: number; data: unknown } = await axios({
method: 'get',
url: `${APIM_MDM_URL}customers?companyReg=${companyReg}`,
headers,
});

const response: { status: number; data: unknown } = await axios({
method: 'get',
url: `${APIM_MDM_URL}customers?companyReg=${companyReg}`,
headers,
}).catch((error: AxiosError) => {
const { status, data } = response;
return res.status(status).send(data);
} catch (error) {
console.error('Error calling Party DB API %o', error);
return { data: 'Failed to call Party DB API', status: error?.response?.status || HttpStatusCode.InternalServerError };
});
if (error instanceof AxiosError) {
return res.status(error?.response?.status || HttpStatusCode.InternalServerError).send('Error calling Party DB API');
}
return res.status(HttpStatusCode.InternalServerError).send({ status: HttpStatusCode.InternalServerError, message: 'An unknown error occurred' });
}
};

const { status, data } = response;
/**
* Gets a customer in Salesforce, and creates it if it does not exist by sending a request to APIM,
* based on the provided company registration number and company name.
* This function validates the company registration number and sends an HTTP POST request to the MDM API
* to get or create the party. If validation fails or an error occurs, it returns the appropriate HTTP status and error message.
*
* @param {CustomExpressRequest<{ reqBody: { companyName: string } }>} req - The Express request object, which contains:
* - `req.body` - The company name (`companyName`) and companies house number (`companyReg`).
* @param {Response} res - The Express response object used to send the HTTP response.
*
* @returns {Promise<Response>} A promise that resolves to an HTTP response. The response contains:
* - On success: The status and data returned from the MDM API.
* - On failure: A relevant error message with the corresponding status code if there's an issue with the MDM request.
*
* @example
* // Example usage:
* const req = { body: { companyReg: '12345678', companyName: 'Test Corp' } };
* const res = { status: () => res, send: () => {} };
* await getOrCreateParty(req, res);
*/
export const getOrCreateParty = async (req: CustomExpressRequest<{ reqBody: { companyRegNo: string; companyName: string } }>, res: Response) => {
try {
const { companyRegNo: companyRegistrationNumber, companyName } = req.body;

return res.status(status).send(data);
if (!isValidCompanyRegistrationNumber(companyRegistrationNumber)) {
console.error('Invalid company registration number provided %s', companyRegistrationNumber);
return res.status(HttpStatusCode.BadRequest).send({ status: HttpStatusCode.BadRequest, data: 'Invalid company registration number' });
}

if (!companyName) {
console.error('No company name provided');
return res.status(HttpStatusCode.BadRequest).send({ status: HttpStatusCode.BadRequest, data: 'Invalid company name' });
}

const response: { status: number; data: unknown } = await axios({
method: 'post',
url: `${APIM_MDM_URL}customers`,
headers,
data: {
companyRegistrationNumber,
companyName,
},
});
const { status, data } = response;
return res.status(status).send(data);
} catch (error) {
console.error('Error calling Party DB API %o', error);
if (error instanceof AxiosError) {
return res.status(error?.response?.status || HttpStatusCode.InternalServerError).send('Error calling Party DB API');
}
return res.status(HttpStatusCode.InternalServerError).send({ status: HttpStatusCode.InternalServerError, message: 'An unknown error occurred' });
}
};
41 changes: 41 additions & 0 deletions external-api/src/v1/routes/external-apis.route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,45 @@ apiRoutes.post('/acbs/facility/:id/amendments', acbs.amendAcbsFacilityPost);
*/
apiRoutes.get('/party-db/:partyDbCompanyRegistrationNumber', partyDb.lookup);

/**
* @openapi
* /party-db/:partyDbCompanyRegistrationNumber:
* post:
* summary: Get a UKEF party from Salesforce, or create it if it doesn't exist
* tags: [APIM, Salesforce]
* description: We only consume the Companies House number and company name. Not all fields are in the response example.
* parameters:
* - in: path
* name: partyDbCompanyRegistrationNumber
* schema:
* type: string
* example: '12341234'
* required: true
* description: Companies House Registration Number for UKEF Party creation
* requestBody:
* required: true
* description: Company fields
* content:
* application/json:
* schema:
* name: companyName
* schema:
* type: string
* example: 'Some Name'
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* $ref: '#/definitions/PartyDB'
* 400:
* description: Bad request. Invalid Companies House registration number
* 500:
* description: Error getting or creating the party
*/
apiRoutes.post('/party-db', partyDb.getOrCreateParty);

/**
* @openapi
* /party-db/urn/:urn:
Expand All @@ -380,6 +419,8 @@ apiRoutes.get('/party-db/:partyDbCompanyRegistrationNumber', partyDb.lookup);
* application/json:
* schema:
* $ref: '#/definitions/PartyDB'
* 400:
* description: Bad request. Invalid Companies House registration number
* 404:
* description: Not found
*/
Expand Down
14 changes: 12 additions & 2 deletions gef-ui/server/services/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,16 @@ const updateSupportingInformation = async ({ dealId, application, field, user, u
}
};

/**
* Updates the application status for a specific deal.
*
* Notably, the timeout is so long because in the event that the deal status is being updated to 'Submitted',
* multiple API calls are triggered, and the user has to wait for the completion of these in the UI in the
* current implementation.
*
* In the future, these operations should instead be run as part of a fail-safe background process, as discussed
* in the root README. At that point, the timeout can be reduced.
*/
const setApplicationStatus = async ({ dealId, status, userToken }) => {
if (!isValidMongoId(dealId)) {
console.error('setApplicationStatus: API call failed for dealId %s', dealId);
Expand All @@ -113,8 +123,8 @@ const setApplicationStatus = async ({ dealId, status, userToken }) => {
{
status,
},
{ ...config(userToken), timeout: 10000 },
); // Application status has multiple api calls in portal api
{ ...config(userToken), timeout: 30000 },
);
return data;
} catch (error) {
return apiErrorHandler(error);
Expand Down
1 change: 1 addition & 0 deletions libs/common/src/helpers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export {
isTfmDealCancellationFeatureFlagEnabled,
isTfmSsoFeatureFlagEnabled,
isPortalFacilityAmendmentsFeatureFlagEnabled,
isSalesforceCustomerCreationEnabled,
} from './is-feature-flag-enabled';
export * from './gef-deal-versioning';
export * from './monetary-value';
Expand Down
5 changes: 5 additions & 0 deletions libs/common/src/helpers/is-feature-flag-enabled.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
isTfmFacilityEndDateFeatureFlagEnabled,
isTfmPaymentReconciliationFeatureFlagEnabled,
isTfmSsoFeatureFlagEnabled,
isSalesforceCustomerCreationEnabled,
} from './is-feature-flag-enabled';
import { withBooleanFeatureFlagTests } from './with-boolean-feature-flag.tests';

Expand All @@ -15,4 +16,8 @@ describe('is-feature-flag-enabled helpers', () => {
withBooleanFeatureFlagTests({ featureFlagName: 'FF_TFM_DEAL_CANCELLATION_ENABLED', getFeatureFlagValue: isTfmDealCancellationFeatureFlagEnabled });
withBooleanFeatureFlagTests({ featureFlagName: 'FF_PORTAL_FACILITY_AMENDMENTS_ENABLED', getFeatureFlagValue: isPortalFacilityAmendmentsFeatureFlagEnabled });
withBooleanFeatureFlagTests({ featureFlagName: 'FF_TFM_SSO_ENABLED', getFeatureFlagValue: isTfmSsoFeatureFlagEnabled });
withBooleanFeatureFlagTests({
featureFlagName: 'AUTOMATIC_SALESFORCE_CUSTOMER_CREATION_ENABLED',
getFeatureFlagValue: isSalesforceCustomerCreationEnabled,
});
});
3 changes: 3 additions & 0 deletions libs/common/src/helpers/is-feature-flag-enabled.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const featureFlagsSchema = z.object({
FF_TFM_DEAL_CANCELLATION_ENABLED: featureFlagOptions,
FF_PORTAL_FACILITY_AMENDMENTS_ENABLED: featureFlagOptions,
FF_TFM_SSO_ENABLED: featureFlagOptions,
AUTOMATIC_SALESFORCE_CUSTOMER_CREATION_ENABLED: featureFlagOptions,
});

export type FeatureFlag = keyof z.infer<typeof featureFlagsSchema>;
Expand All @@ -36,3 +37,5 @@ export const isTfmDealCancellationFeatureFlagEnabled = isFeatureFlagEnabled('FF_
export const isPortalFacilityAmendmentsFeatureFlagEnabled = isFeatureFlagEnabled('FF_PORTAL_FACILITY_AMENDMENTS_ENABLED');

export const isTfmSsoFeatureFlagEnabled = isFeatureFlagEnabled('FF_TFM_SSO_ENABLED');

export const isSalesforceCustomerCreationEnabled = isFeatureFlagEnabled('AUTOMATIC_SALESFORCE_CUSTOMER_CREATION_ENABLED');
27 changes: 23 additions & 4 deletions trade-finance-manager-api/src/v1/__mocks__/api.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
const { isSalesforceCustomerCreationEnabled } = require('@ukef/dtfs2-common');
const { HttpStatusCode } = require('axios');
const { MOCK_FACILITIES } = require('./mock-facilities');
const MOCK_BSS_FACILITIES_USD_CURRENCY = require('./mock-facilities-USD-currency');
const MOCK_CURRENCY_EXCHANGE_RATE = require('./mock-currency-exchange-rate');
Expand Down Expand Up @@ -178,14 +180,31 @@ module.exports = {
getFacilityExposurePeriod: jest.fn(() => ({
exposurePeriodInMonths: 12,
})),
getPartyDbInfo: ({ companyRegNo }) =>
companyRegNo === 'NO_MATCH'
? false
: [
getPartyDbInfo: ({ companyRegNo }) => {
const noCompanyMatch = companyRegNo === 'NO_MATCH';

if (isSalesforceCustomerCreationEnabled()) {
if (noCompanyMatch) {
return { status: HttpStatusCode.NotFound, data: 'Party not found' };
}

return {
status: HttpStatusCode.Ok,
data: [
{
partyUrn: 'testPartyUrn',
},
],
};
}
if (noCompanyMatch) {
return false;
}

return {
partyUrn: 'testPartyUrn',
};
},
findUser: (username) => {
if (username === 'invalidUser') {
return false;
Expand Down
Loading

0 comments on commit 5b07601

Please sign in to comment.