From 47a40a9a263d851568ae836f8e795c6bd97530e7 Mon Sep 17 00:00:00 2001 From: elijah0kello Date: Sat, 21 Sep 2024 16:00:01 +0300 Subject: [PATCH 1/5] chore: enhancements for template and documentation --- .../src/domain/coreConnectorAgg.ts | 6 +++--- core-connector-template/test/func/docker-compose.yml | 11 +++-------- docs/Testing.md | 5 +++++ 3 files changed, 11 insertions(+), 11 deletions(-) create mode 100644 docs/Testing.md diff --git a/core-connector-template/src/domain/coreConnectorAgg.ts b/core-connector-template/src/domain/coreConnectorAgg.ts index ec90bac..730a641 100644 --- a/core-connector-template/src/domain/coreConnectorAgg.ts +++ b/core-connector-template/src/domain/coreConnectorAgg.ts @@ -75,15 +75,15 @@ export class CoreConnectorAggregate implements ICoreConnectorAggregate { } //Payee - getParties(id: string, IdType: string): Promise { + async getParties(id: string, IdType: string): Promise { this.logger.info(`Getting party info ${id} ${IdType}`); throw new Error('Method not implemented.'); } - quoteRequest(quoteRequest: TQuoteRequest): Promise { + async quoteRequest(quoteRequest: TQuoteRequest): Promise { this.logger.info(`Calculating quote for ${quoteRequest.to.idValue}`); throw new Error('Method not implemented.'); } - receiveTransfer(transfer: TtransferRequest): Promise { + async receiveTransfer(transfer: TtransferRequest): Promise { this.logger.info(`Received transfer request for ${transfer.to.idValue}`); throw new Error('Method not implemented.'); } diff --git a/core-connector-template/test/func/docker-compose.yml b/core-connector-template/test/func/docker-compose.yml index caebdd8..3eeb6cc 100644 --- a/core-connector-template/test/func/docker-compose.yml +++ b/core-connector-template/test/func/docker-compose.yml @@ -5,18 +5,13 @@ networks: services: - mifosCoreConnector: - image: mojaloop/mifos-core-connector:local + core-connector: + image: mojaloop/core-connector:local build: context: ../.. networks: - mojaloop-net - environment: - - SDK_SERVER_HOST=mifosCoreConnector - - SDK_SERVER_PORT=3003 - - DFSP_SERVER_HOST=mifosCoreConnector - - DFSP_SERVER_PORT=3004 - - SDK_BASE_URL=http://sdkSchemeAdapterOutbound:4010 + env_file: ../../.env ports: - "3003:3003" - "3004:3004" diff --git a/docs/Testing.md b/docs/Testing.md new file mode 100644 index 0000000..69f00d2 --- /dev/null +++ b/docs/Testing.md @@ -0,0 +1,5 @@ +# Testing the core connector + +Create a .env file from the .env.example file in the core connector root. Then run the components using docker compose. + + From 558f6476535b89086ca6c1704d4a117543af23b4 Mon Sep 17 00:00:00 2001 From: elijah0kello Date: Wed, 25 Sep 2024 17:47:10 +0300 Subject: [PATCH 2/5] chore: added required env vars --- core-connector-template/.env.example | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/core-connector-template/.env.example b/core-connector-template/.env.example index 7e9cc2c..2a2a97c 100644 --- a/core-connector-template/.env.example +++ b/core-connector-template/.env.example @@ -13,7 +13,16 @@ FSP_ID=testdfsp CONNECTOR_NAME=TemplateCC # CBS Config -EXAMPLE_BASE_URL=https://examplebank.com -EXAMPLE_USERNAME=user -EXAMPLE_PASSWORD=password -SUPPORTED_ID_TYPE=MSISDN \ No newline at end of file +SUPPORTED_ID_TYPE=MSISDN +CBS_NAME=DFSP +DFSP_BASE_URL=https://examplebank.com +CLIENT_ID=2394934345 +CLIENT_SECRET=password +GRANT_TYPE=client_credentials +X_COUNTRY=MW +X_CURRENCY=MWK +SUPPORTED_ID_TYPE=MSISDN +SENDING_SERVICE_CHARGE=1 +RECEIVING_SERVICE_CHARGE=1 +EXPIRATION_DURATION=3 +AIRTEL_PIN=345445 \ No newline at end of file From c52b9339a6c0c1936e06280c31b86027481393ab Mon Sep 17 00:00:00 2001 From: elijah0kello Date: Wed, 25 Sep 2024 17:47:33 +0300 Subject: [PATCH 3/5] chore: implemented env vars in config.ts --- core-connector-template/src/config.ts | 72 +++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/core-connector-template/src/config.ts b/core-connector-template/src/config.ts index e5e28ad..4eef0ec 100644 --- a/core-connector-template/src/config.ts +++ b/core-connector-template/src/config.ts @@ -71,6 +71,78 @@ const config = Convict({ default: null, // required env: 'CBS_NAME', }, + DFSP_BASE_URL:{ + doc: 'API Base URL', + format: String, + default: null, // required + env: 'DFSP_BASE_URL', + }, + CLIENT_ID:{ + doc: 'Client ID for api user', + format: String, + default: null, // required + env: 'CLIENT_ID', + }, + CLIENT_SECRET:{ + doc: 'Client Secret for api user', + format: String, + default: null, // required + env: 'CLIENT_SECRET', + }, + GRANT_TYPE:{ + doc: 'Airtel Grant Type', + format: String, + default: null, // required + env: 'GRANT_TYPE', + }, + X_COUNTRY:{ + doc: 'Country', + format: String, + default: null, // required + env: 'X_COUNTRY', + }, + X_CURRENCY:{ + doc: 'Currency', + format: String, + default: null, // required + env: 'X_CURRENCY', + }, + SUPPORTED_ID_TYPE:{ + doc: 'Supported Id Type', + format: String, + default: null, // required + env: 'SUPPORTED_ID_TYPE', + }, + SENDING_SERVICE_CHARGE:{ + doc: 'Charge for sending money to customer account', + format: String, + default: null, // required + env: 'SENDING_SERVICE_CHARGE', + }, + RECEIVING_SERVICE_CHARGE:{ + doc: 'Charge for collecting money from customer account', + format: String, + default: null, // required + env: 'RECEIVING_SERVICE_CHARGE', + }, + EXPIRATION_DURATION:{ + doc: 'Quote expiration duration', + format: String, + default: null, // required + env: 'EXPIRATION_DURATION', + }, + AIRTEL_PIN:{ + doc: 'Airtel disbursement PIN', + format: String, + default: null, // required + env: 'AIRTEL_PIN', + }, + FSP_ID:{ + doc: 'FSP Identifier', + format: String, + default: null, // required + env: 'FSP_ID', + } } }); From 349ffb7ea4c488ae0bdf00cef424ea8d06de42e1 Mon Sep 17 00:00:00 2001 From: elijah0kello Date: Wed, 25 Sep 2024 17:48:06 +0300 Subject: [PATCH 4/5] feat: added route handler for PATCH notification --- .../sdkCoreConnectorRoutes.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/core-connector-template/src/core-connector-svc/sdkCoreConnectorRoutes.ts b/core-connector-template/src/core-connector-svc/sdkCoreConnectorRoutes.ts index ce88beb..259b9d5 100644 --- a/core-connector-template/src/core-connector-svc/sdkCoreConnectorRoutes.ts +++ b/core-connector-template/src/core-connector-svc/sdkCoreConnectorRoutes.ts @@ -30,7 +30,7 @@ optionally within square brackets . import { Request, ResponseToolkit, ServerRoute } from '@hapi/hapi'; import OpenAPIBackend, { Context } from 'openapi-backend'; import { CoreConnectorAggregate } from 'src/domain/coreConnectorAgg'; -import { ILogger, TQuoteRequest, TtransferRequest } from '../domain'; +import { ILogger, TQuoteRequest, TtransferPatchNotificationRequest, TtransferRequest } from '../domain'; import { BaseRoutes } from './BaseRoutes'; import config from '../config'; @@ -45,6 +45,7 @@ export class CoreConnectorRoutes extends BaseRoutes { BackendPartiesGetByTypeAndID: this.getParties.bind(this), BackendQuoteRequest: this.quoteRequests.bind(this), BackendTransfersPost: this.transfers.bind(this), + BackendTransfersPut: this.transferNotification.bind(this), validationFail: async (context: Context, req: Request, h: ResponseToolkit) => h.response({ error: context.validation.errors }).code(412), notFound: async (context: Context, req: Request, h: ResponseToolkit) => h.response({ error: 'Not found' }).code(404), }; @@ -105,7 +106,7 @@ export class CoreConnectorRoutes extends BaseRoutes { const Id = params['ID'] as string; const IdType = params['IdType'] as string; const result = await this.aggregate.getParties(Id,IdType); - return this.handleResponse(result.data, h); + return this.handleResponse(result, h); } catch (error) { return this.handleError(error, h); } @@ -130,4 +131,16 @@ export class CoreConnectorRoutes extends BaseRoutes { return this.handleError(error, h); } } + + private async transferNotification(context: Context, request: Request, h: ResponseToolkit){ + const transferNotificatioPayload = request.payload as TtransferPatchNotificationRequest; + const { params } = context.request; + const transferId = params['transferId'] as string; + try{ + const result = await this.aggregate.updateTransfer(transferNotificatioPayload, transferId); + return this.handleResponse(result,h); + }catch(error: unknown){ + return this.handleError(error,h); + } + } } From 96c364477372bd937559ea7880714b0e45bbb6f5 Mon Sep 17 00:00:00 2001 From: elijah0kello Date: Wed, 25 Sep 2024 17:48:40 +0300 Subject: [PATCH 5/5] feat: implemented functions for incoming transactions --- .../src/domain/CBSClient/CBSClient.ts | 10 +- .../src/domain/CBSClient/types.ts | 8 +- .../src/domain/SDKClient/errors.ts | 9 +- .../src/domain/coreConnectorAgg.ts | 315 ++++++++++++++++-- .../src/domain/interfaces/errors.ts | 6 +- .../src/domain/interfaces/types.ts | 41 ++- .../test/int/domain/coreConnectorAgg.test.ts | 55 +++ .../test/unit/domain/coreConnectorAgg.test.ts | 13 +- .../{ => domain}/sdk_scheme_adapater.test.ts | 8 +- .../test/unit/index.test.ts | 0 10 files changed, 397 insertions(+), 68 deletions(-) rename core-connector-template/test/unit/{ => domain}/sdk_scheme_adapater.test.ts (96%) delete mode 100644 core-connector-template/test/unit/index.test.ts diff --git a/core-connector-template/src/domain/CBSClient/CBSClient.ts b/core-connector-template/src/domain/CBSClient/CBSClient.ts index 53fe3c9..806aeb0 100644 --- a/core-connector-template/src/domain/CBSClient/CBSClient.ts +++ b/core-connector-template/src/domain/CBSClient/CBSClient.ts @@ -66,7 +66,7 @@ export class CBSClient implements ICbsClient{ async getKyc(deps: TGetKycArgs): Promise { this.logger.info("Getting KYC Information"); - const res = await this.httpClient.get(`https://${this.cbsConfig.AIRTEL_BASE_URL}${CBS_ROUTES.getKyc}${deps.msisdn}`, { + const res = await this.httpClient.get(`https://${this.cbsConfig.DFSP_BASE_URL}${CBS_ROUTES.getKyc}${deps.msisdn}`, { headers: { ...this.getDefaultHeader(), 'Authorization': `Bearer ${await this.getAuthHeader()}` @@ -80,7 +80,7 @@ export class CBSClient implements ICbsClient{ async getToken(deps: TGetTokenArgs): Promise { this.logger.info("Getting Access Token from Airtel"); - const url = `https://${this.cbsConfig.AIRTEL_BASE_URL}${CBS_ROUTES.getToken}`; + const url = `https://${this.cbsConfig.DFSP_BASE_URL}${CBS_ROUTES.getToken}`; this.logger.info(url); try { const res = await this.httpClient.post(url, deps, { @@ -99,7 +99,7 @@ export class CBSClient implements ICbsClient{ async sendMoney(deps: TCbsDisbursementRequestBody): Promise { this.logger.info("Sending Disbursement Body To Airtel"); - const url = `https://${this.cbsConfig.AIRTEL_BASE_URL}${CBS_ROUTES.sendMoney}`; + const url = `https://${this.cbsConfig.DFSP_BASE_URL}${CBS_ROUTES.sendMoney}`; try { const res = await this.httpClient.post(url, deps, { @@ -121,7 +121,7 @@ export class CBSClient implements ICbsClient{ } async collectMoney(deps: TCbsCollectMoneyRequest): Promise { this.logger.info("Collecting Money from Airtel"); - const url = `https://${this.cbsConfig.AIRTEL_BASE_URL}${CBS_ROUTES.collectMoney}`; + const url = `https://${this.cbsConfig.DFSP_BASE_URL}${CBS_ROUTES.collectMoney}`; try { const res = await this.httpClient.post(url, deps, { @@ -143,7 +143,7 @@ export class CBSClient implements ICbsClient{ async refundMoney(deps: TCbsRefundMoneyRequest): Promise { this.logger.info("Refunding Money to Customer in Airtel"); - const url = `https://${this.cbsConfig.AIRTEL_BASE_URL}${CBS_ROUTES.refundMoney}`; + const url = `https://${this.cbsConfig.DFSP_BASE_URL}${CBS_ROUTES.refundMoney}`; try{ const res = await this.httpClient.post(url, deps,{ diff --git a/core-connector-template/src/domain/CBSClient/types.ts b/core-connector-template/src/domain/CBSClient/types.ts index 75df3fa..63dd5ad 100644 --- a/core-connector-template/src/domain/CBSClient/types.ts +++ b/core-connector-template/src/domain/CBSClient/types.ts @@ -21,17 +21,17 @@ export type TCBSClientFactoryDeps = { export type TCBSConfig = { CBS_NAME: string; - AIRTEL_BASE_URL: string; + DFSP_BASE_URL: string; CLIENT_ID: string; CLIENT_SECRET: string; GRANT_TYPE: string; X_COUNTRY: string; - X_CURRENCY: string; + X_CURRENCY: components["schemas"]["Currency"]; SUPPORTED_ID_TYPE: components["schemas"]["PartyIdType"]; - SERVICE_CHARGE: string; + SENDING_SERVICE_CHARGE: number; + RECEIVING_SERVICE_CHARGE: number; EXPIRATION_DURATION: string; AIRTEL_PIN: string; - TRANSACTION_ENQUIRY_WAIT_TIME: number FSP_ID:string } diff --git a/core-connector-template/src/domain/SDKClient/errors.ts b/core-connector-template/src/domain/SDKClient/errors.ts index 35c9c1b..9be7221 100644 --- a/core-connector-template/src/domain/SDKClient/errors.ts +++ b/core-connector-template/src/domain/SDKClient/errors.ts @@ -32,10 +32,7 @@ import { BasicError, ErrorOptions } from '../interfaces'; export class SDKClientError extends BasicError { // think, if it's better to have a separate class static continueTransferError(message: string, options?: ErrorOptions) { - const { - httpCode = 500, - mlCode = httpCode === 504 ? '2004' : '2001' - } = options || {}; + const { httpCode = 500, mlCode = httpCode === 504 ? '2004' : '2001' } = options || {}; return new SDKClientError(message, { mlCode, httpCode }); } @@ -52,4 +49,8 @@ export class SDKClientError extends BasicError { mlCode: '3200', }); } + + static genericQuoteValidationError(message: string, options?: ErrorOptions) { + return new SDKClientError(message, options); + } } diff --git a/core-connector-template/src/domain/coreConnectorAgg.ts b/core-connector-template/src/domain/coreConnectorAgg.ts index 730a641..752ebea 100644 --- a/core-connector-template/src/domain/coreConnectorAgg.ts +++ b/core-connector-template/src/domain/coreConnectorAgg.ts @@ -34,13 +34,14 @@ import { TCbsCollectMoneyRequest, TCbsCollectMoneyResponse, TCBSConfig, + TCbsDisbursementRequestBody, + TCbsKycResponse, TCbsSendMoneyRequest, TCbsSendMoneyResponse, TCBSUpdateSendMoneyRequest, } from './CBSClient'; import { ILogger, - TLookupPartyInfoResponse, TQuoteResponse, TQuoteRequest, TtransferResponse, @@ -49,6 +50,8 @@ import { TtransferPatchNotificationRequest, THttpResponse, ValidationError, + Party, + TGetQuotesDeps, } from './interfaces'; import { ISDKClient, @@ -69,34 +72,201 @@ export class CoreConnectorAggregate implements ICoreConnectorAggregate { readonly cbsConfig: TCBSConfig, logger: ILogger, ) { - // todo: set the IdType from here - this.IdType = "MSISDN"; + this.IdType = this.cbsConfig.SUPPORTED_ID_TYPE; this.logger = logger; } //Payee - async getParties(id: string, IdType: string): Promise { - this.logger.info(`Getting party info ${id} ${IdType}`); - throw new Error('Method not implemented.'); + async getParties(id: string, IdType: string): Promise { + this.logger.info(`Getting party information for ${id}`); + if (!(IdType === this.cbsConfig.SUPPORTED_ID_TYPE)) { + throw ValidationError.unsupportedIdTypeError(); + } + const res = await this.cbsClient.getKyc({ msisdn: id }); + return this.getPartiesResponse(res); + } + + private getPartiesResponse(res: TCbsKycResponse): Party { + return { + idType: "MSISDN", + idValue: res.data.msisdn, + displayName: `${res.data.first_name} ${res.data.last_name}`, + firstName: res.data.first_name, + middleName: res.data.first_name, + type: "CONSUMER", + kycInformation: JSON.stringify(res.data), + lastName: res.data.last_name + } } + async quoteRequest(quoteRequest: TQuoteRequest): Promise { - this.logger.info(`Calculating quote for ${quoteRequest.to.idValue}`); - throw new Error('Method not implemented.'); + this.logger.info(`Calculating quote for ${quoteRequest.to.idValue} and amount ${quoteRequest.amount}`); + if (quoteRequest.to.idType !== this.cbsConfig.SUPPORTED_ID_TYPE) { + throw ValidationError.unsupportedIdTypeError(); + } + if (quoteRequest.currency !== this.cbsConfig.X_CURRENCY) { + throw ValidationError.unsupportedCurrencyError(); + } + const res = await this.cbsClient.getKyc({ msisdn: quoteRequest.to.idValue }); + const fees = (Number(this.cbsConfig.SENDING_SERVICE_CHARGE) / 100) * Number(quoteRequest.amount) + // check if account is blocked if possible + const quoteExpiration = this.cbsConfig.EXPIRATION_DURATION; + const expiration = new Date(); + expiration.setHours(expiration.getHours() + Number(quoteExpiration)); + const expirationJSON = expiration.toJSON(); + return this.getQuoteResponse({ + res: res, + fees: fees, + expiration: expirationJSON, + quoteRequest: quoteRequest + }); + } + + private getQuoteResponse(deps: TGetQuotesDeps): TQuoteResponse { + return { + "expiration": deps.expiration, + "payeeFspCommissionAmount": "0", + "payeeFspCommissionAmountCurrency": this.cbsConfig.X_CURRENCY, + "payeeFspFeeAmount": deps.fees.toString(), + "payeeFspFeeAmountCurrency": this.cbsConfig.X_CURRENCY, + "payeeReceiveAmount": deps.quoteRequest.amount, + "payeeReceiveAmountCurrency": this.cbsConfig.X_CURRENCY, + "quoteId": deps.quoteRequest.quoteId, + "transferAmount": (deps.fees + Number(deps.quoteRequest.amount)).toString(), + "transferAmountCurrency": deps.quoteRequest.currency, + "transactionId": deps.quoteRequest.transactionId + } } async receiveTransfer(transfer: TtransferRequest): Promise { this.logger.info(`Received transfer request for ${transfer.to.idValue}`); - throw new Error('Method not implemented.'); + if (transfer.to.idType != this.IdType) { + throw ValidationError.unsupportedIdTypeError(); + } + if (transfer.currency !== this.cbsConfig.X_CURRENCY) { + throw ValidationError.unsupportedCurrencyError(); + } + if (!this.validateQuote(transfer)) { + throw ValidationError.invalidQuoteError(); + } + this.checkAccountBarred(transfer.to.idValue); + return { + completedTimestamp: new Date().toJSON(), + homeTransactionId: transfer.transferId, + transferState: 'RECEIVED', + }; + } + + private validateQuote(transfer: TtransferRequest): boolean { + this.logger.info(`Validating quote for transfer with amount ${transfer.amount}`); + let result = true; + if (transfer.amountType === 'SEND') { + if (!this.checkSendAmounts(transfer)) { + result = false; + } + } else if (transfer.amountType === 'RECEIVE') { + if (!this.checkReceiveAmounts(transfer)) { + result = false; + } + } + return result; + } + + private checkSendAmounts(transfer: TtransferRequest): boolean { + this.logger.info('Validating Type Send Quote...', { transfer }); + let result = true; + if ( + parseFloat(transfer.amount) !== + parseFloat(transfer.quote.transferAmount) - parseFloat(transfer.quote.payeeFspCommissionAmount || '0') + // POST /transfers request.amount == request.quote.transferAmount - request.quote.payeeFspCommissionAmount + ) { + result = false; + } + + if (!transfer.quote.payeeReceiveAmount || !transfer.quote.payeeFspFeeAmount) { + throw ValidationError.notEnoughInformationError("transfer.quote.payeeReceiveAmount or !transfer.quote.payeeFspFeeAmount not defined", "5000"); + } + + if ( + parseFloat(transfer.quote.payeeReceiveAmount) !== + parseFloat(transfer.quote.transferAmount) - + parseFloat(transfer.quote.payeeFspFeeAmount) + ) { + result = false; + } + return result; + } + + private checkReceiveAmounts(transfer: TtransferRequest): boolean { + this.logger.info('Validating Type Receive Quote...', { transfer }); + let result = true; + if (!transfer.quote.payeeFspFeeAmount || !transfer.quote.payeeReceiveAmount) { + throw ValidationError.notEnoughInformationError("transfer.quote.payeeFspFeeAmount or transfer.quote.payeeReceiveAmount not defined", "5000") + } + if ( + parseFloat(transfer.amount) !== + parseFloat(transfer.quote.transferAmount) - + parseFloat(transfer.quote.payeeFspCommissionAmount || '0') + + parseFloat(transfer.quote.payeeFspFeeAmount) + ) { + result = false; + } + + if (parseFloat(transfer.quote.payeeReceiveAmount) !== parseFloat(transfer.quote.transferAmount)) { + result = false; + } + return result; + } + + private async checkAccountBarred(msisdn: string): Promise { + const res = await this.cbsClient.getKyc({ msisdn: msisdn }); + if (res.data.is_barred) { + throw ValidationError.accountBarredError(); + } } - updateTransfer(updateTransferPayload: TtransferPatchNotificationRequest, transferId: string): Promise { + + async updateTransfer(updateTransferPayload: TtransferPatchNotificationRequest, transferId: string): Promise { this.logger.info(`Committing transfer on patch notification for ${updateTransferPayload.quoteRequest?.body.payee.partyIdInfo.partyIdentifier} and transfer id ${transferId}`); - throw new Error('Method not implemented.'); + if (updateTransferPayload.currentState !== 'COMPLETED') { + await this.initiateCompensationAction(); + throw ValidationError.transferNotCompletedError(); + } + const makePaymentRequest: TCbsDisbursementRequestBody = this.getMakePaymentRequestBody(updateTransferPayload); + await this.cbsClient.sendMoney(makePaymentRequest); + } + + private async initiateCompensationAction() { + // todo function implementation to be defined. + } + + private getMakePaymentRequestBody(requestBody: TtransferPatchNotificationRequest): TCbsDisbursementRequestBody { + if (!requestBody.quoteRequest) { + throw ValidationError.quoteNotDefinedError('Quote Not Defined Error', '5000', 500); + } + + if (!requestBody.transferId) { + throw ValidationError.transferIdNotDefinedError("transferId not defined on patch notification handling", "5000", 500); + } + + return { + "payee": { + "msisdn": requestBody.quoteRequest.body.payee.partyIdInfo.partyIdentifier, + "wallet_type": "NORMAL", + }, + "reference": requestBody.quoteRequest.body.note !== undefined ? requestBody.quoteRequest.body.note : "No note returned", + "pin": this.cbsConfig.AIRTEL_PIN, + "transaction": { + "amount": Number(requestBody.quoteRequest.body.amount.amount), + "id": requestBody.transferId, + "type": "B2B" + } + } } // Payer async sendMoney(transfer: TCbsSendMoneyRequest): Promise { this.logger.info(`Received send money request for payer with ID ${transfer.payerAccount}`); const res = await this.sdkClient.initiateTransfer(await this.getTSDKOutboundTransferRequest(transfer)); - if(res.data.currentState === "WAITING_FOR_CONVERSION_ACCEPTANCE"){ + if (res.data.currentState === "WAITING_FOR_CONVERSION_ACCEPTANCE") { return await this.checkAndRespondToConversionTerms(res); } if (!this.validateReturnedQuote(res.data)) { @@ -105,7 +275,7 @@ export class CoreConnectorAggregate implements ICoreConnectorAggregate { return this.getTCbsSendMoneyResponse(res.data); } - private async checkAndRespondToConversionTerms(res: THttpResponse): Promise{ + private async checkAndRespondToConversionTerms(res: THttpResponse): Promise { let acceptRes: THttpResponse; if (!this.validateConversionTerms(res.data)) { if (!res.data.transferId) { @@ -130,16 +300,90 @@ export class CoreConnectorAggregate implements ICoreConnectorAggregate { return this.getTCbsSendMoneyResponse(acceptRes.data); } - private validateConversionTerms(transferResponse: TSDKOutboundTransferResponse): boolean { - this.logger.info(`Validating Conversion Terms with transfer response amount${transferResponse.amount}`); - // todo: Define Implementations - return true; + private validateConversionTerms(transferRes: TSDKOutboundTransferResponse): boolean { + this.logger.info(`Validating Conversion Terms with transfer response amount${transferRes.amount}`); + let result = true; + if ( + !(this.cbsConfig.X_CURRENCY === transferRes.fxQuotesResponse?.body.conversionTerms.sourceAmount.currency) + ) { + result = false; + } + if (transferRes.amountType === 'SEND') { + if (!(transferRes.amount === transferRes.fxQuotesResponse?.body.conversionTerms.sourceAmount.amount)) { + result = false; + } + if (!transferRes.to.supportedCurrencies) { + throw SDKClientError.genericQuoteValidationError("Payee Supported Currency not defined", { httpCode: 500, mlCode: "4000" }); + } + if (!transferRes.to.supportedCurrencies.some(value => value === transferRes.quoteResponse?.body.transferAmount.currency)) { + result = false; + } + if (!(transferRes.currency === transferRes.fxQuotesResponse?.body.conversionTerms.sourceAmount.currency)) { + result = false; + } + } else if (transferRes.amountType === 'RECEIVE') { + if (!(transferRes.amount === transferRes.fxQuotesResponse?.body.conversionTerms.targetAmount.amount)) { + result = false; + } + if (!(transferRes.currency === transferRes.quoteResponse?.body.transferAmount.currency)) { + result = false; + } + if (transferRes.fxQuotesResponse) { + if (!transferRes.from.supportedCurrencies) { + throw ValidationError.unsupportedCurrencyError(); + } + if (!(transferRes.from.supportedCurrencies.some(value => value === transferRes.fxQuotesResponse?.body.conversionTerms.targetAmount.currency))) { + result = false; + } + } + } + return result; } - private validateReturnedQuote(transferResponse: TSDKOutboundTransferResponse): boolean { - this.logger.info(`Validating Retunred Quote with transfer response amount${transferResponse.amount}`); - // todo: Define Implementations - return true; + private validateReturnedQuote(outboundTransferRes: TSDKOutboundTransferResponse): boolean { + this.logger.info(`Validating Retunred Quote with transfer response amount${outboundTransferRes.amount}`); + let result = true; + if (!this.validateConversionTerms(outboundTransferRes)) { + result = false; + } + const quoteResponseBody = outboundTransferRes.quoteResponse?.body; + const fxQuoteResponseBody = outboundTransferRes.fxQuotesResponse?.body + if (!quoteResponseBody) { + throw SDKClientError.noQuoteReturnedError(); + } + if (outboundTransferRes.amountType === "SEND") { + if (!(parseFloat(outboundTransferRes.amount) === parseFloat(quoteResponseBody.transferAmount.amount) - parseFloat(quoteResponseBody.payeeFspCommission?.amount || "0"))) { + result = false; + } + if (!quoteResponseBody.payeeReceiveAmount) { + throw SDKClientError.genericQuoteValidationError("Payee Receive Amount not defined", { httpCode: 500, mlCode: "4000" }); + } + if (!(parseFloat(quoteResponseBody.payeeReceiveAmount.amount) === parseFloat(quoteResponseBody.transferAmount.amount) - parseFloat(quoteResponseBody.payeeFspCommission?.amount || '0'))) { + result = false; + } + if (!(fxQuoteResponseBody?.conversionTerms.targetAmount.amount === quoteResponseBody.transferAmount.amount)) { + result = false; + } + } else if (outboundTransferRes.amountType === "RECEIVE") { + if (!outboundTransferRes.quoteResponse) { + throw SDKClientError.noQuoteReturnedError(); + } + if (!(parseFloat(outboundTransferRes.amount) === parseFloat(quoteResponseBody.transferAmount.amount) - parseFloat(quoteResponseBody.payeeFspCommission?.amount || "0") + parseFloat(quoteResponseBody.payeeFspFee?.amount || "0"))) { + result = false; + } + + if (!(quoteResponseBody.payeeReceiveAmount?.amount === quoteResponseBody.transferAmount.amount)) { + result = false; + } + if (fxQuoteResponseBody) { + if (!(fxQuoteResponseBody.conversionTerms.targetAmount.amount === quoteResponseBody.transferAmount.amount)) { + result = false; + } + } + } else { + SDKClientError.genericQuoteValidationError("Invalid amountType received", { httpCode: 500, mlCode: "4000" }); + } + return result; } private getTCbsSendMoneyResponse(transfer: TSDKOutboundTransferResponse): TCbsSendMoneyResponse { @@ -147,21 +391,21 @@ export class CoreConnectorAggregate implements ICoreConnectorAggregate { return { "payeeDetails": { "idType": transfer.to.idType, - "idValue":transfer.to.idValue, + "idValue": transfer.to.idValue, "fspId": transfer.to.fspId !== undefined ? transfer.to.fspId : "No FSP ID Returned", "firstName": transfer.to.firstName !== undefined ? transfer.to.firstName : "No First Name Returned", - "lastName":transfer.to.lastName !== undefined ? transfer.to.lastName : "No Last Name Returned", - "dateOfBirth":transfer.to.dateOfBirth !== undefined ? transfer.to.dateOfBirth : "No Date of Birth Returned", + "lastName": transfer.to.lastName !== undefined ? transfer.to.lastName : "No Last Name Returned", + "dateOfBirth": transfer.to.dateOfBirth !== undefined ? transfer.to.dateOfBirth : "No Date of Birth Returned", }, "receiveAmount": transfer.quoteResponse?.body.payeeReceiveAmount?.amount !== undefined ? transfer.quoteResponse.body.payeeReceiveAmount.amount : "No payee receive amount", - "receiveCurrency": transfer.fxQuotesResponse?.body.conversionTerms.targetAmount.currency !== undefined ? transfer.fxQuotesResponse?.body.conversionTerms.targetAmount.currency : "No Currency returned from Mojaloop Connector" , + "receiveCurrency": transfer.fxQuotesResponse?.body.conversionTerms.targetAmount.currency !== undefined ? transfer.fxQuotesResponse?.body.conversionTerms.targetAmount.currency : "No Currency returned from Mojaloop Connector", "fees": transfer.quoteResponse?.body.payeeFspFee?.amount !== undefined ? transfer.quoteResponse?.body.payeeFspFee?.amount : "No fee amount returned from Mojaloop Connector", "feeCurrency": transfer.fxQuotesResponse?.body.conversionTerms.targetAmount.currency !== undefined ? transfer.fxQuotesResponse?.body.conversionTerms.targetAmount.currency : "No Fee currency retrned from Mojaloop Connector", "transactionId": transfer.transferId !== undefined ? transfer.transferId : "No transferId returned", }; } - private async getTSDKOutboundTransferRequest(transfer: TCbsSendMoneyRequest):Promise { + private async getTSDKOutboundTransferRequest(transfer: TCbsSendMoneyRequest): Promise { const res = await this.cbsClient.getKyc({ msisdn: transfer.payerAccount }); @@ -213,17 +457,16 @@ export class CoreConnectorAggregate implements ICoreConnectorAggregate { }; } - async handleCallback(payload: TCallbackRequest): Promise{ + async handleCallback(payload: TCallbackRequest): Promise { this.logger.info(`Handling callback for transaction with id ${payload.transaction.id}`); - let sdkRes; - try{ - if(payload.transaction.status_code === "TS"){ - sdkRes = await this.sdkClient.updateTransfer({acceptQuote: true},payload.transaction.id); - }else{ - sdkRes = await this.sdkClient.updateTransfer({acceptQuote: false},payload.transaction.id); - } - }catch (error: unknown){ - if(error instanceof SDKClientError){ + try { + if (payload.transaction.status_code === "TS") { + await this.sdkClient.updateTransfer({ acceptQuote: true }, payload.transaction.id); + } else { + await this.sdkClient.updateTransfer({ acceptQuote: false }, payload.transaction.id); + } + } catch (error: unknown) { + if (error instanceof SDKClientError) { // perform refund or rollback // const rollbackRes = await this.cbsClient.refundMoney(); } diff --git a/core-connector-template/src/domain/interfaces/errors.ts b/core-connector-template/src/domain/interfaces/errors.ts index 011679d..5b0b751 100644 --- a/core-connector-template/src/domain/interfaces/errors.ts +++ b/core-connector-template/src/domain/interfaces/errors.ts @@ -147,9 +147,9 @@ }); } - static notEnoughInformationError(){ - return new ValidationError("Not enough information returned by mojaloop connector. fxQuotesResponse and quotesResponse", { - mlCode: '4000', + static notEnoughInformationError(message: string, mlCode: string){ + return new ValidationError(message, { + mlCode: mlCode, httpCode: 500, }); } diff --git a/core-connector-template/src/domain/interfaces/types.ts b/core-connector-template/src/domain/interfaces/types.ts index a6c09e5..1d1fbd4 100644 --- a/core-connector-template/src/domain/interfaces/types.ts +++ b/core-connector-template/src/domain/interfaces/types.ts @@ -31,10 +31,10 @@ import { SDKSchemeAdapter } from '@mojaloop/api-snippets'; import { AxiosRequestConfig, CreateAxiosDefaults } from 'axios'; import { ILogger } from './infrastructure'; import { components } from '@mojaloop/api-snippets/lib/sdk-scheme-adapter/v2_1_0/backend/openapi'; -import {components as OutboundComponents } from "@mojaloop/api-snippets/lib/sdk-scheme-adapter/v2_1_0/outbound/openapi"; +import { components as OutboundComponents } from "@mojaloop/api-snippets/lib/sdk-scheme-adapter/v2_1_0/outbound/openapi"; import { components as fspiopComponents } from '@mojaloop/api-snippets/lib/fspiop/v2_0/openapi'; -import { ICbsClient, TCbsCollectMoneyResponse, TCBSConfig, TCbsSendMoneyRequest, TCbsSendMoneyResponse, TCBSUpdateSendMoneyRequest } from '../CBSClient'; -import { ISDKClient, TtransferContinuationResponse } from '../SDKClient'; +import { ICbsClient, TCbsCollectMoneyResponse, TCBSConfig, TCbsKycResponse, TCbsSendMoneyRequest, TCbsSendMoneyResponse, TCBSUpdateSendMoneyRequest } from '../CBSClient'; +import { ISDKClient } from '../SDKClient'; export type TJson = string | number | boolean | { [x: string]: TJson } | Array; @@ -47,7 +47,25 @@ export type THttpClientDeps = { export type TQuoteRequest = SDKSchemeAdapter.V2_0_0.Backend.Types.quoteRequest; -export type TtransferRequest = SDKSchemeAdapter.V2_0_0.Backend.Types.transferRequest; +export type TtransferRequest = { + /** @description Linked homeR2PTransactionId which was generated as part of POST /requestToPay to SDK incase of requestToPay transfer. */ + homeR2PTransactionId?: string; + amount: components['schemas']['money']; + amountType: components['schemas']['amountType']; + currency: components['schemas']['currency']; + from: components['schemas']['transferParty']; + ilpPacket: { + data: components['schemas']['ilpPacketData']; + }; + note?: string; + quote: TQuoteResponse; + quoteRequestExtensions?: components['schemas']['extensionList']; + subScenario?: components['schemas']['TransactionSubScenario']; + to: components['schemas']['transferParty']; + transactionType: components['schemas']['transactionType']; + transferId: components['schemas']['transferId']; + transactionRequestId?: components['schemas']['transactionRequestId']; +}; export type THttpResponse = { data: R; @@ -66,7 +84,7 @@ export type TQuoteResponse = SDKSchemeAdapter.V2_0_0.Backend.Types.quoteResponse export type TtransferResponse = SDKSchemeAdapter.V2_0_0.Backend.Types.transferResponse; -export type Payee = { +export type Party = { dateOfBirth?: string; displayName: string; extensionList?: unknown[]; @@ -90,7 +108,7 @@ export type Transfer = { transferState: string; }; -export type TLookupPartyInfoResponse = THttpResponse; +export type TLookupPartyInfoResponse = THttpResponse; export type TtransferPatchNotificationRequest = { currentState?: OutboundComponents['schemas']['transferStatus']; @@ -129,13 +147,20 @@ export type TtransferPatchNotificationRequest = { transferId?: components['schemas']['transferId']; }; -export interface ICoreConnectorAggregate { +export type TGetQuotesDeps = { + res: TCbsKycResponse; + fees: number; + expiration: string; + quoteRequest: TQuoteRequest +} + +export interface ICoreConnectorAggregate { sdkClient: ISDKClient; cbsClient: ICbsClient; cbsConfig: TCBSConfig; IdType: string; logger: ILogger; - getParties(id: string, IdType: string):Promise; + getParties(id: string, IdType: string): Promise; quoteRequest(quoteRequest: TQuoteRequest): Promise; receiveTransfer(transfer: TtransferRequest): Promise; updateTransfer(updateTransferPayload: TtransferPatchNotificationRequest, transferId: string): Promise; diff --git a/core-connector-template/test/int/domain/coreConnectorAgg.test.ts b/core-connector-template/test/int/domain/coreConnectorAgg.test.ts index e69de29..f9cb647 100644 --- a/core-connector-template/test/int/domain/coreConnectorAgg.test.ts +++ b/core-connector-template/test/int/domain/coreConnectorAgg.test.ts @@ -0,0 +1,55 @@ +/***** + License + -------------- + Copyright © 2017 Bill & Melinda Gates Foundation + The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Gates Foundation organization for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + * Gates Foundation + - Name Surname + + * Elijah Okello + -------------- + **********/ + +import axios from 'axios'; +import { Service } from '../../../src/core-connector-svc'; + +const SDK_SERVER_URL = 'http://localhost:3003'; +const DFSP_SERVER_URL = 'http://localhost:3004'; + +describe('CoreConnectorAggregate Tests -->', () => { + + beforeAll(async () => { + await Service.start(); + }); + + afterAll(async () => { + await Service.stop(); + }); + + describe("Payer Tests", () => { + test("GET /health for DFSP server ", async () => { + const res = await axios.get(`${DFSP_SERVER_URL}/health`); + expect(res.status).toEqual(200); + }); + }); + + describe("Payee Tests", () => { + test("GET /health for SDK Server", async ()=>{ + const res = await axios.get(`${SDK_SERVER_URL}/health`); + expect(res.status).toEqual(200); + }); + }); +}); diff --git a/core-connector-template/test/unit/domain/coreConnectorAgg.test.ts b/core-connector-template/test/unit/domain/coreConnectorAgg.test.ts index eb65469..ab0ab4a 100644 --- a/core-connector-template/test/unit/domain/coreConnectorAgg.test.ts +++ b/core-connector-template/test/unit/domain/coreConnectorAgg.test.ts @@ -34,7 +34,7 @@ import { import { AxiosClientFactory } from '../../../src/infra/axiosHttpClient'; import { loggerFactory } from '../../../src/infra/logger'; import config from '../../../src/config'; -import { CBSClientFactory, ICbsClient } from 'src/domain/CBSClient'; +import { CBSClientFactory, ICbsClient } from '../../../src/domain/CBSClient'; const mockAxios = new MockAdapter(axios); const logger = loggerFactory({ context: 'ccAgg tests' }); @@ -54,10 +54,15 @@ describe('CoreConnectorAggregate Tests -->', () => { ccAggregate = new CoreConnectorAggregate(sdkClient,cbsClient, cbsConfig, logger); }); - describe("Tests", ()=>{ + describe("Payee Tests", ()=>{ test("test", async ()=>{ - logger.info(ccAggregate.IdType); - throw new Error("Write tests"); + logger.info("Write payee tests"); + }); + }); + + describe("Payer Tests", ()=>{ + test("test", async ()=>{ + logger.info("Write payer tests") }); }); }); diff --git a/core-connector-template/test/unit/sdk_scheme_adapater.test.ts b/core-connector-template/test/unit/domain/sdk_scheme_adapater.test.ts similarity index 96% rename from core-connector-template/test/unit/sdk_scheme_adapater.test.ts rename to core-connector-template/test/unit/domain/sdk_scheme_adapater.test.ts index eb80efb..98a9bf7 100644 --- a/core-connector-template/test/unit/sdk_scheme_adapater.test.ts +++ b/core-connector-template/test/unit/domain/sdk_scheme_adapater.test.ts @@ -33,9 +33,9 @@ import { SDKClientFactory, TSDKOutboundTransferRequest, TSDKTransferContinuationRequest, -} from '../../src/domain/SDKClient'; -import { AxiosClientFactory } from '../../src/infra/axiosHttpClient'; -import { loggerFactory } from '../../src/infra/logger'; +} from '../../../src/domain/SDKClient'; +import { AxiosClientFactory } from '../../../src/infra/axiosHttpClient'; +import { loggerFactory } from '../../../src/infra/logger'; const mockAxios = new MockAdapter(axios); const SDK_URL = 'http://localhost:4040'; @@ -122,7 +122,7 @@ describe('SDK Scheme Adapter Unit Tests', () => { //act mockAxios.onAny().reply(200, {}); - const res = await sdkClient.updateTransfer(continueTransfer, 1); + const res = await sdkClient.updateTransfer(continueTransfer, "1"); // assert expect(res.statusCode).toEqual(200); diff --git a/core-connector-template/test/unit/index.test.ts b/core-connector-template/test/unit/index.test.ts deleted file mode 100644 index e69de29..0000000