diff --git a/.cspell.json b/.cspell.json index 36ebd321..727d49c3 100644 --- a/.cspell.json +++ b/.cspell.json @@ -49,6 +49,7 @@ "npmrc", "NVARCHAR", "osgb", + "pscs", "pino", "pinojs", "satify", diff --git a/.env.sample b/.env.sample index af236dee..a2dc39df 100644 --- a/.env.sample +++ b/.env.sample @@ -45,3 +45,9 @@ ORDNANCE_SURVEY_URL=https://api.os.uk ORDNANCE_SURVEY_KEY= ORDNANCE_SURVEY_MAX_REDIRECTS= ORDNANCE_SURVEY_TIMEOUT= # in milliseconds + +# COMPANIES HOUSE +COMPANIES_HOUSE_URL= +COMPANIES_HOUSE_KEY= +COMPANIES_HOUSE_MAX_REDIRECTS= +COMPANIES_HOUSE_TIMEOUT= # in milliseconds diff --git a/CHANGELOG.md b/CHANGELOG.md index a7c747a0..2e706793 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,7 +26,7 @@ * **DTFS2-7052:** actioning PR comments ([e012eb4](https://github.com/UK-Export-Finance/mdm-api/commit/e012eb4a7912dcb254bfa0e7c2f5d2794f11ab58)) * **DTFS2-7052:** actioning PR comments ([7d15b07](https://github.com/UK-Export-Finance/mdm-api/commit/7d15b07ef4126f99ffcf6189deac2b2391633edd)) * **DTFS2-7052:** adding constants and examples ([a3d5433](https://github.com/UK-Export-Finance/mdm-api/commit/a3d54338610f50f4824d6c6eeffa50ebdb6e91a2)) -* **DTFS2-7052:** adding typescript include for json files, to satify lint. I added big examples to json files ([0b79772](https://github.com/UK-Export-Finance/mdm-api/commit/0b79772256a36fc8e5c9b38e3f67a0433984f2ab)) +* **DTFS2-7052:** adding typescript include for json files, to satisfy lint. I added big examples to json files ([0b79772](https://github.com/UK-Export-Finance/mdm-api/commit/0b79772256a36fc8e5c9b38e3f67a0433984f2ab)) * **DTFS2-7052:** api-tests for geospatial/get-address-by-postcode ([c8cb1bc](https://github.com/UK-Export-Finance/mdm-api/commit/c8cb1bc98c72f3258f8bc9d498effdfe561ff45b)) * **DTFS2-7052:** applying Oscars suggestions on my PR ([f68ac66](https://github.com/UK-Export-Finance/mdm-api/commit/f68ac66050ea3e0515378657f4893eb65f933ed4)) * **DTFS2-7052:** change GET /geospatial/addresses/postcode?postcode= empty response from 200 to 404 ([33c9e65](https://github.com/UK-Export-Finance/mdm-api/commit/33c9e65961e67fdff36c854a7ae9f78c3c1ccd0c)) diff --git a/docker-compose.yml b/docker-compose.yml index b662d6e1..211d385e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -37,6 +37,10 @@ services: ORDNANCE_SURVEY_KEY: ORDNANCE_SURVEY_MAX_REDIRECTS: ORDNANCE_SURVEY_TIMEOUT: + COMPANIES_HOUSE_URL: + COMPANIES_HOUSE_KEY: + COMPANIES_HOUSE_MAX_REDIRECTS: + COMPANIES_HOUSE_TIMEOUT: API_KEY: healthcheck: test: ["CMD", "curl", "-f", "http://localhost:${PORT}"] diff --git a/src/config/companies-house.config.test.ts b/src/config/companies-house.config.test.ts new file mode 100644 index 00000000..68ef676d --- /dev/null +++ b/src/config/companies-house.config.test.ts @@ -0,0 +1,39 @@ +import { withEnvironmentVariableParsingUnitTests } from '@ukef-test/common-tests/environment-variable-parsing-unit-tests'; + +import companiesHouseConfig, { CompaniesHouseConfig } from './companies-house.config'; + +describe('companiesHouseConfig', () => { + const configDirectlyFromEnvironmentVariables: { configPropertyName: keyof CompaniesHouseConfig; environmentVariableName: string }[] = [ + { + configPropertyName: 'baseUrl', + environmentVariableName: 'COMPANIES_HOUSE_URL', + }, + { + configPropertyName: 'key', + environmentVariableName: 'COMPANIES_HOUSE_KEY', + }, + ]; + + const configParsedAsIntFromEnvironmentVariablesWithDefault: { + configPropertyName: keyof CompaniesHouseConfig; + environmentVariableName: string; + defaultConfigValue: number; + }[] = [ + { + configPropertyName: 'maxRedirects', + environmentVariableName: 'COMPANIES_HOUSE_MAX_REDIRECTS', + defaultConfigValue: 5, + }, + { + configPropertyName: 'timeout', + environmentVariableName: 'COMPANIES_HOUSE_TIMEOUT', + defaultConfigValue: 30000, + }, + ]; + + withEnvironmentVariableParsingUnitTests({ + configDirectlyFromEnvironmentVariables, + configParsedAsIntFromEnvironmentVariablesWithDefault, + getConfig: () => companiesHouseConfig(), + }); +}); diff --git a/src/config/companies-house.config.ts b/src/config/companies-house.config.ts new file mode 100644 index 00000000..35715007 --- /dev/null +++ b/src/config/companies-house.config.ts @@ -0,0 +1,20 @@ +import { registerAs } from '@nestjs/config'; +import { COMPANIES_HOUSE } from '@ukef/constants'; +import { getIntConfig } from '@ukef/helpers/get-int-config'; + +export interface CompaniesHouseConfig { + baseUrl: string; + key: string; + maxRedirects: number; + timeout: number; +} + +export default registerAs( + COMPANIES_HOUSE.CONFIG.KEY, + (): CompaniesHouseConfig => ({ + baseUrl: process.env.COMPANIES_HOUSE_URL, + key: process.env.COMPANIES_HOUSE_KEY, + maxRedirects: getIntConfig(process.env.COMPANIES_HOUSE_MAX_REDIRECTS, 5), + timeout: getIntConfig(process.env.COMPANIES_HOUSE_TIMEOUT, 30000), + }), +); diff --git a/src/config/index.ts b/src/config/index.ts index 29fe1c91..dced8537 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -1,7 +1,8 @@ import AppConfig from './app.config'; +import CompaniesHouseConfig from './companies-house.config'; import DatabaseConfig from './database.config'; import DocConfig from './doc.config'; import InformaticaConfig from './informatica.config'; import OrdnanceSurveyConfig from './ordnance-survey.config'; -export default [AppConfig, DocConfig, DatabaseConfig, InformaticaConfig, OrdnanceSurveyConfig]; +export default [AppConfig, CompaniesHouseConfig, DocConfig, DatabaseConfig, InformaticaConfig, OrdnanceSurveyConfig]; diff --git a/src/constants/companies-house.constant.ts b/src/constants/companies-house.constant.ts new file mode 100644 index 00000000..bf494455 --- /dev/null +++ b/src/constants/companies-house.constant.ts @@ -0,0 +1,5 @@ +export const COMPANIES_HOUSE = { + CONFIG: { + KEY: 'companiesHouse', + }, +}; diff --git a/src/constants/companies.constant.ts b/src/constants/companies.constant.ts new file mode 100644 index 00000000..a74d310b --- /dev/null +++ b/src/constants/companies.constant.ts @@ -0,0 +1,10 @@ +export const COMPANIES = { + ENDPOINT_BASE_URL: '/api/v1/companies?registrationNumber=', + EXAMPLES: { + COMPANIES_HOUSE_REGISTRATION_NUMBER: '00000001', + }, + REGEX: { + // This Companies House registration number regex was copied from the DTFS codebase. + COMPANIES_HOUSE_REGISTRATION_NUMBER: /^(([A-Z]{2}|[A-Z]\d|\d{2})(\d{5,6}|\d{4,5}[A-Z]))$/, + }, +}; diff --git a/src/constants/index.ts b/src/constants/index.ts index dd7a98e7..46bb20fe 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -10,9 +10,13 @@ * 6. Strings to redact * 7. Strings locations to redact * 8. Module geospatial + * 9. Companies module + * 10. Companies House helper module */ export * from './auth.constant'; +export * from './companies.constant'; +export * from './companies-house.constant'; export * from './customers.constant'; export * from './database-name.constant'; export * from './date.constant'; diff --git a/src/helper-modules/companies-house/companies-house.module.ts b/src/helper-modules/companies-house/companies-house.module.ts new file mode 100644 index 00000000..97667140 --- /dev/null +++ b/src/helper-modules/companies-house/companies-house.module.ts @@ -0,0 +1,27 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { CompaniesHouseConfig } from '@ukef/config/companies-house.config'; +import { COMPANIES_HOUSE } from '@ukef/constants'; +import { HttpModule } from '@ukef/modules/http/http.module'; + +import { CompaniesHouseService } from './companies-house.service'; + +@Module({ + imports: [ + HttpModule.registerAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: (configService: ConfigService) => { + const { baseUrl, maxRedirects, timeout } = configService.get(COMPANIES_HOUSE.CONFIG.KEY); + return { + baseURL: baseUrl, + maxRedirects, + timeout, + }; + }, + }), + ], + providers: [CompaniesHouseService], + exports: [CompaniesHouseService], +}) +export class CompaniesHouseModule {} diff --git a/src/helper-modules/companies-house/companies-house.service.test.ts b/src/helper-modules/companies-house/companies-house.service.test.ts new file mode 100644 index 00000000..4e3ede16 --- /dev/null +++ b/src/helper-modules/companies-house/companies-house.service.test.ts @@ -0,0 +1,169 @@ +import { HttpService } from '@nestjs/axios'; +import { ConfigService } from '@nestjs/config'; +import { GetCompanyGenerator } from '@ukef-test/support/generator/get-company-generator'; +import { RandomValueGenerator } from '@ukef-test/support/generator/random-value-generator'; +import { AxiosError } from 'axios'; +import { resetAllWhenMocks, when } from 'jest-when'; +import { of, throwError } from 'rxjs'; + +import { CompaniesHouseService } from './companies-house.service'; +import { CompaniesHouseException } from './exception/companies-house.exception'; +import { CompaniesHouseInvalidAuthorizationException } from './exception/companies-house-invalid-authorization.exception'; +import { CompaniesHouseMalformedAuthorizationHeaderException } from './exception/companies-house-malformed-authorization-header.exception'; +import { CompaniesHouseNotFoundException } from './exception/companies-house-not-found.exception'; + +describe('CompaniesHouseService', () => { + let httpServiceGet: jest.Mock; + let configServiceGet: jest.Mock; + let service: CompaniesHouseService; + + const valueGenerator = new RandomValueGenerator(); + + const testRegistrationNumber = '00000001'; + + const { + companiesHousePath, + getCompanyCompaniesHouseResponse, + getCompanyCompaniesHouseMalformedAuthorizationHeaderResponse, + getCompanyCompaniesHouseInvalidAuthorizationResponse, + getCompanyCompaniesHouseNotFoundResponse, + } = new GetCompanyGenerator(valueGenerator).generate({ + numberToGenerate: 1, + registrationNumber: testRegistrationNumber, + }); + + const testKey = valueGenerator.string({ length: 40 }); + const encodedTestKey = Buffer.from(testKey).toString('base64'); + + const expectedHttpServiceGetArguments: [string, object] = [ + companiesHousePath, + { + headers: { + Authorization: `Basic ${encodedTestKey}`, + }, + }, + ]; + + const expectedHttpServiceGetResponse = of({ + data: getCompanyCompaniesHouseResponse, + status: 200, + statusText: 'OK', + config: undefined, + headers: undefined, + }); + + beforeAll(() => { + const httpService = new HttpService(); + httpServiceGet = jest.fn(); + httpService.get = httpServiceGet; + + const configService = new ConfigService(); + configServiceGet = jest.fn().mockReturnValue({ key: testKey }); + configService.get = configServiceGet; + + service = new CompaniesHouseService(httpService, configService); + }); + + beforeEach(() => { + resetAllWhenMocks(); + }); + + describe('getCompanyByRegistrationNumber', () => { + it('calls the Companies House API with the correct arguments', async () => { + when(httpServiceGet) + .calledWith(...expectedHttpServiceGetArguments) + .mockReturnValueOnce(expectedHttpServiceGetResponse); + + await service.getCompanyByRegistrationNumber(testRegistrationNumber); + + expect(httpServiceGet).toHaveBeenCalledTimes(1); + expect(httpServiceGet).toHaveBeenCalledWith(...expectedHttpServiceGetArguments); + }); + + it('returns the results when the Companies House API returns a 200 response with results', async () => { + when(httpServiceGet) + .calledWith(...expectedHttpServiceGetArguments) + .mockReturnValueOnce(expectedHttpServiceGetResponse); + + const response = await service.getCompanyByRegistrationNumber(testRegistrationNumber); + + expect(response).toBe(getCompanyCompaniesHouseResponse); + }); + + it(`throws a CompaniesHouseMalformedAuthorizationHeaderException when the Companies House API returns a 400 response containing the error string 'Invalid Authorization header'`, async () => { + const axiosError = new AxiosError(); + axiosError.response = { + data: getCompanyCompaniesHouseMalformedAuthorizationHeaderResponse, + status: 400, + statusText: 'Bad Request', + config: undefined, + headers: undefined, + }; + + when(httpServiceGet) + .calledWith(...expectedHttpServiceGetArguments) + .mockReturnValueOnce(throwError(() => axiosError)); + + const getCompanyPromise = service.getCompanyByRegistrationNumber(testRegistrationNumber); + + await expect(getCompanyPromise).rejects.toBeInstanceOf(CompaniesHouseMalformedAuthorizationHeaderException); + await expect(getCompanyPromise).rejects.toThrow(`Invalid 'Authorization' header. Check that your 'Authorization' header is well-formed.`); + await expect(getCompanyPromise).rejects.toHaveProperty('innerError', axiosError); + }); + + it(`throws a CompaniesHouseInvalidAuthorizationException when the Companies House API returns a 401 response containing the error string 'Invalid Authorization'`, async () => { + const axiosError = new AxiosError(); + axiosError.response = { + data: getCompanyCompaniesHouseInvalidAuthorizationResponse, + status: 401, + statusText: 'Unauthorized', + config: undefined, + headers: undefined, + }; + + when(httpServiceGet) + .calledWith(...expectedHttpServiceGetArguments) + .mockReturnValueOnce(throwError(() => axiosError)); + + const getCompanyPromise = service.getCompanyByRegistrationNumber(testRegistrationNumber); + + await expect(getCompanyPromise).rejects.toBeInstanceOf(CompaniesHouseInvalidAuthorizationException); + await expect(getCompanyPromise).rejects.toThrow('Invalid authorization. Check your Companies House API key.'); + await expect(getCompanyPromise).rejects.toHaveProperty('innerError', axiosError); + }); + + it(`throws a CompaniesHouseNotFoundException when the Companies House API returns a 404 response containing the error string 'company-profile-not-found'`, async () => { + const axiosError = new AxiosError(); + axiosError.response = { + data: getCompanyCompaniesHouseNotFoundResponse, + status: 404, + statusText: 'Not Found', + config: undefined, + headers: undefined, + }; + + when(httpServiceGet) + .calledWith(...expectedHttpServiceGetArguments) + .mockReturnValueOnce(throwError(() => axiosError)); + + const getCompanyPromise = service.getCompanyByRegistrationNumber(testRegistrationNumber); + + await expect(getCompanyPromise).rejects.toBeInstanceOf(CompaniesHouseNotFoundException); + await expect(getCompanyPromise).rejects.toThrow(`Company with registration number ${testRegistrationNumber} was not found.`); + await expect(getCompanyPromise).rejects.toHaveProperty('innerError', axiosError); + }); + + it('throws a CompaniesHouseException if the Companies House API returns an unknown error response', async () => { + const axiosError = new AxiosError(); + when(httpServiceGet) + .calledWith(...expectedHttpServiceGetArguments) + .mockReturnValueOnce(throwError(() => axiosError)); + + const getCompanyPromise = service.getCompanyByRegistrationNumber(testRegistrationNumber); + + await expect(getCompanyPromise).rejects.toBeInstanceOf(CompaniesHouseException); + await expect(getCompanyPromise).rejects.toThrow('Failed to get response from Companies House API.'); + await expect(getCompanyPromise).rejects.toHaveProperty('innerError', axiosError); + }); + }); +}); diff --git a/src/helper-modules/companies-house/companies-house.service.ts b/src/helper-modules/companies-house/companies-house.service.ts new file mode 100644 index 00000000..628548f2 --- /dev/null +++ b/src/helper-modules/companies-house/companies-house.service.ts @@ -0,0 +1,48 @@ +import { HttpService } from '@nestjs/axios'; +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { CompaniesHouseConfig } from '@ukef/config/companies-house.config'; +import { COMPANIES_HOUSE } from '@ukef/constants'; +import { HttpClient } from '@ukef/modules/http/http.client'; + +import { GetCompanyCompaniesHouseResponse } from './dto/get-company-companies-house-response.dto'; +import { + getCompanyInvalidAuthorizationKnownCompaniesHouseError, + getCompanyMalformedAuthorizationHeaderKnownCompaniesHouseError, + getCompanyNotFoundKnownCompaniesHouseError, +} from './known-errors'; +import { createWrapCompaniesHouseHttpGetErrorCallback } from './wrap-companies-house-http-error-callback'; + +@Injectable() +export class CompaniesHouseService { + private readonly httpClient: HttpClient; + private readonly key: string; + + constructor(httpService: HttpService, configService: ConfigService) { + this.httpClient = new HttpClient(httpService); + const { key } = configService.get(COMPANIES_HOUSE.CONFIG.KEY); + this.key = key; + } + + async getCompanyByRegistrationNumber(registrationNumber: string): Promise { + const path = `/company/${registrationNumber}`; + const encodedKey = Buffer.from(this.key).toString('base64'); + + const { data } = await this.httpClient.get({ + path, + headers: { + Authorization: `Basic ${encodedKey}`, + }, + onError: createWrapCompaniesHouseHttpGetErrorCallback({ + messageForUnknownError: 'Failed to get response from Companies House API.', + knownErrors: [ + getCompanyMalformedAuthorizationHeaderKnownCompaniesHouseError(), + getCompanyInvalidAuthorizationKnownCompaniesHouseError(), + getCompanyNotFoundKnownCompaniesHouseError(registrationNumber), + ], + }), + }); + + return data; + } +} diff --git a/src/helper-modules/companies-house/dto/get-company-companies-house-error-response.dto.ts b/src/helper-modules/companies-house/dto/get-company-companies-house-error-response.dto.ts new file mode 100644 index 00000000..4aa3ee7c --- /dev/null +++ b/src/helper-modules/companies-house/dto/get-company-companies-house-error-response.dto.ts @@ -0,0 +1,4 @@ +export type GetCompanyCompaniesHouseErrorResponse = { + error: string; + type: string; +}; diff --git a/src/helper-modules/companies-house/dto/get-company-companies-house-multiple-error-response.dto.ts b/src/helper-modules/companies-house/dto/get-company-companies-house-multiple-error-response.dto.ts new file mode 100644 index 00000000..b7aaf05c --- /dev/null +++ b/src/helper-modules/companies-house/dto/get-company-companies-house-multiple-error-response.dto.ts @@ -0,0 +1,5 @@ +import { GetCompanyCompaniesHouseErrorResponse } from './get-company-companies-house-error-response.dto'; + +export type GetCompanyCompaniesHouseMultipleErrorResponse = { + errors: GetCompanyCompaniesHouseErrorResponse[]; +}; diff --git a/src/helper-modules/companies-house/dto/get-company-companies-house-response.dto.ts b/src/helper-modules/companies-house/dto/get-company-companies-house-response.dto.ts new file mode 100644 index 00000000..8fe7143a --- /dev/null +++ b/src/helper-modules/companies-house/dto/get-company-companies-house-response.dto.ts @@ -0,0 +1,58 @@ +export type GetCompanyCompaniesHouseResponse = { + links: { + filing_history: string; + self: string; + persons_with_significant_control: string; + officers: string; + }; + accounts: { + last_accounts: { + period_end_on: string; + type: string; + made_up_to: string; + period_start_on: string; + }; + accounting_reference_date: { + month: string; + day: string; + }; + overdue: boolean; + next_made_up_to: string; + next_due: string; + next_accounts: { + period_start_on: string; + due_on: string; + period_end_on: string; + overdue: boolean; + }; + }; + company_name: string; + company_number: string; + company_status: string; + confirmation_statement: { + next_made_up_to: string; + next_due: string; + overdue: boolean; + last_made_up_to: string; + }; + date_of_creation: string; + etag: string; + has_charges: boolean; + has_insolvency_history: boolean; + jurisdiction: string; + registered_office_address: { + organisation_name?: string; + address_line_1: string; + address_line_2?: string; + address_line_3?: string; + locality: string; + postal_code: string; + country: string; + }; + registered_office_is_in_dispute: boolean; + sic_codes: string[]; + type: string; + undeliverable_registered_office_address: boolean; + has_super_secure_pscs: boolean; + can_file: boolean; +}; diff --git a/src/helper-modules/companies-house/examples/example-response-for-get-company-by-registration-number-overseas-company.json b/src/helper-modules/companies-house/examples/example-response-for-get-company-by-registration-number-overseas-company.json new file mode 100644 index 00000000..d1b10129 --- /dev/null +++ b/src/helper-modules/companies-house/examples/example-response-for-get-company-by-registration-number-overseas-company.json @@ -0,0 +1,51 @@ +{ + "links": { + "filing_history": "/company/OE006930/filing-history", + "self": "/company/OE006930", + "persons_with_significant_control": "/company/OE006930/persons-with-significant-control", + "persons_with_significant_control_statements": "/company/OE006930/persons-with-significant-control-statements" + }, + "company_name": "APPLE BLOSSOM HOLDINGS LIMITED", + "company_number": "OE006930", + "company_status": "registered", + "confirmation_statement": { + "last_made_up_to": "2023-12-06", + "next_due": "2024-12-20", + "overdue": false, + "next_made_up_to": "2024-12-06" + }, + "date_of_creation": "2022-12-08", + "etag": "332373c5004d70a1869c1078f0892759fcdd1d95", + "external_registration_number": "1770420", + "foreign_company_details": { + "registration_number": "1770420", + "originating_registry": { + "country": "VIRGIN ISLANDS, BRITISH", + "name": "Registry Of Corporate Affairs, British Virgin Islands,British Virgin Islands" + }, + "legal_form": "Limited Company", + "governed_by": "British Virgin Islands, Business Companies Act" + }, + "has_charges": false, + "has_insolvency_history": false, + "has_super_secure_pscs": false, + "jurisdiction": "united-kingdom", + "registered_office_address": { + "address_line_2": "Road Town", + "locality": "Tortola", + "country": "Virgin Islands, British", + "address_line_1": "Nerine Chambers PO BOX 905, Quastisky Building" + }, + "registered_office_is_in_dispute": false, + "service_address": { + "address_line_2": "10 Barley Mow Passage", + "country": "United Kingdom", + "locality": "London", + "postal_code": "W4 4PH", + "address_line_1": "Unit 2.15 Barley Mow Centre" + }, + "super_secure_managing_officer_count": 0, + "type": "registered-overseas-entity", + "undeliverable_registered_office_address": false, + "can_file": false +} \ No newline at end of file diff --git a/src/helper-modules/companies-house/examples/example-response-for-get-company-by-registration-number.json b/src/helper-modules/companies-house/examples/example-response-for-get-company-by-registration-number.json new file mode 100644 index 00000000..db9a93ac --- /dev/null +++ b/src/helper-modules/companies-house/examples/example-response-for-get-company-by-registration-number.json @@ -0,0 +1,60 @@ +{ + "links": { + "filing_history": "/company/00000001/filing-history", + "self": "/company/00000001", + "persons_with_significant_control": "/company/00000001/persons-with-significant-control", + "officers": "/company/00000001/officers" + }, + "accounts": { + "last_accounts": { + "period_end_on": "2022-12-31", + "type": "micro-entity", + "made_up_to": "2022-12-31", + "period_start_on": "2022-01-01" + }, + "accounting_reference_date": { + "month": "12", + "day": "31" + }, + "overdue": false, + "next_made_up_to": "2023-12-31", + "next_due": "2024-09-30", + "next_accounts": { + "period_start_on": "2023-01-01", + "due_on": "2024-09-30", + "period_end_on": "2023-12-31", + "overdue": false + } + }, + "company_name": "TEST COMPANY LTD", + "company_number": "00000001", + "company_status": "active", + "confirmation_statement": { + "next_made_up_to": "2025-01-20", + "next_due": "2025-02-03", + "overdue": false, + "last_made_up_to": "2024-01-20" + }, + "date_of_creation": "2020-01-21", + "etag": "4f776861e3dfca11b773ff496c55999b7ec6a1cc", + "has_charges": false, + "has_insolvency_history": false, + "jurisdiction": "england-wales", + "registered_office_address": { + "address_line_1": "1 Test Street", + "locality": "Test City", + "postal_code": "A1 2BC", + "country": "United Kingdom" + }, + "registered_office_is_in_dispute": false, + "sic_codes": [ + "59112", + "62012", + "62020", + "62090" + ], + "type": "ltd", + "undeliverable_registered_office_address": false, + "has_super_secure_pscs": false, + "can_file": true +} \ No newline at end of file diff --git a/src/helper-modules/companies-house/exception/companies-house-invalid-authorization.exception.test.ts b/src/helper-modules/companies-house/exception/companies-house-invalid-authorization.exception.test.ts new file mode 100644 index 00000000..b4e37913 --- /dev/null +++ b/src/helper-modules/companies-house/exception/companies-house-invalid-authorization.exception.test.ts @@ -0,0 +1,28 @@ +import { RandomValueGenerator } from '@ukef-test/support/generator/random-value-generator'; + +import { CompaniesHouseInvalidAuthorizationException } from './companies-house-invalid-authorization.exception'; + +describe('CompaniesHouseInvalidAuthorizationException', () => { + const valueGenerator = new RandomValueGenerator(); + const message = valueGenerator.string(); + + it('exposes the message it was created with', () => { + const exception = new CompaniesHouseInvalidAuthorizationException(message); + + expect(exception.message).toBe(message); + }); + + it('exposes the name of the exception', () => { + const exception = new CompaniesHouseInvalidAuthorizationException(message); + + expect(exception.name).toBe('CompaniesHouseInvalidAuthorizationException'); + }); + + it('exposes the inner error it was created with', () => { + const innerError = new Error(); + + const exception = new CompaniesHouseInvalidAuthorizationException(message, innerError); + + expect(exception.innerError).toBe(innerError); + }); +}); diff --git a/src/helper-modules/companies-house/exception/companies-house-invalid-authorization.exception.ts b/src/helper-modules/companies-house/exception/companies-house-invalid-authorization.exception.ts new file mode 100644 index 00000000..5b535e1e --- /dev/null +++ b/src/helper-modules/companies-house/exception/companies-house-invalid-authorization.exception.ts @@ -0,0 +1,9 @@ +export class CompaniesHouseInvalidAuthorizationException extends Error { + constructor( + message: string, + public readonly innerError?: Error, + ) { + super(message); + this.name = this.constructor.name; + } +} diff --git a/src/helper-modules/companies-house/exception/companies-house-malformed-authorization-header.exception.test.ts b/src/helper-modules/companies-house/exception/companies-house-malformed-authorization-header.exception.test.ts new file mode 100644 index 00000000..7a0cc044 --- /dev/null +++ b/src/helper-modules/companies-house/exception/companies-house-malformed-authorization-header.exception.test.ts @@ -0,0 +1,28 @@ +import { RandomValueGenerator } from '@ukef-test/support/generator/random-value-generator'; + +import { CompaniesHouseMalformedAuthorizationHeaderException } from './companies-house-malformed-authorization-header.exception'; + +describe('CompaniesHouseMalformedAuthorizationHeaderException', () => { + const valueGenerator = new RandomValueGenerator(); + const message = valueGenerator.string(); + + it('exposes the message it was created with', () => { + const exception = new CompaniesHouseMalformedAuthorizationHeaderException(message); + + expect(exception.message).toBe(message); + }); + + it('exposes the name of the exception', () => { + const exception = new CompaniesHouseMalformedAuthorizationHeaderException(message); + + expect(exception.name).toBe('CompaniesHouseMalformedAuthorizationHeaderException'); + }); + + it('exposes the inner error it was created with', () => { + const innerError = new Error(); + + const exception = new CompaniesHouseMalformedAuthorizationHeaderException(message, innerError); + + expect(exception.innerError).toBe(innerError); + }); +}); diff --git a/src/helper-modules/companies-house/exception/companies-house-malformed-authorization-header.exception.ts b/src/helper-modules/companies-house/exception/companies-house-malformed-authorization-header.exception.ts new file mode 100644 index 00000000..ab83377c --- /dev/null +++ b/src/helper-modules/companies-house/exception/companies-house-malformed-authorization-header.exception.ts @@ -0,0 +1,9 @@ +export class CompaniesHouseMalformedAuthorizationHeaderException extends Error { + constructor( + message: string, + public readonly innerError?: Error, + ) { + super(message); + this.name = this.constructor.name; + } +} diff --git a/src/helper-modules/companies-house/exception/companies-house-not-found.exception.test.ts b/src/helper-modules/companies-house/exception/companies-house-not-found.exception.test.ts new file mode 100644 index 00000000..701b3cd3 --- /dev/null +++ b/src/helper-modules/companies-house/exception/companies-house-not-found.exception.test.ts @@ -0,0 +1,28 @@ +import { RandomValueGenerator } from '@ukef-test/support/generator/random-value-generator'; + +import { CompaniesHouseNotFoundException } from './companies-house-not-found.exception'; + +describe('CompaniesHouseNotFoundException', () => { + const valueGenerator = new RandomValueGenerator(); + const message = valueGenerator.string(); + + it('exposes the message it was created with', () => { + const exception = new CompaniesHouseNotFoundException(message); + + expect(exception.message).toBe(message); + }); + + it('exposes the name of the exception', () => { + const exception = new CompaniesHouseNotFoundException(message); + + expect(exception.name).toBe('CompaniesHouseNotFoundException'); + }); + + it('exposes the inner error it was created with', () => { + const innerError = new Error(); + + const exception = new CompaniesHouseNotFoundException(message, innerError); + + expect(exception.innerError).toBe(innerError); + }); +}); diff --git a/src/helper-modules/companies-house/exception/companies-house-not-found.exception.ts b/src/helper-modules/companies-house/exception/companies-house-not-found.exception.ts new file mode 100644 index 00000000..bcb78dc2 --- /dev/null +++ b/src/helper-modules/companies-house/exception/companies-house-not-found.exception.ts @@ -0,0 +1,9 @@ +export class CompaniesHouseNotFoundException extends Error { + constructor( + message: string, + public readonly innerError?: Error, + ) { + super(message); + this.name = this.constructor.name; + } +} diff --git a/src/helper-modules/companies-house/exception/companies-house.exception.test.ts b/src/helper-modules/companies-house/exception/companies-house.exception.test.ts new file mode 100644 index 00000000..8bc55b2e --- /dev/null +++ b/src/helper-modules/companies-house/exception/companies-house.exception.test.ts @@ -0,0 +1,28 @@ +import { RandomValueGenerator } from '@ukef-test/support/generator/random-value-generator'; + +import { CompaniesHouseException } from './companies-house.exception'; + +describe('CompaniesHouseException', () => { + const valueGenerator = new RandomValueGenerator(); + const message = valueGenerator.string(); + + it('exposes the message it was created with', () => { + const exception = new CompaniesHouseException(message); + + expect(exception.message).toBe(message); + }); + + it('exposes the name of the exception', () => { + const exception = new CompaniesHouseException(message); + + expect(exception.name).toBe('CompaniesHouseException'); + }); + + it('exposes the inner error it was created with', () => { + const innerError = new Error(); + + const exception = new CompaniesHouseException(message, innerError); + + expect(exception.innerError).toBe(innerError); + }); +}); diff --git a/src/helper-modules/companies-house/exception/companies-house.exception.ts b/src/helper-modules/companies-house/exception/companies-house.exception.ts new file mode 100644 index 00000000..0b79b96e --- /dev/null +++ b/src/helper-modules/companies-house/exception/companies-house.exception.ts @@ -0,0 +1,9 @@ +export class CompaniesHouseException extends Error { + constructor( + message: string, + public readonly innerError?: Error, + ) { + super(message); + this.name = this.constructor.name; + } +} diff --git a/src/helper-modules/companies-house/known-errors.ts b/src/helper-modules/companies-house/known-errors.ts new file mode 100644 index 00000000..d39643a2 --- /dev/null +++ b/src/helper-modules/companies-house/known-errors.ts @@ -0,0 +1,33 @@ +import { AxiosError } from 'axios'; + +import { CompaniesHouseInvalidAuthorizationException } from './exception/companies-house-invalid-authorization.exception'; +import { CompaniesHouseMalformedAuthorizationHeaderException } from './exception/companies-house-malformed-authorization-header.exception'; +import { CompaniesHouseNotFoundException } from './exception/companies-house-not-found.exception'; + +export type KnownErrors = KnownError[]; + +type KnownError = { caseInsensitiveSubstringToFind: string; throwError: (error: AxiosError) => never }; + +export const getCompanyMalformedAuthorizationHeaderKnownCompaniesHouseError = (): KnownError => ({ + caseInsensitiveSubstringToFind: 'Invalid Authorization header', + throwError: (error) => { + throw new CompaniesHouseMalformedAuthorizationHeaderException( + `Invalid 'Authorization' header. Check that your 'Authorization' header is well-formed.`, + error, + ); + }, +}); + +export const getCompanyInvalidAuthorizationKnownCompaniesHouseError = (): KnownError => ({ + caseInsensitiveSubstringToFind: 'Invalid Authorization', + throwError: (error) => { + throw new CompaniesHouseInvalidAuthorizationException('Invalid authorization. Check your Companies House API key.', error); + }, +}); + +export const getCompanyNotFoundKnownCompaniesHouseError = (registrationNumber: string): KnownError => ({ + caseInsensitiveSubstringToFind: 'company-profile-not-found', + throwError: (error) => { + throw new CompaniesHouseNotFoundException(`Company with registration number ${registrationNumber} was not found.`, error); + }, +}); diff --git a/src/helper-modules/companies-house/wrap-companies-house-http-error-callback.ts b/src/helper-modules/companies-house/wrap-companies-house-http-error-callback.ts new file mode 100644 index 00000000..9f031788 --- /dev/null +++ b/src/helper-modules/companies-house/wrap-companies-house-http-error-callback.ts @@ -0,0 +1,34 @@ +import { AxiosError } from 'axios'; +import { ObservableInput, throwError } from 'rxjs'; + +import { CompaniesHouseException } from './exception/companies-house.exception'; +import { KnownErrors } from './known-errors'; + +type CompaniesHouseHttpErrorCallback = (error: Error) => ObservableInput; + +export const createWrapCompaniesHouseHttpGetErrorCallback = + ({ messageForUnknownError, knownErrors }: { messageForUnknownError: string; knownErrors: KnownErrors }): CompaniesHouseHttpErrorCallback => + (error: Error) => { + if (error instanceof AxiosError && error.response && typeof error.response.data === 'object') { + const errorResponseData = error.response.data; + let errorMessage: string; + + if (typeof errorResponseData.error === 'string') { + errorMessage = errorResponseData.error; + } else if (errorResponseData.errors && errorResponseData.errors[0] && typeof errorResponseData.errors[0].error === 'string') { + errorMessage = errorResponseData.errors[0].error; + } + + if (errorMessage) { + const errorMessageInLowerCase = errorMessage.toLowerCase(); + + knownErrors.forEach(({ caseInsensitiveSubstringToFind, throwError }) => { + if (errorMessageInLowerCase.includes(caseInsensitiveSubstringToFind.toLowerCase())) { + return throwError(error); + } + }); + } + } + + return throwError(() => new CompaniesHouseException(messageForUnknownError, error)); + }; diff --git a/src/modules/companies/companies.controller.test.ts b/src/modules/companies/companies.controller.test.ts new file mode 100644 index 00000000..36658385 --- /dev/null +++ b/src/modules/companies/companies.controller.test.ts @@ -0,0 +1,51 @@ +import { GetCompanyGenerator } from '@ukef-test/support/generator/get-company-generator'; +import { RandomValueGenerator } from '@ukef-test/support/generator/random-value-generator'; +import { resetAllWhenMocks, when } from 'jest-when'; + +import { CompaniesController } from './companies.controller'; +import { CompaniesService } from './companies.service'; + +describe('CompaniesController', () => { + let companiesServiceGetCompanyByRegistrationNumber: jest.Mock; + let controller: CompaniesController; + + const valueGenerator = new RandomValueGenerator(); + + const testRegistrationNumber = '00000001'; + + const { getCompanyResponse } = new GetCompanyGenerator(valueGenerator).generate({ + numberToGenerate: 1, + registrationNumber: testRegistrationNumber, + }); + + beforeAll(() => { + const companiesService = new CompaniesService(null, null); + companiesServiceGetCompanyByRegistrationNumber = jest.fn(); + companiesService.getCompanyByRegistrationNumber = companiesServiceGetCompanyByRegistrationNumber; + + controller = new CompaniesController(companiesService); + }); + + beforeEach(() => { + resetAllWhenMocks(); + }); + + describe('getCompanyByRegistrationNumber', () => { + it('calls getCompanyByRegistrationNumber on the CompaniesService with the registration number', async () => { + when(companiesServiceGetCompanyByRegistrationNumber).calledWith(testRegistrationNumber).mockReturnValueOnce(getCompanyResponse); + + await controller.getCompanyByRegistrationNumber({ registrationNumber: testRegistrationNumber }); + + expect(companiesServiceGetCompanyByRegistrationNumber).toHaveBeenCalledTimes(1); + expect(companiesServiceGetCompanyByRegistrationNumber).toHaveBeenCalledWith(testRegistrationNumber); + }); + + it('returns the company from the service', async () => { + when(companiesServiceGetCompanyByRegistrationNumber).calledWith(testRegistrationNumber).mockReturnValueOnce(getCompanyResponse); + + const response = await controller.getCompanyByRegistrationNumber({ registrationNumber: testRegistrationNumber }); + + expect(response).toEqual(getCompanyResponse); + }); + }); +}); diff --git a/src/modules/companies/companies.controller.ts b/src/modules/companies/companies.controller.ts new file mode 100644 index 00000000..4bd12591 --- /dev/null +++ b/src/modules/companies/companies.controller.ts @@ -0,0 +1,45 @@ +import { Controller, Get, Query } from '@nestjs/common'; +import { + ApiBadRequestResponse, + ApiInternalServerErrorResponse, + ApiNotFoundResponse, + ApiOperation, + ApiResponse, + ApiTags, + ApiUnprocessableEntityResponse, +} from '@nestjs/swagger'; + +import { CompaniesService } from './companies.service'; +import { GetCompanyByRegistrationNumberQuery } from './dto/get-company-by-registration-number-query.dto'; +import { GetCompanyResponse } from './dto/get-company-response.dto'; + +@ApiTags('companies') +@Controller('companies') +export class CompaniesController { + constructor(private readonly companiesService: CompaniesService) {} + + @Get() + @ApiOperation({ + summary: 'Get company by Companies House registration number.', + }) + @ApiResponse({ + status: 200, + description: 'Returns the company matching the Companies House registration number.', + type: GetCompanyResponse, + }) + @ApiBadRequestResponse({ + description: 'Invalid Companies House registration number.', + }) + @ApiNotFoundResponse({ + description: 'Company not found.', + }) + @ApiUnprocessableEntityResponse({ + description: 'Company is an overseas company. UKEF can only process applications from companies based in the UK.', + }) + @ApiInternalServerErrorResponse({ + description: 'Internal server error.', + }) + getCompanyByRegistrationNumber(@Query() query: GetCompanyByRegistrationNumberQuery): Promise { + return this.companiesService.getCompanyByRegistrationNumber(query.registrationNumber); + } +} diff --git a/src/modules/companies/companies.module.ts b/src/modules/companies/companies.module.ts new file mode 100644 index 00000000..65955e6a --- /dev/null +++ b/src/modules/companies/companies.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { CompaniesHouseModule } from '@ukef/helper-modules/companies-house/companies-house.module'; + +import { SectorIndustriesModule } from '../sector-industries/sector-industries.module'; +import { CompaniesController } from './companies.controller'; +import { CompaniesService } from './companies.service'; + +@Module({ + imports: [CompaniesHouseModule, SectorIndustriesModule], + controllers: [CompaniesController], + providers: [CompaniesService], +}) +export class CompaniesModule {} diff --git a/src/modules/companies/companies.service.test.ts b/src/modules/companies/companies.service.test.ts new file mode 100644 index 00000000..652d37d6 --- /dev/null +++ b/src/modules/companies/companies.service.test.ts @@ -0,0 +1,135 @@ +import { NotFoundException, UnprocessableEntityException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { CompaniesHouseService } from '@ukef/helper-modules/companies-house/companies-house.service'; +import { CompaniesHouseException } from '@ukef/helper-modules/companies-house/exception/companies-house.exception'; +import { CompaniesHouseInvalidAuthorizationException } from '@ukef/helper-modules/companies-house/exception/companies-house-invalid-authorization.exception'; +import { CompaniesHouseMalformedAuthorizationHeaderException } from '@ukef/helper-modules/companies-house/exception/companies-house-malformed-authorization-header.exception'; +import { CompaniesHouseNotFoundException } from '@ukef/helper-modules/companies-house/exception/companies-house-not-found.exception'; +import { GetCompanyGenerator } from '@ukef-test/support/generator/get-company-generator'; +import { RandomValueGenerator } from '@ukef-test/support/generator/random-value-generator'; +import { resetAllWhenMocks, when } from 'jest-when'; + +import { SectorIndustriesService } from '../sector-industries/sector-industries.service'; +import { CompaniesService } from './companies.service'; +import { CompaniesOverseasCompanyException } from './exception/companies-overseas-company-exception.exception'; + +describe('CompaniesService', () => { + let configServiceGet: jest.Mock; + let companiesHouseServiceGetCompanyByRegistrationNumber: jest.Mock; + let sectorIndustriesServiceFind: jest.Mock; + let service: CompaniesService; + + const valueGenerator = new RandomValueGenerator(); + + const testRegistrationNumber = '00000001'; + + const { getCompanyCompaniesHouseResponse, findSectorIndustriesResponse, getCompanyResponse, getCompanyCompaniesHouseOverseasCompanyResponse } = + new GetCompanyGenerator(valueGenerator).generate({ + numberToGenerate: 1, + registrationNumber: testRegistrationNumber, + }); + + beforeAll(() => { + const configService = new ConfigService(); + configServiceGet = jest.fn().mockReturnValue({ key: valueGenerator.word() }); + configService.get = configServiceGet; + + companiesHouseServiceGetCompanyByRegistrationNumber = jest.fn(); + const companiesHouseService = new CompaniesHouseService(null, configService); + companiesHouseService.getCompanyByRegistrationNumber = companiesHouseServiceGetCompanyByRegistrationNumber; + + sectorIndustriesServiceFind = jest.fn(); + const sectorIndustriesService = new SectorIndustriesService(null, null); + sectorIndustriesService.find = sectorIndustriesServiceFind; + + service = new CompaniesService(companiesHouseService, sectorIndustriesService); + }); + + beforeEach(() => { + resetAllWhenMocks(); + jest.clearAllMocks(); + }); + + describe('getCompanyByRegistrationNumber', () => { + it('calls getCompanyByRegistrationNumber on the CompaniesHouseService with the registration number', async () => { + when(companiesHouseServiceGetCompanyByRegistrationNumber).calledWith(testRegistrationNumber).mockReturnValueOnce(getCompanyCompaniesHouseResponse); + when(sectorIndustriesServiceFind).calledWith(null, null).mockReturnValueOnce(findSectorIndustriesResponse); + + await service.getCompanyByRegistrationNumber(testRegistrationNumber); + + expect(companiesHouseServiceGetCompanyByRegistrationNumber).toHaveBeenCalledTimes(1); + expect(companiesHouseServiceGetCompanyByRegistrationNumber).toHaveBeenCalledWith(testRegistrationNumber); + }); + + it(`calls find on the SectorIndustriesService with both arguments as 'null'`, async () => { + when(companiesHouseServiceGetCompanyByRegistrationNumber).calledWith(testRegistrationNumber).mockReturnValueOnce(getCompanyCompaniesHouseResponse); + when(sectorIndustriesServiceFind).calledWith(null, null).mockReturnValueOnce(findSectorIndustriesResponse); + + await service.getCompanyByRegistrationNumber(testRegistrationNumber); + + expect(sectorIndustriesServiceFind).toHaveBeenCalledTimes(1); + expect(sectorIndustriesServiceFind).toHaveBeenCalledWith(null, null); + }); + + it('returns a mapped form of the company returned by the CompaniesHouseService', async () => { + when(companiesHouseServiceGetCompanyByRegistrationNumber).calledWith(testRegistrationNumber).mockReturnValueOnce(getCompanyCompaniesHouseResponse); + when(sectorIndustriesServiceFind).calledWith(null, null).mockReturnValueOnce(findSectorIndustriesResponse); + + const response = await service.getCompanyByRegistrationNumber(testRegistrationNumber); + + expect(response).toEqual(getCompanyResponse); + }); + + it('throws a NotFoundException if the call to getCompanyByRegistrationNumber on the CompaniesHouseService throws a CompaniesHouseNotFoundException', async () => { + const companiesHouseNotFoundException = new CompaniesHouseNotFoundException(`Company with registration number ${testRegistrationNumber} was not found.`); + when(companiesHouseServiceGetCompanyByRegistrationNumber).calledWith(testRegistrationNumber).mockRejectedValueOnce(companiesHouseNotFoundException); + + const getCompanyPromise = service.getCompanyByRegistrationNumber(testRegistrationNumber); + + await expect(getCompanyPromise).rejects.toBeInstanceOf(NotFoundException); + await expect(getCompanyPromise).rejects.toThrow('Not found'); + await expect(getCompanyPromise).rejects.toHaveProperty('cause', companiesHouseNotFoundException); + }); + + it('throws an UnprocessableEntityException if the CompaniesHouseService returns an overseas company', async () => { + const companiesOverseasCompanyException = new CompaniesOverseasCompanyException( + `Company with registration number ${testRegistrationNumber} is an overseas company. UKEF can only process applications from companies based in the UK.`, + ); + when(companiesHouseServiceGetCompanyByRegistrationNumber) + .calledWith(testRegistrationNumber) + .mockReturnValueOnce(getCompanyCompaniesHouseOverseasCompanyResponse); + + const getCompanyPromise = service.getCompanyByRegistrationNumber(testRegistrationNumber); + + await expect(getCompanyPromise).rejects.toBeInstanceOf(UnprocessableEntityException); + await expect(getCompanyPromise).rejects.toThrow('Unprocessable entity'); + await expect(getCompanyPromise).rejects.toHaveProperty('cause', companiesOverseasCompanyException); + }); + + it.each([ + { + exceptionName: 'CompaniesHouseMalformedAuthorizationHeaderException', + exceptionInstance: new CompaniesHouseMalformedAuthorizationHeaderException( + `Invalid 'Authorization' header. Check that your 'Authorization' header is well-formed.`, + ), + }, + { + exceptionName: 'CompaniesHouseInvalidAuthorizationException', + exceptionInstance: new CompaniesHouseInvalidAuthorizationException('Invalid authorization. Check your Companies House API key.'), + }, + { + exceptionName: 'CompaniesHouseException', + exceptionInstance: new CompaniesHouseException('Failed to get response from Companies House API.'), + }, + ])( + 'rethrows the error if the call to getCompanyByRegistrationNumber on the CompaniesHouseService throws a $exceptionName', + async ({ exceptionInstance }) => { + when(companiesHouseServiceGetCompanyByRegistrationNumber).calledWith(testRegistrationNumber).mockRejectedValueOnce(exceptionInstance); + + const getCompanyPromise = service.getCompanyByRegistrationNumber(testRegistrationNumber); + + await expect(getCompanyPromise).rejects.toBe(exceptionInstance); + }, + ); + }); +}); diff --git a/src/modules/companies/companies.service.ts b/src/modules/companies/companies.service.ts new file mode 100644 index 00000000..9bf38ca7 --- /dev/null +++ b/src/modules/companies/companies.service.ts @@ -0,0 +1,88 @@ +import { Injectable, NotFoundException, UnprocessableEntityException } from '@nestjs/common'; +import { CompaniesHouseService } from '@ukef/helper-modules/companies-house/companies-house.service'; +import { GetCompanyCompaniesHouseResponse } from '@ukef/helper-modules/companies-house/dto/get-company-companies-house-response.dto'; +import { CompaniesHouseNotFoundException } from '@ukef/helper-modules/companies-house/exception/companies-house-not-found.exception'; + +import { SectorIndustryEntity } from '../sector-industries/entities/sector-industry.entity'; +import { SectorIndustriesService } from '../sector-industries/sector-industries.service'; +import { GetCompanyResponse, Industry } from './dto/get-company-response.dto'; +import { CompaniesOverseasCompanyException } from './exception/companies-overseas-company-exception.exception'; + +@Injectable() +export class CompaniesService { + constructor( + private readonly companiesHouseService: CompaniesHouseService, + private readonly sectorIndustriesService: SectorIndustriesService, + ) {} + + async getCompanyByRegistrationNumber(registrationNumber: string): Promise { + try { + const company: GetCompanyCompaniesHouseResponse = await this.companiesHouseService.getCompanyByRegistrationNumber(registrationNumber); + this.validateCompanyIsUkCompany(company, registrationNumber); + + const industryClasses: SectorIndustryEntity[] = await this.sectorIndustriesService.find(null, null); + + const mappedCompany = this.mapCompany(company, industryClasses); + + return mappedCompany; + } catch (error) { + if (error instanceof CompaniesHouseNotFoundException) { + throw new NotFoundException('Not found', { cause: error }); + } + + if (error instanceof CompaniesOverseasCompanyException) { + throw new UnprocessableEntityException('Unprocessable entity', { cause: error }); + } + + throw error; + } + } + + private validateCompanyIsUkCompany(company: GetCompanyCompaniesHouseResponse, registrationNumber: string): never | undefined { + if (company.type.includes('oversea')) { + throw new CompaniesOverseasCompanyException( + `Company with registration number ${registrationNumber} is an overseas company. UKEF can only process applications from companies based in the UK.`, + ); + } + } + + private mapCompany(company: GetCompanyCompaniesHouseResponse, industryClasses: SectorIndustryEntity[]): GetCompanyResponse { + const address = company.registered_office_address; + + return { + companiesHouseRegistrationNumber: company.company_number, + companyName: company.company_name, + registeredAddress: { + organisationName: address.organisation_name, + addressLine1: address.address_line_1, + addressLine2: address.address_line_2, + addressLine3: address.address_line_3, + locality: address.locality, + postalCode: address.postal_code, + country: address.country, + }, + industries: this.mapSicCodes(company.sic_codes, industryClasses), + }; + } + + private mapSicCodes(sicCodes: string[], industryClasses: SectorIndustryEntity[]): Industry[] { + const industries = []; + + sicCodes.forEach((sicCode) => { + industryClasses.forEach((industryClass) => { + if (sicCode === industryClass.ukefIndustryId) { + industries.push({ + code: industryClass.ukefSectorId.toString(), + name: industryClass.ukefSectorName, + class: { + code: industryClass.ukefIndustryId, + name: industryClass.ukefIndustryName, + }, + }); + } + }); + }); + + return industries; + } +} diff --git a/src/modules/companies/dto/get-company-by-registration-number-query.dto.ts b/src/modules/companies/dto/get-company-by-registration-number-query.dto.ts new file mode 100644 index 00000000..e6f60899 --- /dev/null +++ b/src/modules/companies/dto/get-company-by-registration-number-query.dto.ts @@ -0,0 +1,17 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { COMPANIES } from '@ukef/constants'; +import { Matches, MaxLength, MinLength } from 'class-validator'; + +export class GetCompanyByRegistrationNumberQuery { + @ApiProperty({ + description: 'The Companies House registration number of the company to find.', + example: COMPANIES.EXAMPLES.COMPANIES_HOUSE_REGISTRATION_NUMBER, + minLength: 7, + maxLength: 8, + pattern: COMPANIES.REGEX.COMPANIES_HOUSE_REGISTRATION_NUMBER.source, + }) + @MinLength(7) + @MaxLength(8) + @Matches(COMPANIES.REGEX.COMPANIES_HOUSE_REGISTRATION_NUMBER) + public registrationNumber: string; +} diff --git a/src/modules/companies/dto/get-company-response.dto.ts b/src/modules/companies/dto/get-company-response.dto.ts new file mode 100644 index 00000000..21c217b1 --- /dev/null +++ b/src/modules/companies/dto/get-company-response.dto.ts @@ -0,0 +1,23 @@ +export class GetCompanyResponse { + companiesHouseRegistrationNumber: string; + companyName: string; + registeredAddress: { + organisationName?: string; + addressLine1: string; + addressLine2?: string; + addressLine3?: string; + locality: string; + postalCode: string; + country: string; + }; + industries: Industry[]; +} + +export class Industry { + code: string; + name: string; + class: { + code: string; + name: string; + }; +} diff --git a/src/modules/companies/examples/example-response-for-get-company-by-registration-number.json b/src/modules/companies/examples/example-response-for-get-company-by-registration-number.json new file mode 100644 index 00000000..7087668a --- /dev/null +++ b/src/modules/companies/examples/example-response-for-get-company-by-registration-number.json @@ -0,0 +1,44 @@ +{ + "companiesHouseRegistrationNumber": "00000001", + "companyName": "TEST COMPANY LTD", + "registeredAddress": { + "addressLine1": "1 Test Street", + "locality": "Test City", + "postalCode": "A1 2BC", + "country": "United Kingdom" + }, + "industries": [ + { + "class": { + "code": "59112", + "name": "Video production activities" + }, + "code": "1009", + "name": "Information and communication" + }, + { + "class": { + "code": "62012", + "name": "Business and domestic software development" + }, + "code": "1009", + "name": "Information and communication" + }, + { + "class": { + "code": "62020", + "name": "Information technology consultancy activities" + }, + "code": "1009", + "name": "Information and communication" + }, + { + "class": { + "code": "62090", + "name": "Other information technology service activities" + }, + "code": "1009", + "name": "Information and communication" + } + ] +} \ No newline at end of file diff --git a/src/modules/companies/exception/companies-overseas-company-exception.exception.ts b/src/modules/companies/exception/companies-overseas-company-exception.exception.ts new file mode 100644 index 00000000..f6f268c3 --- /dev/null +++ b/src/modules/companies/exception/companies-overseas-company-exception.exception.ts @@ -0,0 +1,9 @@ +export class CompaniesOverseasCompanyException extends Error { + constructor( + message: string, + public readonly innerError?: Error, + ) { + super(message); + this.name = this.constructor.name; + } +} diff --git a/src/modules/companies/exception/companies-overseas-company.exception.test.ts b/src/modules/companies/exception/companies-overseas-company.exception.test.ts new file mode 100644 index 00000000..71531ddb --- /dev/null +++ b/src/modules/companies/exception/companies-overseas-company.exception.test.ts @@ -0,0 +1,28 @@ +import { RandomValueGenerator } from '@ukef-test/support/generator/random-value-generator'; + +import { CompaniesOverseasCompanyException } from './companies-overseas-company-exception.exception'; + +describe('CompaniesOverseasCompanyException', () => { + const valueGenerator = new RandomValueGenerator(); + const message = valueGenerator.string(); + + it('exposes the message it was created with', () => { + const exception = new CompaniesOverseasCompanyException(message); + + expect(exception.message).toBe(message); + }); + + it('exposes the name of the exception', () => { + const exception = new CompaniesOverseasCompanyException(message); + + expect(exception.name).toBe('CompaniesOverseasCompanyException'); + }); + + it('exposes the inner error it was created with', () => { + const innerError = new Error(); + + const exception = new CompaniesOverseasCompanyException(message, innerError); + + expect(exception.innerError).toBe(innerError); + }); +}); diff --git a/src/modules/informatica/wrap-informatica-http-error-callback.ts b/src/modules/informatica/wrap-informatica-http-error-callback.ts index 7ecc9618..4025a6fc 100644 --- a/src/modules/informatica/wrap-informatica-http-error-callback.ts +++ b/src/modules/informatica/wrap-informatica-http-error-callback.ts @@ -4,10 +4,10 @@ import { ObservableInput, throwError } from 'rxjs'; import { InformaticaException } from './exception/informatica.exception'; import { KnownErrors } from './known-errors'; -type AcbsHttpErrorCallback = (error: Error) => ObservableInput; +type InformaticaHttpErrorCallback = (error: Error) => ObservableInput; export const createWrapInformaticaHttpGetErrorCallback = - ({ messageForUnknownError, knownErrors }: { messageForUnknownError: string; knownErrors: KnownErrors }): AcbsHttpErrorCallback => + ({ messageForUnknownError, knownErrors }: { messageForUnknownError: string; knownErrors: KnownErrors }): InformaticaHttpErrorCallback => (error: Error) => { let errorString; if (error instanceof AxiosError && error.response) { diff --git a/src/modules/mdm.module.ts b/src/modules/mdm.module.ts index 790a1510..72a74830 100644 --- a/src/modules/mdm.module.ts +++ b/src/modules/mdm.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { AuthModule } from '@ukef/auth/auth.module'; import { DatabaseModule } from '@ukef/database/database.module'; +import { CompaniesModule } from '@ukef/modules/companies/companies.module'; import { CurrenciesModule } from '@ukef/modules/currencies/currencies.module'; import { CustomersModule } from '@ukef/modules/customers/customers.module'; import { ExposurePeriodModule } from '@ukef/modules/exposure-period/exposure-period.module'; @@ -28,6 +29,7 @@ import { YieldRatesModule } from '@ukef/modules/yield-rates/yield-rates.module'; SectorIndustriesModule, YieldRatesModule, GeospatialModule, + CompaniesModule, ], exports: [ AuthModule, @@ -43,6 +45,7 @@ import { YieldRatesModule } from '@ukef/modules/yield-rates/yield-rates.module'; SectorIndustriesModule, YieldRatesModule, GeospatialModule, + CompaniesModule, ], }) export class MdmModule {} diff --git a/src/modules/sector-industries/sector-industries.module.ts b/src/modules/sector-industries/sector-industries.module.ts index 00f8b6eb..6a0082ec 100644 --- a/src/modules/sector-industries/sector-industries.module.ts +++ b/src/modules/sector-industries/sector-industries.module.ts @@ -10,5 +10,6 @@ import { SectorIndustriesService } from './sector-industries.service'; imports: [TypeOrmModule.forFeature([SectorIndustryEntity], DATABASE.MDM)], controllers: [SectorIndustriesController], providers: [SectorIndustriesService], + exports: [SectorIndustriesService], }) export class SectorIndustriesModule {} diff --git a/test/companies/get-company-by-registration-number.api-test.ts b/test/companies/get-company-by-registration-number.api-test.ts new file mode 100644 index 00000000..b177d096 --- /dev/null +++ b/test/companies/get-company-by-registration-number.api-test.ts @@ -0,0 +1,168 @@ +import { IncorrectAuthArg, withClientAuthenticationTests } from '@ukef-test/common-tests/client-authentication-api-tests'; +import { Api } from '@ukef-test/support/api'; +import { ENVIRONMENT_VARIABLES, TIME_EXCEEDING_COMPANIES_HOUSE_TIMEOUT } from '@ukef-test/support/environment-variables'; +import { GetCompanyGenerator } from '@ukef-test/support/generator/get-company-generator'; +import { RandomValueGenerator } from '@ukef-test/support/generator/random-value-generator'; +import nock from 'nock'; +import getCompanyCompaniesHouseResponse = require('@ukef/helper-modules/companies-house/examples/example-response-for-get-company-by-registration-number.json'); +import getCompanyResponse = require('@ukef/modules/companies/examples/example-response-for-get-company-by-registration-number.json'); +import getCompanyCompaniesHouseOverseasCompanyResponse = require('@ukef/helper-modules/companies-house/examples/example-response-for-get-company-by-registration-number-overseas-company.json'); + +describe('GET /companies?registrationNumber=', () => { + let api: Api; + + const valueGenerator = new RandomValueGenerator(); + + const { + companiesHousePath, + mdmPath, + getCompanyCompaniesHouseMalformedAuthorizationHeaderResponse, + getCompanyCompaniesHouseInvalidAuthorizationResponse, + getCompanyCompaniesHouseNotFoundResponse, + } = new GetCompanyGenerator(valueGenerator).generate({ + numberToGenerate: 1, + registrationNumber: '00000001', + }); + + const getCompaniesHousePath = (registrationNumber: string) => `/company/${registrationNumber}`; + const getMdmPath = (registrationNumber: string) => `/api/v1/companies?registrationNumber=${encodeURIComponent(registrationNumber)}`; + + beforeAll(async () => { + api = await Api.create(); + }); + + afterAll(async () => { + await api.destroy(); + }); + + afterEach(() => { + nock.abortPendingRequests(); + nock.cleanAll(); + }); + + // MDM auth tests + withClientAuthenticationTests({ + givenTheRequestWouldOtherwiseSucceed: () => { + requestToGetCompanyByRegistrationNumber(companiesHousePath).reply(200, getCompanyCompaniesHouseResponse); + }, + makeRequestWithoutAuth: (incorrectAuth?: IncorrectAuthArg) => api.getWithoutAuth(mdmPath, incorrectAuth?.headerName, incorrectAuth?.headerValue), + }); + + it('returns a 200 response with the company if it is returned by the Companies House API', async () => { + requestToGetCompanyByRegistrationNumber(companiesHousePath).reply(200, getCompanyCompaniesHouseResponse); + + const { status, body } = await api.get(mdmPath); + + expect(status).toBe(200); + expect(body).toStrictEqual(getCompanyResponse); + }); + + it.each([ + { + registrationNumber: valueGenerator.stringOfNumericCharacters({ length: 6 }), + validationError: 'registrationNumber must be longer than or equal to 7 characters', + }, + { + registrationNumber: valueGenerator.stringOfNumericCharacters({ length: 9 }), + validationError: 'registrationNumber must be shorter than or equal to 8 characters', + }, + { + registrationNumber: '0A000001', + validationError: 'registrationNumber must match /^(([A-Z]{2}|[A-Z]\\d|\\d{2})(\\d{5,6}|\\d{4,5}[A-Z]))$/ regular expression', + }, + { + registrationNumber: '', + validationError: 'registrationNumber must be longer than or equal to 7 characters', + }, + { + registrationNumber: ' ', + validationError: 'registrationNumber must match /^(([A-Z]{2}|[A-Z]\\d|\\d{2})(\\d{5,6}|\\d{4,5}[A-Z]))$/ regular expression', + }, + ])(`returns a 400 response with validation errors if postcode is '$registrationNumber'`, async ({ registrationNumber, validationError }) => { + const { status, body } = await api.get(getMdmPath(registrationNumber)); + + expect(status).toBe(400); + expect(body).toMatchObject({ + error: 'Bad Request', + message: expect.arrayContaining([validationError]), + statusCode: 400, + }); + }); + + it(`returns a 404 response if the Companies House API returns a 404 response containing the error string 'company-profile-not-found'`, async () => { + requestToGetCompanyByRegistrationNumber(companiesHousePath).reply(404, getCompanyCompaniesHouseNotFoundResponse); + + const { status, body } = await api.get(mdmPath); + + expect(status).toBe(404); + expect(body).toStrictEqual({ + statusCode: 404, + message: 'Not found', + }); + }); + + it('returns a 422 response if the Companies House API returns an overseas company', async () => { + const registrationNumber = 'OE006930'; + + requestToGetCompanyByRegistrationNumber(getCompaniesHousePath(registrationNumber)).reply(200, getCompanyCompaniesHouseOverseasCompanyResponse); + + const { status, body } = await api.get(getMdmPath(registrationNumber)); + + expect(status).toBe(422); + expect(body).toStrictEqual({ + statusCode: 422, + message: 'Unprocessable entity', + }); + }); + + it(`returns a 500 response if the Companies House API returns a 400 response containing the error string 'Invalid Authorization header'`, async () => { + requestToGetCompanyByRegistrationNumber(companiesHousePath).reply(400, getCompanyCompaniesHouseMalformedAuthorizationHeaderResponse); + + const { status, body } = await api.get(mdmPath); + + expect(status).toBe(500); + expect(body).toStrictEqual({ + statusCode: 500, + message: 'Internal server error', + }); + }); + + it(`returns a 500 response if the Companies House API returns a 401 response containing the error string 'Invalid Authorization'`, async () => { + requestToGetCompanyByRegistrationNumber(companiesHousePath).reply(401, getCompanyCompaniesHouseInvalidAuthorizationResponse); + + const { status, body } = await api.get(mdmPath); + + expect(status).toBe(500); + expect(body).toStrictEqual({ + statusCode: 500, + message: 'Internal server error', + }); + }); + + it('returns a 500 response if the request to the Companies House API times out', async () => { + requestToGetCompanyByRegistrationNumber(companiesHousePath).delay(TIME_EXCEEDING_COMPANIES_HOUSE_TIMEOUT).reply(200, getCompanyCompaniesHouseResponse); + + const { status, body } = await api.get(mdmPath); + + expect(status).toBe(500); + expect(body).toStrictEqual({ + statusCode: 500, + message: 'Internal server error', + }); + }); + + it('returns a 500 response if the request to the Companies House API returns an unhandled error response', async () => { + requestToGetCompanyByRegistrationNumber(companiesHousePath).reply(418, `I'm a teapot`); + + const { status, body } = await api.get(mdmPath); + + expect(status).toBe(500); + expect(body).toStrictEqual({ + statusCode: 500, + message: 'Internal server error', + }); + }); + + const requestToGetCompanyByRegistrationNumber = (companiesHousePath: string): nock.Interceptor => + nock(ENVIRONMENT_VARIABLES.COMPANIES_HOUSE_URL).get(companiesHousePath); +}); diff --git a/test/support/environment-variables.ts b/test/support/environment-variables.ts index 5f57fa07..9cc531cc 100644 --- a/test/support/environment-variables.ts +++ b/test/support/environment-variables.ts @@ -23,6 +23,12 @@ export const ENVIRONMENT_VARIABLES = Object.freeze({ ORDNANCE_SURVEY_MAX_REDIRECTS: 0, ORDNANCE_SURVEY_TIMEOUT: 5000, + COMPANIES_HOUSE_URL: valueGenerator.httpsUrl(), + COMPANIES_HOUSE_KEY: valueGenerator.word(), + + COMPANIES_HOUSE_MAX_REDIRECTS: 0, + COMPANIES_HOUSE_TIMEOUT: 5000, + API_KEY: valueGenerator.string(), }); @@ -34,7 +40,10 @@ export const getEnvironmentVariablesForProcessEnv = (): NodeJS.ProcessEnv => ({ APIM_INFORMATICA_TIMEOUT: ENVIRONMENT_VARIABLES.APIM_INFORMATICA_TIMEOUT.toString(), ORDNANCE_SURVEY_MAX_REDIRECTS: ENVIRONMENT_VARIABLES.ORDNANCE_SURVEY_MAX_REDIRECTS.toString(), ORDNANCE_SURVEY_TIMEOUT: ENVIRONMENT_VARIABLES.ORDNANCE_SURVEY_TIMEOUT.toString(), + COMPANIES_HOUSE_MAX_REDIRECTS: ENVIRONMENT_VARIABLES.COMPANIES_HOUSE_MAX_REDIRECTS.toString(), + COMPANIES_HOUSE_TIMEOUT: ENVIRONMENT_VARIABLES.COMPANIES_HOUSE_TIMEOUT.toString(), }); export const TIME_EXCEEDING_INFORMATICA_TIMEOUT = ENVIRONMENT_VARIABLES.APIM_INFORMATICA_TIMEOUT + 500; export const TIME_EXCEEDING_ORDNANCE_SURVEY_TIMEOUT = ENVIRONMENT_VARIABLES.ORDNANCE_SURVEY_TIMEOUT + 500; +export const TIME_EXCEEDING_COMPANIES_HOUSE_TIMEOUT = ENVIRONMENT_VARIABLES.ORDNANCE_SURVEY_TIMEOUT + 500; diff --git a/test/support/generator/get-company-generator.ts b/test/support/generator/get-company-generator.ts new file mode 100644 index 00000000..865293e1 --- /dev/null +++ b/test/support/generator/get-company-generator.ts @@ -0,0 +1,240 @@ +import { COMPANIES } from '@ukef/constants'; +import { GetCompanyCompaniesHouseErrorResponse } from '@ukef/helper-modules/companies-house/dto/get-company-companies-house-error-response.dto'; +import { GetCompanyCompaniesHouseMultipleErrorResponse } from '@ukef/helper-modules/companies-house/dto/get-company-companies-house-multiple-error-response.dto'; +import { GetCompanyCompaniesHouseResponse } from '@ukef/helper-modules/companies-house/dto/get-company-companies-house-response.dto'; +import { GetCompanyResponse, Industry } from '@ukef/modules/companies/dto/get-company-response.dto'; +import { SectorIndustryEntity } from '@ukef/modules/sector-industries/entities/sector-industry.entity'; + +import { AbstractGenerator } from './abstract-generator'; +import { RandomValueGenerator } from './random-value-generator'; + +export class GetCompanyGenerator extends AbstractGenerator { + constructor(protected readonly valueGenerator: RandomValueGenerator) { + super(valueGenerator); + } + + protected generateValues(): CompanyValues { + return { + companiesHouseRegistrationNumber: this.valueGenerator.stringOfNumericCharacters({ length: 8 }), + companyName: this.valueGenerator.sentence({ words: 2 }), + buildingName: this.valueGenerator.sentence({ words: 2 }), + buildingNumber: this.valueGenerator.nonnegativeInteger({ max: 99 }).toString(), + thoroughfareName: this.valueGenerator.sentence({ words: 2 }), + locality: this.valueGenerator.word(), + postalCode: this.valueGenerator.postcode(), + country: this.valueGenerator.word(), + sicCodes: [ + this.valueGenerator.stringOfNumericCharacters({ length: 5 }), + this.valueGenerator.stringOfNumericCharacters({ length: 5 }), + this.valueGenerator.stringOfNumericCharacters({ length: 5 }), + this.valueGenerator.stringOfNumericCharacters({ length: 5 }), + ], + industryClassNames: [ + this.valueGenerator.sentence({ words: 10 }), + this.valueGenerator.sentence({ words: 10 }), + this.valueGenerator.sentence({ words: 10 }), + this.valueGenerator.sentence({ words: 10 }), + ], + industrySectorCode: this.valueGenerator.integer({ min: 1001, max: 1020 }), + industrySectorName: this.valueGenerator.sentence({ words: 4 }), + }; + } + + protected transformRawValuesToGeneratedValues(values: CompanyValues[], { registrationNumber }: GenerateOptions): GenerateResult { + const [v] = values; + const registrationNumberToUse = registrationNumber || v.companiesHouseRegistrationNumber; + + const companiesHousePath = `/company/${registrationNumberToUse}`; + + const mdmPath = `${COMPANIES.ENDPOINT_BASE_URL}${registrationNumberToUse}`; + + const randomDateString = () => this.valueGenerator.date().toISOString().split('T')[0]; + const randomAccountingReferenceDate = this.valueGenerator.date(); + + const shuffleArray = (array: Array) => { + for (const i of [...Array(array.length).keys()].reverse().slice(0, -1)) { + const j = Math.floor(Math.random() * (i + 1)); + [array[i], array[j]] = [array[j], array[i]]; + } + }; + + const getCompanyCompaniesHouseResponse: GetCompanyCompaniesHouseResponse = { + links: { + filing_history: `/company/${registrationNumberToUse}/filing-history`, + self: `/company/${registrationNumberToUse}`, + persons_with_significant_control: `/company/${registrationNumberToUse}/persons-with-significant-control`, + officers: `/company/${registrationNumberToUse}/officers`, + }, + accounts: { + last_accounts: { + period_end_on: randomDateString(), + type: 'micro-entity', + made_up_to: randomDateString(), + period_start_on: randomDateString(), + }, + accounting_reference_date: { + month: (randomAccountingReferenceDate.getMonth() + 1).toString(), + day: randomAccountingReferenceDate.getDate().toString(), + }, + overdue: false, + next_made_up_to: randomDateString(), + next_due: randomDateString(), + next_accounts: { + period_start_on: randomDateString(), + due_on: randomDateString(), + period_end_on: randomDateString(), + overdue: false, + }, + }, + company_name: v.companyName, + company_number: registrationNumberToUse, + company_status: 'active', + confirmation_statement: { + next_made_up_to: randomDateString(), + next_due: randomDateString(), + overdue: false, + last_made_up_to: randomDateString(), + }, + date_of_creation: randomDateString(), + etag: this.valueGenerator.stringOfNumericCharacters({ length: 40 }), + has_charges: false, + has_insolvency_history: false, + jurisdiction: this.valueGenerator.word(), + registered_office_address: { + address_line_1: `${v.buildingName} ${v.buildingNumber} ${v.thoroughfareName}`, + locality: v.locality, + postal_code: v.postalCode, + country: v.country, + }, + registered_office_is_in_dispute: false, + sic_codes: v.sicCodes, + type: 'ltd', + undeliverable_registered_office_address: false, + has_super_secure_pscs: false, + can_file: true, + }; + + const findSectorIndustriesResponse: SectorIndustryEntity[] = v.sicCodes.map((sicCode, index) => ({ + id: this.valueGenerator.integer({ min: 1, max: 1000 }), + ukefSectorId: v.industrySectorCode, + ukefSectorName: v.industrySectorName, + internalNo: null, + ukefIndustryId: sicCode, + ukefIndustryName: v.industryClassNames[index], + acbsSectorId: this.valueGenerator.stringOfNumericCharacters({ length: 2 }), + acbsSectorName: this.valueGenerator.sentence({ words: 5 }), + acbsIndustryId: this.valueGenerator.stringOfNumericCharacters({ length: 2 }), + acbsIndustryName: this.valueGenerator.sentence({ words: 4 }), + created: this.valueGenerator.date(), + updated: this.valueGenerator.date(), + effectiveFrom: this.valueGenerator.date(), + effectiveTo: this.valueGenerator.date(), + })); + + const nonMatchingIndustryClass: SectorIndustryEntity = { + id: this.valueGenerator.integer({ min: 1, max: 1000 }), + ukefSectorId: this.valueGenerator.integer({ min: 1001, max: 1020 }), + ukefSectorName: this.valueGenerator.sentence({ words: 4 }), + internalNo: null, + ukefIndustryId: this.valueGenerator.stringOfNumericCharacters({ length: 5 }), + ukefIndustryName: this.valueGenerator.sentence({ words: 10 }), + acbsSectorId: this.valueGenerator.stringOfNumericCharacters({ length: 2 }), + acbsSectorName: this.valueGenerator.sentence({ words: 5 }), + acbsIndustryId: this.valueGenerator.stringOfNumericCharacters({ length: 2 }), + acbsIndustryName: this.valueGenerator.sentence({ words: 4 }), + created: this.valueGenerator.date(), + updated: this.valueGenerator.date(), + effectiveFrom: this.valueGenerator.date(), + effectiveTo: this.valueGenerator.date(), + }; + + findSectorIndustriesResponse.push(nonMatchingIndustryClass); + shuffleArray(findSectorIndustriesResponse); + + const industries: Industry[] = v.sicCodes.map((sicCode, index) => ({ + code: v.industrySectorCode.toString(), + name: v.industrySectorName, + class: { + code: sicCode, + name: v.industryClassNames[index], + }, + })); + + const getCompanyResponse: GetCompanyResponse = { + companiesHouseRegistrationNumber: registrationNumberToUse, + companyName: v.companyName, + registeredAddress: { + addressLine1: `${v.buildingName} ${v.buildingNumber} ${v.thoroughfareName}`, + locality: v.locality, + postalCode: v.postalCode, + country: v.country, + }, + industries, + }; + + const getCompanyCompaniesHouseOverseasCompanyResponse = structuredClone(getCompanyCompaniesHouseResponse); + getCompanyCompaniesHouseOverseasCompanyResponse.type = 'registered-overseas-entity'; + + const getCompanyCompaniesHouseMalformedAuthorizationHeaderResponse: GetCompanyCompaniesHouseErrorResponse = { + error: 'Invalid Authorization header', + type: 'ch:service', + }; + + const getCompanyCompaniesHouseInvalidAuthorizationResponse: GetCompanyCompaniesHouseErrorResponse = { + error: 'Invalid Authorization', + type: 'ch:service', + }; + + const getCompanyCompaniesHouseNotFoundResponse: GetCompanyCompaniesHouseMultipleErrorResponse = { + errors: [ + { + error: 'company-profile-not-found', + type: 'ch:service', + }, + ], + }; + + return { + companiesHousePath, + mdmPath, + getCompanyCompaniesHouseResponse, + findSectorIndustriesResponse, + getCompanyResponse, + getCompanyCompaniesHouseOverseasCompanyResponse, + getCompanyCompaniesHouseMalformedAuthorizationHeaderResponse, + getCompanyCompaniesHouseInvalidAuthorizationResponse, + getCompanyCompaniesHouseNotFoundResponse, + }; + } +} + +interface CompanyValues { + companiesHouseRegistrationNumber: string; + companyName: string; + buildingName: string; + buildingNumber: string; + thoroughfareName: string; + locality: string; + postalCode: string; + country: string; + sicCodes: string[]; + industryClassNames: string[]; + industrySectorCode: number; + industrySectorName: string; +} + +interface GenerateOptions { + registrationNumber?: string; +} + +interface GenerateResult { + companiesHousePath: string; + mdmPath: string; + getCompanyCompaniesHouseResponse: GetCompanyCompaniesHouseResponse; + findSectorIndustriesResponse: SectorIndustryEntity[]; + getCompanyResponse: GetCompanyResponse; + getCompanyCompaniesHouseOverseasCompanyResponse: GetCompanyCompaniesHouseResponse; + getCompanyCompaniesHouseMalformedAuthorizationHeaderResponse: GetCompanyCompaniesHouseErrorResponse; + getCompanyCompaniesHouseInvalidAuthorizationResponse: GetCompanyCompaniesHouseErrorResponse; + getCompanyCompaniesHouseNotFoundResponse: GetCompanyCompaniesHouseMultipleErrorResponse; +}