From 88b8f3b273bb60ee6f4a73888682fcd29dc7188b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Janik?= Date: Mon, 11 Nov 2024 18:59:09 +0200 Subject: [PATCH] KRP-1232 Compliance questions (#208) * extend request with business * Add new middleware for business idents * Add compliance questions types * Make headers optional when creating business * Add compliance questions logic - list questions - answer question - mark ident as ready for review * Fix lint * Add business identification section * Fix failed rebase * Update src/app.ts * Update src/routes/business/complianceQuestions.ts * CR fixes * CR fixes --------- Co-authored-by: lera --- src/app.ts | 23 ++ src/helpers/middlewares.ts | 41 +++- src/helpers/types.ts | 16 ++ src/routes/business/businesses.ts | 2 +- src/routes/business/complianceQuestions.ts | 136 +++++++++++ src/routes/business/identification.ts | 16 +- src/routes/business/index.ts | 1 + src/templates/business.html | 39 ++++ .../business/complianceQuestions.spec.ts | 219 ++++++++++++++++++ 9 files changed, 486 insertions(+), 7 deletions(-) create mode 100644 src/routes/business/complianceQuestions.ts create mode 100644 tests/routes/business/complianceQuestions.spec.ts diff --git a/src/app.ts b/src/app.ts index 173054cf94..bbd2454bc3 100644 --- a/src/app.ts +++ b/src/app.ts @@ -904,6 +904,29 @@ router.get( safeRequestHandler(businessesAPI.retrieveBusinessIdentification) ); +// COMPLIANCE QUESTIONS + +router.get( + "/businesses/:business_id/identifications/:business_identification_id/legal_identification/questions", + middlewares.withBusiness, + middlewares.withBusinessIdentification, + safeRequestHandler(businessesAPI.listComplianceQuestions) +); + +router.post( + "/businesses/:business_id/identifications/:business_identification_id/legal_identification/questions/:question_id/answers", + middlewares.withBusiness, + middlewares.withBusinessIdentification, + safeRequestHandler(businessesAPI.answerComplianceQuestion) +); + +router.patch( + "/businesses/:business_id/identifications/:business_identification_id/legal_identification/mark_as_ready", + middlewares.withBusiness, + middlewares.withBusinessIdentification, + safeRequestHandler(businessesAPI.markLegalIdentificationAsReady) +); + // COMMERCIAL REGISTRATIONS router.get( diff --git a/src/helpers/middlewares.ts b/src/helpers/middlewares.ts index 1e400c0d41..77d786f5a7 100644 --- a/src/helpers/middlewares.ts +++ b/src/helpers/middlewares.ts @@ -1,11 +1,14 @@ import * as express from "express"; import HttpStatusCodes from "http-status"; import { getPerson, getBusiness } from "../db"; -import { MockPerson, MockBusiness } from "./types"; +import { MockPerson, MockBusiness, BusinessIdentification } from "./types"; import generateID from "./id"; export type RequestWithPerson = express.Request & { person?: MockPerson }; -export type RequestWithBusiness = express.Request & { business?: MockBusiness }; +export type RequestWithBusiness = express.Request & { business: MockBusiness }; +export type RequestWithBusinessIdentification = RequestWithBusiness & { + businessIdentification: BusinessIdentification; +}; export const withBusiness = async ( req: RequestWithBusiness, @@ -102,3 +105,37 @@ export const withAccount = async ( ], }); }; + +export const withBusinessIdentification = async ( + req: RequestWithBusiness, + res: express.Response, + next: express.NextFunction +) => { + const { business } = req; + const businessIdentificationId = + req.params.business_identification_id || + req.params.businessIdentificationId; + + const businessIdentification = (business?.identifications || []).find( + (identification) => identification.id === businessIdentificationId + ); + + if (!businessIdentification) { + res.status(HttpStatusCodes.NOT_FOUND).send({ + errors: [ + { + id: generateID(), + status: 404, + code: "model_not_found", + title: "Model Not Found", + detail: `Couldn't find 'Solaris::BusinessIdentification' for id '${businessIdentificationId}'.`, + }, + ], + }); + return; + } + + (req as RequestWithBusinessIdentification).businessIdentification = + businessIdentification; + next(); +}; diff --git a/src/helpers/types.ts b/src/helpers/types.ts index 0816410261..8e9afd9252 100644 --- a/src/helpers/types.ts +++ b/src/helpers/types.ts @@ -307,6 +307,7 @@ export enum LegalIdentificationStatus { SUCCESSFUL = "successful", FAILED = "failed", EXPIRED = "expired", + PENDING = "pending", } export type LegalRepresentativeIdentification = { @@ -346,8 +347,23 @@ export type BusinessIdentification = { legal_identification_missing_information_details?: string; legal_representatives: LegalRepresentativeIdentificationResponse[]; legal_identification_missing_information: string[]; + meta?: { + complianceQuestions?: ComplianceQuestion[]; + }; }; +export interface ComplianceQuestion { + question_id: string; + question_text: string; + legal_identification_id: string; + business_identification_id: string; + business_id: string; + asked_at: string; + answer_id: string | null; + answer_text: string | null; + answered_at: string | null; +} + export enum LegalRepresentativeType { PERSON = "Person", BUSINESS = "Business", diff --git a/src/routes/business/businesses.ts b/src/routes/business/businesses.ts index 6f5d3310eb..5438fc3bf6 100644 --- a/src/routes/business/businesses.ts +++ b/src/routes/business/businesses.ts @@ -80,7 +80,7 @@ export const createBusiness = async (req, res) => { await storeBusinessInSortedSet(business); - if (req.headers.origin) { + if (req.headers?.origin) { await setBusinessOrigin(businessId, req.headers.origin); } }); diff --git a/src/routes/business/complianceQuestions.ts b/src/routes/business/complianceQuestions.ts new file mode 100644 index 0000000000..420fc15ac9 --- /dev/null +++ b/src/routes/business/complianceQuestions.ts @@ -0,0 +1,136 @@ +import type { Response } from "express"; +import generateID from "../../helpers/id"; +import { RequestWithBusinessIdentification } from "../../helpers/middlewares"; +import { fetchRandomQuestion } from "../../helpers/questionsAndAnswers"; +import { + BusinessIdentificationStatus, + ComplianceQuestion, + LegalIdentificationStatus, +} from "../../helpers/types"; +import { saveBusiness } from "../../db"; + +const QUESTION_COUNT = 2; + +export const listComplianceQuestions = async ( + req: RequestWithBusinessIdentification, + res: Response +) => { + const { businessIdentification, business } = req; + + if (businessIdentification.meta?.complianceQuestions) { + res.status(200).send(businessIdentification.meta.complianceQuestions); + return; + } + + const legalIdentificationId = + businessIdentification.legal_representatives[0].identifications?.[0]?.id; + + const questions: ComplianceQuestion[] = await Promise.all( + Array.from({ length: QUESTION_COUNT }).map(async () => { + const question = await fetchRandomQuestion(); + + return { + question_id: generateID(), + question_text: question, + legal_identification_id: legalIdentificationId, + business_identification_id: businessIdentification.id, + business_id: business.id, + asked_at: new Date().toISOString(), + answer_id: null, + answer_text: null, + answered_at: null, + }; + }) + ); + + businessIdentification.meta = businessIdentification.meta || {}; + businessIdentification.meta.complianceQuestions = questions; + await saveBusiness(business); + + res.status(200).send(questions); +}; + +export const answerComplianceQuestion = async ( + req: RequestWithBusinessIdentification, + res: Response +) => { + const { question_id: questionId } = req.params; + const { business, businessIdentification } = req; + const { text: answerText } = req.body; + + const question = businessIdentification.meta?.complianceQuestions?.find( + (q) => q.question_id === questionId + ); + + if (!question) { + res.status(404).send({ + errors: [ + { + id: generateID(), + status: 404, + code: "model_not_found", + title: "Model Not Found", + detail: `Couldn't find 'ComplianceQuestion' for id '${questionId}'.`, + }, + ], + }); + return; + } + + question.answer_id = generateID(); + question.answer_text = answerText; + question.answered_at = new Date().toISOString(); + + await saveBusiness(business); + + res.status(201).send(question); +}; + +export const markLegalIdentificationAsReady = async ( + req: RequestWithBusinessIdentification, + res: Response +) => { + const { businessIdentification, business } = req; + + const allQuestionsAnswered = + businessIdentification.meta?.complianceQuestions?.every( + (question) => question.answer_id + ); + + if (!allQuestionsAnswered) { + res.status(400).send({ + errors: [ + { + id: generateID(), + status: 400, + code: "missing_answers", + title: "Missing Answers", + detail: "Not all compliance questions are answered.", + }, + ], + }); + return; + } + + businessIdentification.legal_identification_missing_information_details = + null; + businessIdentification.legal_identification_missing_information = []; + businessIdentification.legal_identification_status = + LegalIdentificationStatus.PENDING; + + await saveBusiness(business); + + const response = { + id: generateID(), + status: businessIdentification.status, + identification_id: businessIdentification.id, + business_id: business.id, + reason: null, + missing_information: + businessIdentification.legal_identification_missing_information, + missing_information_details: + businessIdentification.legal_identification_missing_information_details, + }; + + res.status(200).send(response); +}; diff --git a/src/routes/business/identification.ts b/src/routes/business/identification.ts index 60090831d8..25988d2cb8 100644 --- a/src/routes/business/identification.ts +++ b/src/routes/business/identification.ts @@ -1,7 +1,8 @@ import type { Response } from "express"; +import _ from "lodash"; import { RequestWithBusiness } from "../../helpers/middlewares"; -import { saveBusiness, getPerson, savePerson } from "../../db"; +import { saveBusiness, getPerson } from "../../db"; import generateID from "../../helpers/id"; import { BusinessIdentification, @@ -61,7 +62,7 @@ export const createBusinessIdentification = async ( await saveBusiness(business); - return res.status(200).send(identification); + replyWithIdentification(res, identification, 201); }; export const retrieveBusinessIdentification = async ( @@ -88,8 +89,15 @@ export const retrieveBusinessIdentification = async ( ], }; - return res.status(404).send(resp); + res.status(404).send(resp); + return; } - return res.status(200).send(identification); + replyWithIdentification(res, identification, 200); }; + +const replyWithIdentification = ( + res: Response, + identification: BusinessIdentification, + status: number +) => res.status(status).send(_.omit(identification, "meta")); diff --git a/src/routes/business/index.ts b/src/routes/business/index.ts index d8154c04a7..df7adcbe74 100644 --- a/src/routes/business/index.ts +++ b/src/routes/business/index.ts @@ -2,3 +2,4 @@ export * from "./businesses"; export * from "./documents"; export * from "./beneficialOwner"; export * from "./identification"; +export * from "./complianceQuestions"; diff --git a/src/templates/business.html b/src/templates/business.html index 928a9c08b3..e47aa02a6c 100644 --- a/src/templates/business.html +++ b/src/templates/business.html @@ -161,6 +161,45 @@

Beneficial Owners

{% endfor %} + + +
+
+

Business Identifications

+
+
+ {% for identification in business.identifications %} +
+

Identification ID: {{ identification.id }}

+

Status: {{ identification.status }}

+

Method: {{ identification.method }}

+

Reference: {{ identification.reference }}

+

Completed At: {{ identification.completed_at }}

+
+
+ {% if identification.meta && identification.meta.complianceQuestions %} +
+
+

Compliance Questions

+
+
+ {% for question in identification.meta.complianceQuestions %} +
+

Question ID: {{ question.question_id }}

+

Question Text: {{ question.question_text }}

+

Asked At: {{ question.asked_at }}

+

Answer ID: {{ question.answer_id }}

+

Answer Text: {{ question.answer_text }}

+

Answered At: {{ question.answered_at }}

+
+
+ {% endfor %} +
+
+ {% endif %} + {% endfor %} +
+
diff --git a/tests/routes/business/complianceQuestions.spec.ts b/tests/routes/business/complianceQuestions.spec.ts new file mode 100644 index 0000000000..e2589d227f --- /dev/null +++ b/tests/routes/business/complianceQuestions.spec.ts @@ -0,0 +1,219 @@ +import { expect } from "chai"; +import sinon from "sinon"; +import { mockReq, mockRes } from "sinon-express-mock"; +import * as db from "../../../src/db"; +import * as complianceAPI from "../../../src/routes/business/complianceQuestions"; +import { + BusinessIdentification, + BusinessIdentificationStatus, + LegalIdentificationStatus, +} from "../../../src/helpers/types"; +import generateID from "../../../src/helpers/id"; +import * as qa from "../../../src/helpers/questionsAndAnswers"; +import * as businessesAPI from "../../../src/routes/business/businesses"; +import * as businessIdentAPI from "../../../src/routes/business"; +import { createPerson } from "../../../src/routes/persons"; + +describe("Compliance Questions API", () => { + let res: sinon.SinonSpy; + let businessId: string; + let businessIdentification: BusinessIdentification; + let req: sinon.SinonSpy; + let sandbox: sinon.SinonSandbox; + let saveBusinessSpy: sinon.SinonSpy; + + const setupBusinessAndIdentification = async () => { + await db.flushDb(); + + res = mockRes(); + req = mockReq({ + body: { + email: "superuser@kontist.com", + }, + headers: {}, + }); + await createPerson(req, res); + + const personId = res.send.lastCall.args[0].id; + + await businessesAPI.createBusiness( + { + body: { + name: "Test Business", + }, + }, + res + ); + businessId = res.send.lastCall.args[0].id; + + req = mockReq({ + params: { + business_id: businessId, + }, + business: { + id: businessId, + legalRepresentatives: [ + { + id: "rep123", + legal_representative_id: personId, + identifications: [{ id: "ident123" }], + }, + ], + }, + }); + + await businessIdentAPI.createBusinessIdentification(req, res); + businessIdentification = res.send.lastCall.args[0]; + }; + + beforeEach(async () => { + sandbox = sinon.createSandbox(); + await setupBusinessAndIdentification(); + res = mockRes(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe("listComplianceQuestions", () => { + beforeEach(async () => { + req = mockReq({ + businessIdentification, + business: { id: businessId, identifications: [businessIdentification] }, + }); + + sandbox.stub(qa, "fetchRandomQuestion").resolves("Sample Question?"); + saveBusinessSpy = sandbox.spy(db, "saveBusiness"); + await complianceAPI.listComplianceQuestions(req, res); + }); + + it("should return generated compliance questions", () => { + const response = res.send.lastCall.args[0]; + expect(response).to.be.an("array").with.lengthOf(2); + expect(response[0]).to.have.property("question_text", "Sample Question?"); + }); + + it("should save questions in businessIdentification meta", async () => { + expect(saveBusinessSpy.calledOnce).to.be.true; + expect( + saveBusinessSpy.lastCall.args[0].identifications[0].meta + .complianceQuestions + ) + .to.be.an("array") + .with.lengthOf(2); + }); + }); + + describe("answerComplianceQuestion", () => { + let questionId: string; + + beforeEach(async () => { + saveBusinessSpy = sandbox.spy(db, "saveBusiness"); + + const identification = { + ...businessIdentification, + meta: { + complianceQuestions: [ + { + question_id: generateID(), + question_text: "Sample Question?", + legal_identification_id: "ident123", + business_identification_id: businessIdentification.id, + business_id: businessId, + asked_at: new Date().toISOString(), + answer_id: null, + answer_text: null, + answered_at: null, + }, + ], + }, + }; + + req = mockReq({ + businessIdentification: identification, + params: { + question_id: identification.meta.complianceQuestions[0].question_id, + }, + business: { id: businessId, identifications: [identification] }, + body: { text: "Sample Answer" }, + }); + + questionId = req.params.question_id; + await complianceAPI.answerComplianceQuestion(req, res); + + expect(saveBusinessSpy.calledOnce).to.be.true; + const answer = + saveBusinessSpy.lastCall.args[0].identifications[0].meta + .complianceQuestions[0]; + + expect(answer).to.have.property("answer_text", "Sample Answer"); + expect(answer).to.have.property("answered_at").that.is.a("string"); + }); + + it("should return the answered question with answer details", () => { + const response = res.send.lastCall.args[0]; + expect(response).to.have.property("answer_text", "Sample Answer"); + expect(response).to.have.property("answer_id").that.is.a("string"); + expect(response).to.have.property("answered_at").that.is.a("string"); + }); + + it("should throw an error if question is not found", async () => { + req = mockReq({ + businessIdentification, + params: { question_id: "non-existent-id" }, + business: { id: businessId }, + body: { text: "Sample Answer" }, + }); + + await complianceAPI.answerComplianceQuestion(req, res); + const response = res.send.lastCall.args[0]; + expect(response.errors[0]).to.have.property("status", 404); + }); + }); + + describe("markLegalIdentificationAsReady", () => { + beforeEach(async () => { + saveBusinessSpy = sandbox.spy(db, "saveBusiness"); + + const identification = { + ...businessIdentification, + meta: { + complianceQuestions: [ + { + question_id: generateID(), + question_text: "Sample Question?", + legal_identification_id: "ident123", + business_identification_id: businessIdentification.id, + business_id: businessId, + asked_at: new Date().toISOString(), + answer_id: generateID(), + answer_text: "Answer", + answered_at: new Date().toISOString(), + }, + ], + }, + }; + + req = mockReq({ + businessIdentification: identification, + business: { id: businessId, identifications: [identification] }, + }); + await complianceAPI.markLegalIdentificationAsReady(req, res); + }); + + it("should update status of business identification", () => { + const response = res.send.lastCall.args[0]; + expect(response).to.have.property( + "status", + BusinessIdentificationStatus.CREATED + ); + + expect(saveBusinessSpy.calledOnce).to.be.true; + expect( + saveBusinessSpy.lastCall.args[0].identifications[0] + .legal_identification_status + ).to.equal(LegalIdentificationStatus.PENDING); + }); + }); +});