From 5c6c817c78da8b42d629422accdce479596aa7cb Mon Sep 17 00:00:00 2001 From: Ruben Date: Mon, 16 Dec 2024 14:19:30 +0100 Subject: [PATCH 01/13] Nedbank implementation --- .../test-registrations-Nedbank.csv | 2 + services/.env.example | 25 +- services/121-service/.gitignore | 5 +- services/121-service/module-dependencies.md | 4 + services/121-service/src/app.module.ts | 2 + .../src/cronjob/cronjob.service.ts | 12 + .../financial-service-provider-name.enum.ts | 1 + ...ancial-service-providers-settings.const.ts | 15 + .../src/metrics/metrics.service.ts | 42 +- .../src/migration/1736155425026-nedbank.ts | 27 + .../intersolve-visa.api.service.ts | 15 +- .../create-order-request-body-nedbank.dto.ts | 26 + .../create-order-response-nedbank.dto.ts | 13 + .../nedbank-api/error-reponse-nedbank.dto.ts | 11 + .../get-order-reponse-nedbank.dto.ts | 33 ++ .../nedbank/enums/nedbank-error-code.enum.ts | 6 + .../enums/nedbank-voucher-status.enum.ts | 8 + .../nedbank/errors/nedbank.error.ts | 14 + .../nedbank-create-voucher-params.ts | 5 + .../nedbank/nedbank-voucher.entity.ts | 22 + .../fsp-integration/nedbank/nedbank.module.ts | 23 + .../nedbank/nedbank.service.spec.ts | 115 +++++ .../nedbank/nedbank.service.ts | 81 +++ .../nedbank-voucher.scoped.repository.ts | 50 ++ .../nedbank-api.helper.service.spec.ts | 307 ++++++++++++ .../services/nedbank-api.helper.service.ts | 137 ++++++ .../nedbank/services/nedbank-api.service.ts | 114 +++++ .../src/payments/payments.module.ts | 2 + .../src/payments/payments.service.ts | 85 +++- .../transactions/transaction.repository.ts | 18 +- .../enum/transaction-job-queue-names.enum.ts | 1 + .../queues-registry/queues-registry.module.ts | 12 + .../queues-registry.service.ts | 2 + .../nedbank-reconciliation.controller.ts | 35 ++ .../nedbank-reconciliation.module.ts | 13 + .../nedbank-reconciliation.service.ts | 77 +++ .../src/scripts/enum/seed-script.enum.ts | 2 +- .../src/scripts/seed-configuration.const.ts | 10 + .../organization/organization-sarc.json | 6 + .../seed-data/program/program-nedbank.json | 65 +++ .../services/custom-http.service.spec.ts | 48 ++ .../shared/services/custom-http.service.ts | 43 +- .../transaction-job-nedbank.processor.spec.ts | 83 ++++ .../transaction-job-nedbank.processor.ts | 35 ++ .../transaction-job-processors.module.ts | 7 +- ...transaction-job-processors.service.spec.ts | 170 +++++-- .../transaction-job-processors.service.ts | 136 ++++- .../dto/nedbank-transaction-job.dto.ts | 12 + .../transaction-queues.service.ts | 13 + .../121-service/src/utils/uuid.helpers.ts | 12 + services/121-service/swagger.json | 345 ++++++++++--- .../test/helpers/program.helper.ts | 30 +- .../test/helpers/registration.helper.ts | 4 +- .../test/helpers/utility.helper.ts | 7 + .../do-payment-fsp-nedbank.test.ts.snap | 5 + .../payment/do-payment-fsp-nedbank.test.ts | 464 ++++++++++++++++++ .../pagination/pagination-data.ts | 10 + .../test/visa-card/export-report.test.ts | 11 +- services/mock-service/src/app.module.ts | 2 + .../nedbank/nedbank.mock.controller.ts | 53 ++ .../nedbank/nedbank.mock.module.ts | 12 + .../nedbank/nedbank.mock.service.ts | 139 ++++++ 62 files changed, 2919 insertions(+), 165 deletions(-) create mode 100644 e2e/test-registration-data/test-registrations-Nedbank.csv create mode 100644 services/121-service/src/migration/1736155425026-nedbank.ts create mode 100644 services/121-service/src/payments/fsp-integration/nedbank/dtos/nedbank-api/create-order-request-body-nedbank.dto.ts create mode 100644 services/121-service/src/payments/fsp-integration/nedbank/dtos/nedbank-api/create-order-response-nedbank.dto.ts create mode 100644 services/121-service/src/payments/fsp-integration/nedbank/dtos/nedbank-api/error-reponse-nedbank.dto.ts create mode 100644 services/121-service/src/payments/fsp-integration/nedbank/dtos/nedbank-api/get-order-reponse-nedbank.dto.ts create mode 100644 services/121-service/src/payments/fsp-integration/nedbank/enums/nedbank-error-code.enum.ts create mode 100644 services/121-service/src/payments/fsp-integration/nedbank/enums/nedbank-voucher-status.enum.ts create mode 100644 services/121-service/src/payments/fsp-integration/nedbank/errors/nedbank.error.ts create mode 100644 services/121-service/src/payments/fsp-integration/nedbank/interfaces/nedbank-create-voucher-params.ts create mode 100644 services/121-service/src/payments/fsp-integration/nedbank/nedbank-voucher.entity.ts create mode 100644 services/121-service/src/payments/fsp-integration/nedbank/nedbank.module.ts create mode 100644 services/121-service/src/payments/fsp-integration/nedbank/nedbank.service.spec.ts create mode 100644 services/121-service/src/payments/fsp-integration/nedbank/nedbank.service.ts create mode 100644 services/121-service/src/payments/fsp-integration/nedbank/repositories/nedbank-voucher.scoped.repository.ts create mode 100644 services/121-service/src/payments/fsp-integration/nedbank/services/nedbank-api.helper.service.spec.ts create mode 100644 services/121-service/src/payments/fsp-integration/nedbank/services/nedbank-api.helper.service.ts create mode 100644 services/121-service/src/payments/fsp-integration/nedbank/services/nedbank-api.service.ts create mode 100644 services/121-service/src/reconciliation/nedbank-reconciliation/nedbank-reconciliation.controller.ts create mode 100644 services/121-service/src/reconciliation/nedbank-reconciliation/nedbank-reconciliation.module.ts create mode 100644 services/121-service/src/reconciliation/nedbank-reconciliation/nedbank-reconciliation.service.ts create mode 100644 services/121-service/src/seed-data/organization/organization-sarc.json create mode 100644 services/121-service/src/seed-data/program/program-nedbank.json create mode 100644 services/121-service/src/shared/services/custom-http.service.spec.ts create mode 100644 services/121-service/src/transaction-job-processors/processors/transaction-job-nedbank.processor.spec.ts create mode 100644 services/121-service/src/transaction-job-processors/processors/transaction-job-nedbank.processor.ts create mode 100644 services/121-service/src/transaction-queues/dto/nedbank-transaction-job.dto.ts create mode 100644 services/121-service/src/utils/uuid.helpers.ts create mode 100644 services/121-service/test/payment/__snapshots__/do-payment-fsp-nedbank.test.ts.snap create mode 100644 services/121-service/test/payment/do-payment-fsp-nedbank.test.ts create mode 100644 services/mock-service/src/fsp-integration/nedbank/nedbank.mock.controller.ts create mode 100644 services/mock-service/src/fsp-integration/nedbank/nedbank.mock.module.ts create mode 100644 services/mock-service/src/fsp-integration/nedbank/nedbank.mock.service.ts diff --git a/e2e/test-registration-data/test-registrations-Nedbank.csv b/e2e/test-registration-data/test-registrations-Nedbank.csv new file mode 100644 index 0000000000..5f54148899 --- /dev/null +++ b/e2e/test-registration-data/test-registrations-Nedbank.csv @@ -0,0 +1,2 @@ +referenceId,programFinancialServiceProviderConfigurationName,phoneNumber,preferredLanguage,paymentAmountMultiplier,fullName +,Nedbank,1234567890,en,1,Sample Name diff --git a/services/.env.example b/services/.env.example index 64239dabf3..abc5c23a06 100644 --- a/services/.env.example +++ b/services/.env.example @@ -26,6 +26,9 @@ NODE_ENV=development # Set to `:windows` if on windows, otherwise leave empty/undefined WINDOWS_DEV_STARTUP_SUFFIX= +# This is the UUID that is used as namespace when generating the UUIDs(v5) for Intersolve Visa transfers and Nedbank transfers +UUID_NAMESPACE= + # ---------- # API set up # ---------- @@ -173,6 +176,11 @@ CRON_INTERSOLVE_VISA_UPDATE_WALLET_DETAILS= # (Optional) Check CBE-accounts CRON_CBE_ACCOUNT_ENQUIRIES_VALIDATION= +# FSP-Specific: Nedbank +# (All Require Admin-account credentials to be set!) +# (Optional) Check Nedbank-vouchers +CRON_NEDBANK_VOUCHERS= + # -------------------------- # Interface(s) configuration @@ -253,8 +261,6 @@ INTERSOLVE_VISA_COVERLETTER_CODE=TESTINTERSOLVEVISACOVERLETTERCODE # Only use ca INTERSOLVE_VISA_ASSET_CODE=test-INTERSOLVE_VISA_ASSET_CODE INTERSOLVE_VISA_FUNDINGTOKEN_CODE=test_INTERSOLVE_VISA_FUNDINGTOKEN_CODE # Use pre-agreed code for Acceptance environment INTERSOLVE_VISA_TENANT_ID= -# This is the UUID that is used as namespace when generating the UUIDs(v5) for Intersolve Visa transfers -INTERSOLVE_VISA_UUID_NAMESPACE= # Sync data automatically with third parties (now only used for Intersolve Visa) # Use: `TRUE` to enable, leave empty or out to disable. @@ -288,6 +294,21 @@ COMMERCIAL_BANK_ETHIOPIA_USERNAME=test-COMMERCIAL_BANK_ETHIOPIA_USERNAME MOCK_COMMERCIAL_BANK_ETHIOPIA=TRUE +# -------------------- +# Third-party FSP: Nedbank +# -------------------- +NEDBANK_ACCOUNT_NUMBER=1009000675 +NEDBANK_CLIENT_ID=test-NEDBANK_CLIENT_ID +NEDBANK_CLIENT_SECRET=test-NEDBANK_CLIENT_SECRET +# This is the sandbox url which is also visible in the Nedbank API documentation +# In production and staging a different URL is used +NEDBANK_API_URL=https://b2b-api.nedbank.co.za/apimarket/b2b-sb/ +# To use a mock version of the NEDBANK API, use: `TRUE` to enable, leave empty or out to disable. +MOCK_NEDBANK=TRUE +##TODO: Add some documentation about the certificate +NEDBANK_CERTIFICATE_PATH=cert/APIMTPP_redcross_sandbox.pfx +NEDBANK_CERTIFICATE_PASSWORD= + # --------------------------------------------------------------------- # END of ENV-configuration # Make sure to store this file only temporary, or in a secure location! diff --git a/services/121-service/.gitignore b/services/121-service/.gitignore index 2411561cf1..7bf97ce494 100644 --- a/services/121-service/.gitignore +++ b/services/121-service/.gitignore @@ -18,11 +18,14 @@ swagger-spec.json .idea # certificates +# database certificate cert/DigiCertGlobalRootCA.crt.pem +# nedbank integration certificate +cert/APIMTPP_redcross_sandbox.pfx # Compodoc documentation documentation # AppMap tmp -sql_warning.txt \ No newline at end of file +sql_warning.txt diff --git a/services/121-service/module-dependencies.md b/services/121-service/module-dependencies.md index bd411a169f..c8a202c507 100644 --- a/services/121-service/module-dependencies.md +++ b/services/121-service/module-dependencies.md @@ -72,6 +72,7 @@ graph LR PaymentsModule-->SafaricomModule SafaricomModule-->RedisModule SafaricomModule-->QueuesRegistryModule + PaymentsModule-->NedbankModule PaymentsModule-->ExcelModule ExcelModule-->TransactionsModule ExcelModule-->RegistrationsModule @@ -102,6 +103,8 @@ graph LR MessageIncomingModule-->MessageTemplateModule MessageIncomingModule-->RegistrationDataModule MessageIncomingModule-->QueuesRegistryModule + NedbankReconciliationModule-->NedbankModule + NedbankReconciliationModule-->TransactionsModule NoteModule-->RegistrationsModule NoteModule-->UserModule ActivitiesModule-->NoteModule @@ -112,6 +115,7 @@ graph LR TransactionJobProcessorsModule-->RedisModule TransactionJobProcessorsModule-->IntersolveVisaModule TransactionJobProcessorsModule-->SafaricomModule + TransactionJobProcessorsModule-->NedbankModule TransactionJobProcessorsModule-->ProgramFinancialServiceProviderConfigurationsModule TransactionJobProcessorsModule-->RegistrationsModule TransactionJobProcessorsModule-->ProgramModule diff --git a/services/121-service/src/app.module.ts b/services/121-service/src/app.module.ts index 6691fa265a..ab8ef59a73 100644 --- a/services/121-service/src/app.module.ts +++ b/services/121-service/src/app.module.ts @@ -24,6 +24,7 @@ import { OrganizationModule } from '@121-service/src/organization/organization.m import { ProgramAidworkerAssignmentEntity } from '@121-service/src/programs/program-aidworker.entity'; import { ProgramModule } from '@121-service/src/programs/programs.module'; import { QueuesRegistryModule } from '@121-service/src/queues-registry/queues-registry.module'; +import { NedbankReconciliationModule } from '@121-service/src/reconciliation/nedbank-reconciliation/nedbank-reconciliation.module'; import { ScriptsModule } from '@121-service/src/scripts/scripts.module'; import { ProgramExistenceInterceptor } from '@121-service/src/shared/interceptors/program-existence.interceptor'; import { TransactionJobProcessorsModule } from '@121-service/src/transaction-job-processors/transaction-job-processors.module'; @@ -45,6 +46,7 @@ import { TypeOrmModule } from '@121-service/src/typeorm.module'; MessageModule, MetricsModule, MessageIncomingModule, + NedbankReconciliationModule, NoteModule, EmailsModule, ScheduleModule.forRoot(), diff --git a/services/121-service/src/cronjob/cronjob.service.ts b/services/121-service/src/cronjob/cronjob.service.ts index ab640c9722..c6c9918867 100644 --- a/services/121-service/src/cronjob/cronjob.service.ts +++ b/services/121-service/src/cronjob/cronjob.service.ts @@ -81,6 +81,18 @@ export class CronjobService { await this.httpService.post(url, {}, headers); } + // Nedbank's systems are not available between 0:00 and 3:00 at night South Africa time + @Cron(CronExpression.EVERY_DAY_AT_4AM, { + disabled: !shouldBeEnabled(process.env.CRON_NEDBANK_VOUCHERS), + }) + public async cronDoNedbankReconciliation(): Promise { + // Calling via API/HTTP instead of directly the Service so scope-functionality works, which needs a HTTP request to work which a cronjob does not have + const accessToken = await this.axiosCallsService.getAccessToken(); + const url = `${this.axiosCallsService.getBaseUrl()}/financial-service-providers/nedbank`; + const headers = this.axiosCallsService.accesTokenToHeaders(accessToken); + await this.httpService.patch(url, {}, headers); + } + @Cron(CronExpression.EVERY_DAY_AT_6AM, { disabled: !shouldBeEnabled(process.env.CRON_GET_DAILY_EXCHANGE_RATES), }) diff --git a/services/121-service/src/financial-service-providers/enum/financial-service-provider-name.enum.ts b/services/121-service/src/financial-service-providers/enum/financial-service-provider-name.enum.ts index b13e86f340..d8811d1db4 100644 --- a/services/121-service/src/financial-service-providers/enum/financial-service-provider-name.enum.ts +++ b/services/121-service/src/financial-service-providers/enum/financial-service-provider-name.enum.ts @@ -6,6 +6,7 @@ export enum FinancialServiceProviders { commercialBankEthiopia = 'Commercial-bank-ethiopia', excel = 'Excel', deprecatedJumbo = 'Intersolve-jumbo-physical', + nedbank = 'Nedbank', } export enum FinancialServiceProviderConfigurationProperties { diff --git a/services/121-service/src/financial-service-providers/financial-service-providers-settings.const.ts b/services/121-service/src/financial-service-providers/financial-service-providers-settings.const.ts index 84f63d5e4d..d3b933b897 100644 --- a/services/121-service/src/financial-service-providers/financial-service-providers-settings.const.ts +++ b/services/121-service/src/financial-service-providers/financial-service-providers-settings.const.ts @@ -178,4 +178,19 @@ export const FINANCIAL_SERVICE_PROVIDER_SETTINGS: FinancialServiceProviderDto[] attributes: [], configurationProperties: [], }, + { + name: FinancialServiceProviders.nedbank, + integrationType: FinancialServiceProviderIntegrationType.api, + defaultLabel: { + en: 'Nedbank', + }, + notifyOnTransaction: false, + attributes: [ + { + name: FinancialServiceProviderAttributes.phoneNumber, + isRequired: true, + }, + ], + configurationProperties: [], + }, ]; diff --git a/services/121-service/src/metrics/metrics.service.ts b/services/121-service/src/metrics/metrics.service.ts index 7847616ceb..c4581d0b63 100644 --- a/services/121-service/src/metrics/metrics.service.ts +++ b/services/121-service/src/metrics/metrics.service.ts @@ -17,6 +17,7 @@ import { ExportVisaCardDetails } from '@121-service/src/payments/fsp-integration import { ExportVisaCardDetailsRawData } from '@121-service/src/payments/fsp-integration/intersolve-visa/interfaces/export-visa-card-details-raw-data.interface'; import { IntersolveVisaStatusMapper } from '@121-service/src/payments/fsp-integration/intersolve-visa/mappers/intersolve-visa-status.mapper'; import { IntersolveVoucherService } from '@121-service/src/payments/fsp-integration/intersolve-voucher/intersolve-voucher.service'; +import { NedbankVoucherEntity } from '@121-service/src/payments/fsp-integration/nedbank/nedbank-voucher.entity'; import { SafaricomTransferEntity } from '@121-service/src/payments/fsp-integration/safaricom/entities/safaricom-transfer.entity'; import { TransactionStatusEnum } from '@121-service/src/payments/transactions/enums/transaction-status.enum'; import { TransactionEntity } from '@121-service/src/payments/transactions/transaction.entity'; @@ -790,12 +791,15 @@ export class MetricsService { await this.getAdditionalFspExportFields(programId); for (const field of additionalFspExportFields) { + const joinTableAlias = `joinTable${field.entityJoinedToTransaction.name}${field.attribute}`; transactionQuery.leftJoin( field.entityJoinedToTransaction, - 'joinTable', - 'transaction.id = joinTable.transactionId', + joinTableAlias, + `transaction.id = ${joinTableAlias}.transactionId`, + ); + transactionQuery.addSelect( + `"${joinTableAlias}"."${field.attribute}" as "${field.alias}"`, ); - transactionQuery.addSelect(`"${field.attribute}"`); } const duplicateNames = registrationDataOptions @@ -863,10 +867,12 @@ export class MetricsService { return result; } - private async getAdditionalFspExportFields( - programId: number, - ): Promise< - { entityJoinedToTransaction: EntityClass; attribute: string }[] + private async getAdditionalFspExportFields(programId: number): Promise< + { + entityJoinedToTransaction: EntityClass; + attribute: string; + alias: string; + }[] > { const program = await this.programRepository.findOneOrFail({ where: { id: Equal(programId) }, @@ -875,6 +881,7 @@ export class MetricsService { let fields: { entityJoinedToTransaction: EntityClass; attribute: string; + alias: string; }[] = []; for (const fspConfig of program.programFinancialServiceProviderConfigurations) { @@ -888,6 +895,27 @@ export class MetricsService { { entityJoinedToTransaction: SafaricomTransferEntity, attribute: 'mpesaTransactionId', + alias: 'mpesaTransactionId', + }, + ], + ]; + } + if ( + fspConfig.financialServiceProviderName === + FinancialServiceProviders.nedbank + ) { + fields = [ + ...fields, + ...[ + { + entityJoinedToTransaction: NedbankVoucherEntity, //TODO: should we move this to financial-service-providers-settings.const.ts? + attribute: 'status', + alias: 'nedbankVoucherStatus', + }, + { + entityJoinedToTransaction: NedbankVoucherEntity, //TODO: should we move this to financial-service-providers-settings.const.ts? + attribute: 'orderCreateReference', + alias: 'nedbankOrderCreateReference', }, ], ]; diff --git a/services/121-service/src/migration/1736155425026-nedbank.ts b/services/121-service/src/migration/1736155425026-nedbank.ts new file mode 100644 index 0000000000..e4e74096f9 --- /dev/null +++ b/services/121-service/src/migration/1736155425026-nedbank.ts @@ -0,0 +1,27 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class Nedbank1736155425026 implements MigrationInterface { + name = 'Nedbank1736155425026'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "121-service"."nedbank_voucher" ("id" SERIAL NOT NULL, "created" TIMESTAMP NOT NULL DEFAULT now(), "updated" TIMESTAMP NOT NULL DEFAULT now(), "orderCreateReference" character varying NOT NULL, "status" character varying, "transactionId" integer NOT NULL, CONSTRAINT "UQ_3a31e9cd76bd9c06826c016c130" UNIQUE ("orderCreateReference"), CONSTRAINT "REL_739b726eaa8f29ede851906edd" UNIQUE ("transactionId"), CONSTRAINT "PK_85d56d9ed997ba24b53b3aa36e7" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_0db7adee73f8cc5c9d44a77e7b" ON "121-service"."nedbank_voucher" ("created") `, + ); + await queryRunner.query( + `ALTER TABLE "121-service"."nedbank_voucher" ADD CONSTRAINT "FK_739b726eaa8f29ede851906edd3" FOREIGN KEY ("transactionId") REFERENCES "121-service"."transaction"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "121-service"."nedbank_voucher" DROP CONSTRAINT "FK_739b726eaa8f29ede851906edd3"`, + ); + await queryRunner.query( + `DROP INDEX "121-service"."IDX_0db7adee73f8cc5c9d44a77e7b"`, + ); + await queryRunner.query(`DROP TABLE "121-service"."nedbank_voucher"`); + } +} diff --git a/services/121-service/src/payments/fsp-integration/intersolve-visa/intersolve-visa.api.service.ts b/services/121-service/src/payments/fsp-integration/intersolve-visa/intersolve-visa.api.service.ts index a8fde88b56..e977552a0a 100644 --- a/services/121-service/src/payments/fsp-integration/intersolve-visa/intersolve-visa.api.service.ts +++ b/services/121-service/src/payments/fsp-integration/intersolve-visa/intersolve-visa.api.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { Issuer, TokenSet } from 'openid-client'; -import { v4 as uuid, v5 as uuidv5 } from 'uuid'; +import { v4 as uuid } from 'uuid'; import { CreateCustomerRequestIntersolveApiDto } from '@121-service/src/payments/fsp-integration/intersolve-visa/dtos/intersolve-api/create-customer-request-intersolve-api.dto'; import { CreateCustomerResponseIntersolveApiDto } from '@121-service/src/payments/fsp-integration/intersolve-visa/dtos/intersolve-api/create-customer-response-intersolve-api.dto'; @@ -28,18 +28,7 @@ import { IntersolveVisaApiError } from '@121-service/src/payments/fsp-integratio import { CustomHttpService } from '@121-service/src/shared/services/custom-http.service'; import { formatPhoneNumber } from '@121-service/src/utils/phone-number.helpers'; import { TokenValidationService } from '@121-service/src/utils/token/token-validation.service'; - -const INTERSOLVE_VISA_UUID_NAMESPACE = - process.env.INTERSOLVE_VISA_UUID_NAMESPACE || uuid(); - -/** - * Generate a UUID v5 based on a seed. - * @param seed The seed to generate the UUID. - * @returns The generated UUID. - */ -function generateUUIDFromSeed(seed: string): string { - return uuidv5(seed, INTERSOLVE_VISA_UUID_NAMESPACE); -} +import { generateUUIDFromSeed } from '@121-service/src/utils/uuid.helpers'; const intersolveVisaApiUrl = process.env.MOCK_INTERSOLVE ? `${process.env.MOCK_SERVICE_URL}api/fsp/intersolve-visa` diff --git a/services/121-service/src/payments/fsp-integration/nedbank/dtos/nedbank-api/create-order-request-body-nedbank.dto.ts b/services/121-service/src/payments/fsp-integration/nedbank/dtos/nedbank-api/create-order-request-body-nedbank.dto.ts new file mode 100644 index 0000000000..0f2fa68f38 --- /dev/null +++ b/services/121-service/src/payments/fsp-integration/nedbank/dtos/nedbank-api/create-order-request-body-nedbank.dto.ts @@ -0,0 +1,26 @@ +export interface NedbankCreateOrderRequestBodyDto { + Data: { + Initiation: { + InstructionIdentification: string; + InstructedAmount: { + Amount: string; // This should be a string with two decimal places + Currency: 'ZAR'; // This should always be 'ZAR' + }; + DebtorAccount: { + SchemeName: 'account'; // should always be 'account' + Identification: string; + Name: string; + }; + CreditorAccount: { + SchemeName: string; + Identification: string; + Name: string; + }; + }; + ExpirationDateTime: string; + }; + Risk: { + OrderCreateReference: string; + OrderDateTime: string; + }; +} diff --git a/services/121-service/src/payments/fsp-integration/nedbank/dtos/nedbank-api/create-order-response-nedbank.dto.ts b/services/121-service/src/payments/fsp-integration/nedbank/dtos/nedbank-api/create-order-response-nedbank.dto.ts new file mode 100644 index 0000000000..68b49f97ed --- /dev/null +++ b/services/121-service/src/payments/fsp-integration/nedbank/dtos/nedbank-api/create-order-response-nedbank.dto.ts @@ -0,0 +1,13 @@ +import { NedbankVoucherStatus } from '@121-service/src/payments/fsp-integration/nedbank/enums/nedbank-voucher-status.enum'; + +// Named the interface 'CreateOrder' in line with https://apim.nedbank.co.za/static/docs/cashout-create-order +export interface CreateOrderResponseNedbankDto { + Data: { + OrderId: string; + Status: NedbankVoucherStatus; + }; + Links: { + Self: string; + }; + Meta: Record; +} diff --git a/services/121-service/src/payments/fsp-integration/nedbank/dtos/nedbank-api/error-reponse-nedbank.dto.ts b/services/121-service/src/payments/fsp-integration/nedbank/dtos/nedbank-api/error-reponse-nedbank.dto.ts new file mode 100644 index 0000000000..8008d63954 --- /dev/null +++ b/services/121-service/src/payments/fsp-integration/nedbank/dtos/nedbank-api/error-reponse-nedbank.dto.ts @@ -0,0 +1,11 @@ +export interface ErrorReponseNedbankDto { + Message: string; + Code: string; + Id: string; + Errors: { + ErrorCode: string; + Message: string; + Path?: string; + Url?: string; + }[]; +} diff --git a/services/121-service/src/payments/fsp-integration/nedbank/dtos/nedbank-api/get-order-reponse-nedbank.dto.ts b/services/121-service/src/payments/fsp-integration/nedbank/dtos/nedbank-api/get-order-reponse-nedbank.dto.ts new file mode 100644 index 0000000000..c634303183 --- /dev/null +++ b/services/121-service/src/payments/fsp-integration/nedbank/dtos/nedbank-api/get-order-reponse-nedbank.dto.ts @@ -0,0 +1,33 @@ +import { NedbankVoucherStatus } from '@121-service/src/payments/fsp-integration/nedbank/enums/nedbank-voucher-status.enum'; + +export interface GetOrderResponseNedbankDto { + Data: { + OrderId: string; + Transactions: { + Voucher: { + Code: string; + Status: NedbankVoucherStatus; + Redeem: { + Redeemable: boolean; + Redeemed: boolean; + RedeemedOn: string; + RedeemedAt: string; + }; + Refund: { + Refundable: boolean; + Refunded: boolean; + RefundedOn: string; + }; + Pin: string; + }; + PaymentReferenceNumber: string; + OrderCreateReference: string; + OrderDateTime: string; + OrderExpiry: string; + }; + }; + Links: { + Self: string; + }; + Meta: Record; +} diff --git a/services/121-service/src/payments/fsp-integration/nedbank/enums/nedbank-error-code.enum.ts b/services/121-service/src/payments/fsp-integration/nedbank/enums/nedbank-error-code.enum.ts new file mode 100644 index 0000000000..b6c4c97f49 --- /dev/null +++ b/services/121-service/src/payments/fsp-integration/nedbank/enums/nedbank-error-code.enum.ts @@ -0,0 +1,6 @@ +// This is non-exhaustive enum of error codes that can be returned by Nedbank API +// It is used to identify the error code and use it for business logic in our code +// Since nedbank API is not documented, only the error codes that are specifically handled in our code are included +export enum NedbankErrorCode { + NBApimResourceNotFound = 'NB.APIM.Resource.NotFound', +} diff --git a/services/121-service/src/payments/fsp-integration/nedbank/enums/nedbank-voucher-status.enum.ts b/services/121-service/src/payments/fsp-integration/nedbank/enums/nedbank-voucher-status.enum.ts new file mode 100644 index 0000000000..02c9d56c11 --- /dev/null +++ b/services/121-service/src/payments/fsp-integration/nedbank/enums/nedbank-voucher-status.enum.ts @@ -0,0 +1,8 @@ +export enum NedbankVoucherStatus { + PENDING = 'PENDING', + PROCESSING = 'PROCESSING', + REDEEMABLE = 'REDEEMABLE', + REDEEMED = 'REDEEMED', + REFUNDED = 'REFUNDED', + FAILED = 'FAILED', // This status is used in the 121-platform to indicate that the voucher failed to be created because we got a negative response from Nedbank +} diff --git a/services/121-service/src/payments/fsp-integration/nedbank/errors/nedbank.error.ts b/services/121-service/src/payments/fsp-integration/nedbank/errors/nedbank.error.ts new file mode 100644 index 0000000000..7306115b23 --- /dev/null +++ b/services/121-service/src/payments/fsp-integration/nedbank/errors/nedbank.error.ts @@ -0,0 +1,14 @@ +import { NedbankErrorCode } from '@121-service/src/payments/fsp-integration/nedbank/enums/nedbank-error-code.enum'; + +// Usage: throw new NedbankError('Error message'); +export class NedbankError extends Error { + public code: string | NedbankErrorCode | undefined; + + constructor(message?: string, code?: string | NedbankErrorCode) { + super(message); + this.name = 'NedbankError'; + this.code = code; + + Object.setPrototypeOf(this, new.target.prototype); + } +} diff --git a/services/121-service/src/payments/fsp-integration/nedbank/interfaces/nedbank-create-voucher-params.ts b/services/121-service/src/payments/fsp-integration/nedbank/interfaces/nedbank-create-voucher-params.ts new file mode 100644 index 0000000000..0cfb86bcf6 --- /dev/null +++ b/services/121-service/src/payments/fsp-integration/nedbank/interfaces/nedbank-create-voucher-params.ts @@ -0,0 +1,5 @@ +export interface NedbankCreateVoucherParams { + transferAmount: number; + phoneNumber: string; + orderCreateReference: string; +} diff --git a/services/121-service/src/payments/fsp-integration/nedbank/nedbank-voucher.entity.ts b/services/121-service/src/payments/fsp-integration/nedbank/nedbank-voucher.entity.ts new file mode 100644 index 0000000000..67fff64881 --- /dev/null +++ b/services/121-service/src/payments/fsp-integration/nedbank/nedbank-voucher.entity.ts @@ -0,0 +1,22 @@ +import { Column, Entity, JoinColumn, OneToOne } from 'typeorm'; + +import { Base121Entity } from '@121-service/src/base.entity'; +import { NedbankVoucherStatus } from '@121-service/src/payments/fsp-integration/nedbank/enums/nedbank-voucher-status.enum'; +import { TransactionEntity } from '@121-service/src/payments/transactions/transaction.entity'; + +@Entity('nedbank_voucher') +export class NedbankVoucherEntity extends Base121Entity { + @Column({ unique: true }) + public orderCreateReference: string; + + @Column({ nullable: true, type: 'character varying' }) + public status: NedbankVoucherStatus; + + @OneToOne(() => TransactionEntity, { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'transactionId' }) + transaction: TransactionEntity; + @Column({ type: 'int', nullable: false }) + public transactionId: number; +} diff --git a/services/121-service/src/payments/fsp-integration/nedbank/nedbank.module.ts b/services/121-service/src/payments/fsp-integration/nedbank/nedbank.module.ts new file mode 100644 index 0000000000..13e1663116 --- /dev/null +++ b/services/121-service/src/payments/fsp-integration/nedbank/nedbank.module.ts @@ -0,0 +1,23 @@ +import { HttpModule } from '@nestjs/axios'; +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { NedbankService } from '@121-service/src/payments/fsp-integration/nedbank/nedbank.service'; +import { NedbankVoucherEntity } from '@121-service/src/payments/fsp-integration/nedbank/nedbank-voucher.entity'; +import { NedbankVoucherScopedRepository } from '@121-service/src/payments/fsp-integration/nedbank/repositories/nedbank-voucher.scoped.repository'; +import { NedbankApiHelperService } from '@121-service/src/payments/fsp-integration/nedbank/services/nedbank-api.helper.service'; +import { NedbankApiService } from '@121-service/src/payments/fsp-integration/nedbank/services/nedbank-api.service'; +import { CustomHttpService } from '@121-service/src/shared/services/custom-http.service'; + +@Module({ + imports: [HttpModule, TypeOrmModule.forFeature([NedbankVoucherEntity])], + providers: [ + NedbankService, + NedbankApiService, + CustomHttpService, + NedbankVoucherScopedRepository, + NedbankApiHelperService, + ], + exports: [NedbankService, NedbankVoucherScopedRepository], +}) +export class NedbankModule {} diff --git a/services/121-service/src/payments/fsp-integration/nedbank/nedbank.service.spec.ts b/services/121-service/src/payments/fsp-integration/nedbank/nedbank.service.spec.ts new file mode 100644 index 0000000000..cd537da4fe --- /dev/null +++ b/services/121-service/src/payments/fsp-integration/nedbank/nedbank.service.spec.ts @@ -0,0 +1,115 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { UpdateResult } from 'typeorm'; + +import { NedbankVoucherStatus } from '@121-service/src/payments/fsp-integration/nedbank/enums/nedbank-voucher-status.enum'; +import { NedbankError } from '@121-service/src/payments/fsp-integration/nedbank/errors/nedbank.error'; +import { NedbankService } from '@121-service/src/payments/fsp-integration/nedbank/nedbank.service'; +import { NedbankVoucherScopedRepository } from '@121-service/src/payments/fsp-integration/nedbank/repositories/nedbank-voucher.scoped.repository'; +import { NedbankApiService } from '@121-service/src/payments/fsp-integration/nedbank/services/nedbank-api.service'; +import { registrationNedbank } from '@121-service/test/registrations/pagination/pagination-data'; + +const orderCreateReference = `mock-uuid`; + +jest.mock('./nedbank-api.service'); +jest.mock('./repositories/nedbank-voucher.scoped.repository'); +jest.mock('@121-service/src/utils/uuid.helpers'); + +describe('NedbankService', () => { + let service: NedbankService; + let apiService: NedbankApiService; + let voucherRepository: NedbankVoucherScopedRepository; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + NedbankService, + { + provide: NedbankApiService, + useValue: { + createOrder: jest.fn(), + getOrderByOrderCreateReference: jest.fn(), + }, + }, + { + provide: NedbankVoucherScopedRepository, + useValue: { + update: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(NedbankService); + apiService = module.get(NedbankApiService); + voucherRepository = module.get( + NedbankVoucherScopedRepository, + ); + }); + + describe('createVoucher', () => { + it('should create a voucher successfully', async () => { + const amount = 200; + jest + .spyOn(apiService, 'createOrder') + .mockResolvedValue(NedbankVoucherStatus.PENDING); + + const result = await service.createVoucher({ + transferAmount: amount, + phoneNumber: registrationNedbank.phoneNumber, + orderCreateReference, + }); + + expect(result).toEqual(NedbankVoucherStatus.PENDING); + + expect(apiService.createOrder).toHaveBeenCalledWith({ + transferAmount: amount, + phoneNumber: registrationNedbank.phoneNumber, + orderCreateReference, + }); + }); + + it('should throw an error if amount is not a multiple of 10', async () => { + const amount = 25; + await expect( + service.createVoucher({ + transferAmount: amount, // Not a multiple of 10 + phoneNumber: registrationNedbank.phoneNumber, + orderCreateReference, + }), + ).rejects.toThrow(NedbankError); + + await expect( + service.createVoucher({ + transferAmount: amount, // Not a multiple of 10 + phoneNumber: registrationNedbank.phoneNumber, + orderCreateReference, + }), + ).rejects.toThrow('Amount must be a multiple of 10'); + + expect(apiService.createOrder).not.toHaveBeenCalled(); + }); + }); + + describe('retrieveAndUpdateVoucherStatus', () => { + it('should retrieve and update voucher status successfully', async () => { + jest + .spyOn(apiService, 'getOrderByOrderCreateReference') + .mockResolvedValue(NedbankVoucherStatus.REDEEMABLE); + jest + .spyOn(voucherRepository, 'update') + .mockResolvedValue({} as UpdateResult); + + const result = + await service.retrieveAndUpdateVoucherStatus(orderCreateReference); + + expect(result).toBe(NedbankVoucherStatus.REDEEMABLE); + expect(apiService.getOrderByOrderCreateReference).toHaveBeenCalledWith( + orderCreateReference, + ); + expect(voucherRepository.update).toHaveBeenCalledWith( + { orderCreateReference }, + { status: NedbankVoucherStatus.REDEEMABLE }, + ); + }); + }); +}); diff --git a/services/121-service/src/payments/fsp-integration/nedbank/nedbank.service.ts b/services/121-service/src/payments/fsp-integration/nedbank/nedbank.service.ts new file mode 100644 index 0000000000..4903b3cee2 --- /dev/null +++ b/services/121-service/src/payments/fsp-integration/nedbank/nedbank.service.ts @@ -0,0 +1,81 @@ +import { Injectable } from '@nestjs/common'; + +import { PaPaymentDataDto } from '@121-service/src/payments/dto/pa-payment-data.dto'; +import { FinancialServiceProviderIntegrationInterface } from '@121-service/src/payments/fsp-integration/fsp-integration.interface'; +import { NedbankErrorCode } from '@121-service/src/payments/fsp-integration/nedbank/enums/nedbank-error-code.enum'; +import { NedbankVoucherStatus } from '@121-service/src/payments/fsp-integration/nedbank/enums/nedbank-voucher-status.enum'; +import { NedbankError } from '@121-service/src/payments/fsp-integration/nedbank/errors/nedbank.error'; +import { NedbankCreateVoucherParams } from '@121-service/src/payments/fsp-integration/nedbank/interfaces/nedbank-create-voucher-params'; +import { NedbankVoucherScopedRepository } from '@121-service/src/payments/fsp-integration/nedbank/repositories/nedbank-voucher.scoped.repository'; +import { NedbankApiService } from '@121-service/src/payments/fsp-integration/nedbank/services/nedbank-api.service'; + +@Injectable() +export class NedbankService + implements FinancialServiceProviderIntegrationInterface +{ + public constructor( + private readonly nedbankApiService: NedbankApiService, + private readonly nedbankVoucherScopedRepository: NedbankVoucherScopedRepository, + ) {} + + /** + * Do not use! This function was previously used to send payments. + * It has been deprecated and should not be called anymore. + */ + public async sendPayment( + _paymentList: PaPaymentDataDto[], + _programId: number, + _paymentNr: number, + ): Promise { + throw new Error('Method should not be called anymore.'); + } + + public async createVoucher({ + transferAmount, + phoneNumber, + orderCreateReference, + }: NedbankCreateVoucherParams): Promise { + const isAmountMultipleOf10 = transferAmount % 10 === 0; + if (!isAmountMultipleOf10) { + throw new NedbankError('Amount must be a multiple of 10'); + } + + return await this.nedbankApiService.createOrder({ + transferAmount, + phoneNumber, + orderCreateReference, + }); + } + + public async retrieveAndUpdateVoucherStatus( + orderCreateReference: string, + ): Promise { + let voucherStatus: NedbankVoucherStatus; + try { + voucherStatus = + await this.nedbankApiService.getOrderByOrderCreateReference( + orderCreateReference, + ); + } catch (error) { + if ( + error instanceof NedbankError && + error.code === NedbankErrorCode.NBApimResourceNotFound // Should we abstract this error code to a 121 error code? + ) { + // This condition handles the specific case where the voucher was never created in. + // This situation can occur if: + // 1. The server crashed during the transaction job before the voucher was created. + // 2. We never received a response from Nedbank when creating the voucher. + // In this case, we update the transaction status to 'error' so that the user can retry the transfer. + voucherStatus = NedbankVoucherStatus.FAILED; + } else { + throw error; + } + } + + await this.nedbankVoucherScopedRepository.update( + { orderCreateReference }, + { status: voucherStatus }, + ); + return voucherStatus; + } +} diff --git a/services/121-service/src/payments/fsp-integration/nedbank/repositories/nedbank-voucher.scoped.repository.ts b/services/121-service/src/payments/fsp-integration/nedbank/repositories/nedbank-voucher.scoped.repository.ts new file mode 100644 index 0000000000..9dad1de401 --- /dev/null +++ b/services/121-service/src/payments/fsp-integration/nedbank/repositories/nedbank-voucher.scoped.repository.ts @@ -0,0 +1,50 @@ +import { Inject } from '@nestjs/common'; +import { REQUEST } from '@nestjs/core'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { NedbankVoucherStatus } from '@121-service/src/payments/fsp-integration/nedbank/enums/nedbank-voucher-status.enum'; +import { NedbankVoucherEntity } from '@121-service/src/payments/fsp-integration/nedbank/nedbank-voucher.entity'; +import { ScopedRepository } from '@121-service/src/scoped.repository'; +import { ScopedUserRequest } from '@121-service/src/shared/scoped-user-request'; + +export class NedbankVoucherScopedRepository extends ScopedRepository { + constructor( + @Inject(REQUEST) request: ScopedUserRequest, + @InjectRepository(NedbankVoucherEntity) + scopedRepository: Repository, + ) { + super(request, scopedRepository); + } + + public async storeVoucher({ + orderCreateReference, + transactionId, + voucherStatus, + }: { + orderCreateReference: string; + transactionId: number; + voucherStatus?: NedbankVoucherStatus; + }): Promise { + const nedbankVoucherEntity = this.create({ + orderCreateReference, + status: voucherStatus, + transactionId, + }); + await this.save(nedbankVoucherEntity); + } + + public async getVoucherWithoutStatus({ + registrationId, + paymentId, + }): Promise { + return await this.createQueryBuilder('nedbankVoucher') + .leftJoin('nedbankVoucher.transaction', 'transaction') + .andWhere('transaction.registrationId = :registrationId', { + registrationId, + }) + .andWhere('transaction.payment = :paymentId', { paymentId }) + .andWhere('nedbankVoucher.status IS NULL') + .getOne(); + } +} diff --git a/services/121-service/src/payments/fsp-integration/nedbank/services/nedbank-api.helper.service.spec.ts b/services/121-service/src/payments/fsp-integration/nedbank/services/nedbank-api.helper.service.spec.ts new file mode 100644 index 0000000000..430d7530de --- /dev/null +++ b/services/121-service/src/payments/fsp-integration/nedbank/services/nedbank-api.helper.service.spec.ts @@ -0,0 +1,307 @@ +import { AxiosResponse } from '@nestjs/terminus/dist/health-indicator/http/axios.interfaces'; +import { Test, TestingModule } from '@nestjs/testing'; +import * as https from 'https'; + +import { CreateOrderResponseNedbankDto } from '@121-service/src/payments/fsp-integration/nedbank/dtos/nedbank-api/create-order-response-nedbank.dto'; +import { ErrorReponseNedbankDto } from '@121-service/src/payments/fsp-integration/nedbank/dtos/nedbank-api/error-reponse-nedbank.dto'; +import { NedbankVoucherStatus } from '@121-service/src/payments/fsp-integration/nedbank/enums/nedbank-voucher-status.enum'; +import { NedbankError } from '@121-service/src/payments/fsp-integration/nedbank/errors/nedbank.error'; +import { NedbankApiHelperService } from '@121-service/src/payments/fsp-integration/nedbank/services/nedbank-api.helper.service'; +import { CustomHttpService } from '@121-service/src/shared/services/custom-http.service'; + +jest.mock('@121-service/src/shared/services/custom-http.service'); + +describe('NedbankApiHelperService', () => { + let service: NedbankApiHelperService; + let httpService: CustomHttpService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + NedbankApiHelperService, + { + provide: CustomHttpService, + useValue: { + request: jest.fn(), + createHttpsAgentWithCertificate: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(NedbankApiHelperService); + httpService = module.get(CustomHttpService); + + // Mock the https.Agent + const mockHttpsAgent = {} as https.Agent; + + // Set the mock https.Agent in the service + service.httpsAgent = mockHttpsAgent; + }); + + describe('makeApiRequestOrThrow', () => { + it('should succesfully make an request', async () => { + // Arrange + const payload = { test: 'test' }; + const url = 'https://example.com'; + const response: AxiosResponse = { + data: { + Data: { + OrderId: '', + Status: NedbankVoucherStatus.PENDING, + }, + Links: { + Self: '', + }, + Meta: {}, + }, + status: 201, + statusText: 'OK', + headers: {}, + config: {}, + }; + const method = 'POST'; + + jest.spyOn(httpService, 'request').mockResolvedValue(response); + + // Act + const result = await service.makeApiRequestOrThrow({ + url, + method, + payload, + }); + + // Assert + expect(result).toEqual(response); + const requestCallArgs = (httpService.request as jest.Mock).mock.calls[0]; + const requestCallParamObject = requestCallArgs[0]; + + // ##TODO: Discuss asserting the requestCallParamObject is the best approach should we use a more 'black box' approach and only assert the return value? + // Is the typescript static typing enough to ensure that the correct headers, payload and method are set? + expect(requestCallParamObject.payload).toMatchObject(payload); + + // Check the headers + expect(requestCallParamObject.headers).toEqual([ + { name: 'x-ibm-client-id', value: process.env.NEDBANK_CLIENT_ID }, + { + name: 'x-ibm-client-secret', + value: process.env.NEDBANK_CLIENT_SECRET, + }, + { name: 'x-idempotency-key', value: expect.any(String) }, + { name: 'x-jws-signature', value: expect.any(String) }, + { name: 'x-fapi-financial-id', value: 'OB/2017/001' }, + { + name: 'x-fapi-customer-ip-address', + value: expect.stringMatching( + /^(?:(?:[0-9]{1,3}\.){3}[0-9]{1,3}|[0-9a-fA-F:]+)$/, + ), + }, + { + name: 'x-fapi-interaction-id', + value: expect.stringMatching( + /^[0-9a-f]{8}-[0-9a-f]{4}-[4][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, + ), + }, + { name: 'Content-Type', value: 'application/json' }, + ]); + expect(requestCallParamObject.method).toBe(method); + }); + + it('should throw an error if httpsAgent is not defined', async () => { + service.httpsAgent = undefined; + await expect( + service.makeApiRequestOrThrow({ + url: 'https://example.com', + method: 'GET', + }), + ).rejects.toThrow(NedbankError); + }); + + describe('throw an error and format the error message', () => { + it('should handle multiple Nedbank errors', async () => { + const errorResponse: AxiosResponse = { + data: { + Message: 'BUSINESS ERROR', + Code: 'NB.APIM.Field.Invalid', + Id: '1d3e3076-9e1c-4933-aa7f-69290941ec70', + Errors: [ + { + ErrorCode: 'NB.APIM.Field.Invalid', + Message: + 'Request Validation Error - TPP account configuration mismatch', + Path: '', + Url: '', + }, + { + ErrorCode: 'NB.APIM.Field.Invalid', + Message: 'Another error message', + Path: '', + Url: '', + }, + ], + }, + status: 201, + statusText: '', + headers: {}, + config: {}, + }; + + jest.spyOn(httpService, 'request').mockResolvedValue(errorResponse); + + let errorOnCreateOrder: NedbankError | any; // The any is unfortunately needed to prevent type errors; + try { + await service.makeApiRequestOrThrow({ + url: 'url', + method: 'POST', + payload: {}, + }); + } catch (error) { + errorOnCreateOrder = error; + } + const errorMessage1 = errorResponse.data.Errors[0].Message; + expect(errorOnCreateOrder.message).toContain(errorMessage1); + const errorMessage2 = errorResponse.data.Errors[1].Message; + expect(errorOnCreateOrder.message).toContain(errorMessage2); + expect(errorOnCreateOrder.message).toContain( + `Message: ${errorResponse.data.Message}`, + ); + expect(errorOnCreateOrder.message).toContain( + `Code: ${errorResponse.data.Code}`, + ); + expect(errorOnCreateOrder.message).toContain( + `Id: ${errorResponse.data.Id}`, + ); + }); + + it('should handle a single Nedbank error', async () => { + const errorResponse: AxiosResponse = { + data: { + Message: 'BUSINESS ERROR', + Code: 'NB.APIM.Field.Invalid', + Id: '1d3e3076-9e1c-4933-aa7f-69290941ec70', + Errors: [ + { + ErrorCode: 'NB.APIM.Field.Invalid', + Message: + 'Request Validation Error - TPP account configuration mismatch', + Path: '', + Url: '', + }, + ], + }, + status: 201, + statusText: '', + headers: {}, + config: {}, + }; + + jest.spyOn(httpService, 'request').mockResolvedValue(errorResponse); + + let errorOnCreateOrder: NedbankError | any; // The any is unfortunately needed to prevent type errors; + try { + await service.makeApiRequestOrThrow({ + url: 'url', + method: 'POST', + payload: {}, + }); + } catch (error) { + errorOnCreateOrder = error; + } + const errorMessage = errorResponse.data.Errors[0].Message; + expect(errorOnCreateOrder.message).toContain(errorMessage); + expect(errorOnCreateOrder.message).toContain( + `Message: ${errorResponse.data.Message}`, + ); + expect(errorOnCreateOrder.message).toContain( + `Code: ${errorResponse.data.Code}`, + ); + expect(errorOnCreateOrder.message).toContain( + `Id: ${errorResponse.data.Id}`, + ); + }); + + it('should handle an empty error array from Nedbank', async () => { + const errorResponse: AxiosResponse = { + data: { + Message: 'BUSINESS ERROR', + Code: 'NB.APIM.Field.Invalid', + Id: '1d3e3076-9e1c-4933-aa7f-69290941ec70', + Errors: [], + }, + status: 201, + statusText: '', + headers: {}, + config: {}, + }; + + jest.spyOn(httpService, 'request').mockResolvedValue(errorResponse); + + let errorOnCreateOrder: NedbankError | any; // The any is unfortunately needed to prevent type errors; + try { + await service.makeApiRequestOrThrow({ + url: 'url', + method: 'POST', + payload: {}, + }); + } catch (error) { + errorOnCreateOrder = error; + } + + expect(errorOnCreateOrder.message).toContain( + `Message: ${errorResponse.data.Message}`, + ); + expect(errorOnCreateOrder.message).toContain( + `Code: ${errorResponse.data.Code}`, + ); + expect(errorOnCreateOrder.message).toContain( + `Id: ${errorResponse.data.Id}`, + ); + }); + + it('should handle an error without message', async () => { + const errorResponse: AxiosResponse = { + data: { + Message: '', + Code: 'NB.APIM.Field.Invalid', + Id: '1d3e3076-9e1c-4933-aa7f-69290941ec70', + Errors: [ + { + ErrorCode: 'NB.APIM.Field.Invalid', + Message: '', + Path: '', + Url: '', + }, + ], + }, + status: 201, + statusText: '', + headers: {}, + config: {}, + }; + + jest.spyOn(httpService, 'request').mockResolvedValue(errorResponse); + + let errorOnCreateOrder: NedbankError | any; // The any is unfortunately needed to prevent type errors; + try { + await service.makeApiRequestOrThrow({ + url: 'url', + method: 'POST', + payload: {}, + }); + } catch (error) { + errorOnCreateOrder = error; + } + expect(errorOnCreateOrder).toBeInstanceOf(NedbankError); + expect(errorOnCreateOrder.message).not.toContain( + `Message: ${errorResponse.data.Message}`, + ); + expect(errorOnCreateOrder.message).toContain( + `Code: ${errorResponse.data.Code}`, + ); + expect(errorOnCreateOrder.message).toContain( + `Id: ${errorResponse.data.Id}`, + ); + }); + }); + }); +}); diff --git a/services/121-service/src/payments/fsp-integration/nedbank/services/nedbank-api.helper.service.ts b/services/121-service/src/payments/fsp-integration/nedbank/services/nedbank-api.helper.service.ts new file mode 100644 index 0000000000..49431b00dc --- /dev/null +++ b/services/121-service/src/payments/fsp-integration/nedbank/services/nedbank-api.helper.service.ts @@ -0,0 +1,137 @@ +import { Injectable } from '@nestjs/common'; +import { AxiosResponse } from '@nestjs/terminus/dist/health-indicator/http/axios.interfaces'; +import * as https from 'https'; +import { v4 as uuid } from 'uuid'; + +import { ErrorReponseNedbankDto } from '@121-service/src/payments/fsp-integration/nedbank/dtos/nedbank-api/error-reponse-nedbank.dto'; +import { NedbankError } from '@121-service/src/payments/fsp-integration/nedbank/errors/nedbank.error'; +import { + CustomHttpService, + Header, +} from '@121-service/src/shared/services/custom-http.service'; + +@Injectable() +export class NedbankApiHelperService { + public httpsAgent: https.Agent | undefined; + + public constructor(private readonly httpService: CustomHttpService) { + this.httpsAgent = this.createHttpsAgent(); + } + + public async makeApiRequestOrThrow({ + url, + method, + payload, + }: { + url: string; + method: 'POST' | 'GET'; + payload?: unknown; + }): Promise> { + if (!this.httpsAgent) { + throw new NedbankError( + 'Nedbank certificate has not been read. It could be that NEDBANK_CERTIFICATE_PATH or NEDBANK_CERTIFICATE_PASSWORD are not set or that certificate has not been uploaded to the server. Please contact 121 support', + ); + } + const headers = this.createHeaders(); + + let response: AxiosResponse; + try { + response = await this.httpService.request>({ + method, + url, + payload, + headers, + httpsAgent: this.httpsAgent, + }); + } catch (error) { + throw new NedbankError(`Error: ${error.message}`); + } + if (this.isNedbankErrorResponse(response.data)) { + const errorMessage = this.createErrorMessageIfApplicable(response.data); + throw new NedbankError(errorMessage, response.data.Code); + } + return response; + } + + private createHttpsAgent(): https.Agent | undefined { + if (this.httpsAgent) { + return this.httpsAgent; + } + // We only check here if the NEDBANK_CERTIFICATE_PATH is set and if the NEDBANK_CERTIFICATE_PASSWORD is set + // Locally we use .pfx file which is password protected + // On azure we use .pf12 file which is not password protected + if (!process.env.NEDBANK_CERTIFICATE_PATH) { + return; + } + return this.httpService.createHttpsAgentWithCertificate( + process.env.NEDBANK_CERTIFICATE_PATH!, + process.env.NEDBANK_CERTIFICATE_PASSWORD!, + ); + } + + private createHeaders(): Header[] { + return [ + { name: 'x-ibm-client-id', value: process.env.NEDBANK_CLIENT_ID! }, + { + name: 'x-ibm-client-secret', + value: process.env.NEDBANK_CLIENT_SECRET!, + }, + { + name: 'x-idempotency-key', // We use OrderCreateReference as 'idempotency' key and therefore set this thing with a random value + value: Math.floor(Math.random() * 10000).toString(), + }, + { + name: 'x-jws-signature', + value: Math.floor(Math.random() * 10000).toString(), // Should be a random integer https://apim.nedbank.co.za/static/docs/cashout-create-order + }, + { name: 'x-fapi-financial-id', value: 'OB/2017/001' }, // Should always be this value https://apim.nedbank.co.za/static/docs/cashout-create-order + { name: 'x-fapi-customer-ip-address', value: '0.0.0.0' }, // Should be a valid ip address, it does not seem to matter which one. For now we use a 0.0.0.0 to save us the trouble of setting an env for every server + { name: 'x-fapi-interaction-id', value: uuid() }, // Should be a UUID https://apim.nedbank.co.za/static/docs/cashout-create-order + { name: 'Content-Type', value: 'application/json' }, + ]; + } + + private isNedbankErrorResponse( + response: unknown | ErrorReponseNedbankDto, + ): response is ErrorReponseNedbankDto { + return (response as ErrorReponseNedbankDto).Errors !== undefined; + } + + private createErrorMessageIfApplicable( + responseBody: ErrorReponseNedbankDto | null, + ): string { + if (!responseBody) { + return 'Nebank URL could not be reached'; + } + let errorMessage = ''; + + if (responseBody.Errors && responseBody.Errors.length > 0) { + const errorMessages = responseBody.Errors.map( + (error) => error.Message, + ).filter(Boolean); + errorMessage = `Errors: ${errorMessages.join('; ')}`; + } + + const additionalInfo: string[] = []; + if (responseBody.Message) { + additionalInfo.push(`Message: ${responseBody.Message}`); + } + + if (responseBody.Code) { + additionalInfo.push(`Code: ${responseBody.Code}`); + } + if (responseBody.Id) { + additionalInfo.push(`Id: ${responseBody.Id}`); + } + + if (additionalInfo.length > 0) { + errorMessage += ` (${additionalInfo.join(', ')})`; + } + + if (errorMessage === '') { + errorMessage = 'Unknown error'; + } + + return errorMessage; + } +} diff --git a/services/121-service/src/payments/fsp-integration/nedbank/services/nedbank-api.service.ts b/services/121-service/src/payments/fsp-integration/nedbank/services/nedbank-api.service.ts new file mode 100644 index 0000000000..336a588075 --- /dev/null +++ b/services/121-service/src/payments/fsp-integration/nedbank/services/nedbank-api.service.ts @@ -0,0 +1,114 @@ +import { Injectable } from '@nestjs/common'; +import { AxiosResponse } from '@nestjs/terminus/dist/health-indicator/http/axios.interfaces'; +import { v4 as uuid } from 'uuid'; + +import { NedbankCreateOrderRequestBodyDto } from '@121-service/src/payments/fsp-integration/nedbank/dtos/nedbank-api/create-order-request-body-nedbank.dto'; +import { CreateOrderResponseNedbankDto } from '@121-service/src/payments/fsp-integration/nedbank/dtos/nedbank-api/create-order-response-nedbank.dto'; +import { GetOrderResponseNedbankDto } from '@121-service/src/payments/fsp-integration/nedbank/dtos/nedbank-api/get-order-reponse-nedbank.dto'; +import { NedbankVoucherStatus } from '@121-service/src/payments/fsp-integration/nedbank/enums/nedbank-voucher-status.enum'; +import { NedbankApiHelperService } from '@121-service/src/payments/fsp-integration/nedbank/services/nedbank-api.helper.service'; + +//##TODO Discuss: I decided to not have unit tests for this service because all the functions are just calling the helper service +// There are no if's or else decisions in this service +// I could test the happy path but I think this is covered enough with integration tests and static typing of typescript + +@Injectable() +export class NedbankApiService { + public constructor( + private readonly nedbankApiHelperService: NedbankApiHelperService, + ) {} + + public async createOrder({ + transferAmount, + phoneNumber, + orderCreateReference, + }: { + transferAmount: number; + phoneNumber: string; + orderCreateReference: string; + }): Promise { + const payload = this.createOrderPayload({ + transferAmount, + phoneNumber, + orderCreateReference, + }); + + const createOrderResponse = await this.makeCreateOrderCall(payload); + return createOrderResponse.data.Data.Status; + } + + private createOrderPayload({ + transferAmount, + phoneNumber, + orderCreateReference, + }: { + transferAmount: number; + phoneNumber: string; + orderCreateReference: string; + }): NedbankCreateOrderRequestBodyDto { + const currentDate = new Date(); + const expirationDateIsoString = new Date( + currentDate.setDate(new Date().getDate() + 7), + ).toISOString(); + + return { + Data: { + Initiation: { + InstructionIdentification: uuid().replace(/-/g, ''), // This should be a unique string without dashes or you get an error from nedbank + InstructedAmount: { + Amount: `${transferAmount.toString()}.00`, // This should be a string with two decimal places + Currency: 'ZAR', + }, + DebtorAccount: { + SchemeName: 'account', // should always be 'account' + Identification: process.env.NEDBANK_ACCOUNT_NUMBER!, // ##TODO should we check somewhere if the .env is set? + Name: 'MyRefOnceOffQATrx', // ##TODO Not sure what to set here. Quote from the API word document from didirik: 'This is what shows on the SARCS statement. We can set this value for (manual) reconciliation purposes.' + }, + CreditorAccount: { + SchemeName: 'recipient', + Identification: phoneNumber, + Name: 'MyRefOnceOffQATrx', // Name cannot be left empty so set it to a default value found on nedbank api documentation + }, + }, + ExpirationDateTime: expirationDateIsoString, + }, + Risk: { + OrderCreateReference: orderCreateReference, + OrderDateTime: new Date().toISOString().split('T')[0], // This needs to be set to yyyy-mm-dd + }, + }; + } + + private async makeCreateOrderCall( + payload: NedbankCreateOrderRequestBodyDto, + ): Promise> { + const createOrderUrl = !!process.env.MOCK_NEDBANK + ? `${process.env.MOCK_SERVICE_URL}api/fsp/nedbank/v1/orders` + : `${process.env.NEDBANK_API_URL}/v1/orders`; + + return this.nedbankApiHelperService.makeApiRequestOrThrow( + { + url: createOrderUrl, + method: 'POST', + payload, + }, + ); + } + + public async getOrderByOrderCreateReference( + orderCreateReference: string, + ): Promise { + const getOrderUrl = !!process.env.MOCK_NEDBANK + ? `${process.env.MOCK_SERVICE_URL}api/fsp/nedbank/v1/orders/references/${orderCreateReference}` + : `${process.env.NEDBANK_API_URL}/v1/orders/references/${orderCreateReference}`; + + const response = + await this.nedbankApiHelperService.makeApiRequestOrThrow( + { + url: getOrderUrl, + method: 'GET', + }, + ); + return response.data.Data.Transactions.Voucher.Status; + } +} diff --git a/services/121-service/src/payments/payments.module.ts b/services/121-service/src/payments/payments.module.ts index b9aa040da4..893138ff4c 100644 --- a/services/121-service/src/payments/payments.module.ts +++ b/services/121-service/src/payments/payments.module.ts @@ -9,6 +9,7 @@ import { CommercialBankEthiopiaModule } from '@121-service/src/payments/fsp-inte import { ExcelModule } from '@121-service/src/payments/fsp-integration/excel/excel.module'; import { IntersolveVisaModule } from '@121-service/src/payments/fsp-integration/intersolve-visa/intersolve-visa.module'; import { IntersolveVoucherModule } from '@121-service/src/payments/fsp-integration/intersolve-voucher/intersolve-voucher.module'; +import { NedbankModule } from '@121-service/src/payments/fsp-integration/nedbank/nedbank.module'; import { SafaricomModule } from '@121-service/src/payments/fsp-integration/safaricom/safaricom.module'; import { PaymentsController } from '@121-service/src/payments/payments.controller'; import { PaymentsService } from '@121-service/src/payments/payments.service'; @@ -47,6 +48,7 @@ import { createScopedRepositoryProvider } from '@121-service/src/utils/scope/cre IntersolveVisaModule, TransactionsModule, SafaricomModule, + NedbankModule, ExcelModule, CommercialBankEthiopiaModule, RegistrationsModule, diff --git a/services/121-service/src/payments/payments.service.ts b/services/121-service/src/payments/payments.service.ts index e64c7c5fe6..d61417fc7c 100644 --- a/services/121-service/src/payments/payments.service.ts +++ b/services/121-service/src/payments/payments.service.ts @@ -29,6 +29,7 @@ import { ExcelService } from '@121-service/src/payments/fsp-integration/excel/ex import { FinancialServiceProviderIntegrationInterface } from '@121-service/src/payments/fsp-integration/fsp-integration.interface'; import { IntersolveVisaService } from '@121-service/src/payments/fsp-integration/intersolve-visa/intersolve-visa.service'; import { IntersolveVoucherService } from '@121-service/src/payments/fsp-integration/intersolve-voucher/intersolve-voucher.service'; +import { NedbankService } from '@121-service/src/payments/fsp-integration/nedbank/nedbank.service'; import { SafaricomService } from '@121-service/src/payments/fsp-integration/safaricom/safaricom.service'; import { ReferenceIdAndTransactionAmountInterface } from '@121-service/src/payments/interfaces/referenceid-transaction-amount.interface'; import { @@ -64,6 +65,7 @@ import { RegistrationsPaginationService } from '@121-service/src/registration/se import { ScopedQueryBuilder } from '@121-service/src/scoped.repository'; import { AzureLogService } from '@121-service/src/shared/services/azure-log.service'; import { IntersolveVisaTransactionJobDto } from '@121-service/src/transaction-queues/dto/intersolve-visa-transaction-job.dto'; +import { NedbankTransactionJobDto } from '@121-service/src/transaction-queues/dto/nedbank-transaction-job.dto'; import { SafaricomTransactionJobDto } from '@121-service/src/transaction-queues/dto/safaricom-transaction-job.dto'; import { TransactionQueuesService } from '@121-service/src/transaction-queues/transaction-queues.service'; import { splitArrayIntoChunks } from '@121-service/src/utils/chunk.helper'; @@ -91,6 +93,7 @@ export class PaymentsService { private readonly safaricomService: SafaricomService, private readonly commercialBankEthiopiaService: CommercialBankEthiopiaService, private readonly excelService: ExcelService, + private readonly nedbankService: NedbankService, private readonly registrationsBulkService: RegistrationsBulkService, private readonly registrationsPaginationService: RegistrationsPaginationService, private readonly dataSource: DataSource, @@ -119,6 +122,7 @@ export class PaymentsService { [FinancialServiceProviders.deprecatedJumbo]: [ {} as FinancialServiceProviderIntegrationInterface, ], + [FinancialServiceProviders.nedbank]: [this.nedbankService], }; } @@ -683,6 +687,23 @@ export class PaymentsService { }); } + if (fsp === FinancialServiceProviders.nedbank) { + return await this.createAndAddNedbankTransactionJobs({ + referenceIdsAndTransactionAmounts: paPaymentList.map( + (paPaymentData) => { + return { + referenceId: paPaymentData.referenceId, + transactionAmount: paPaymentData.transactionAmount, + }; + }, + ), + userId: paPaymentList[0].userId, + programId, + paymentNumber: payment, + isRetry, + }); + } + const [paymentService, useWhatsapp] = this.financialServiceProviderNameToServiceMap[fsp]; return await paymentService.sendPayment( @@ -732,7 +753,6 @@ export class PaymentsService { (q) => q.name, ); const dataFieldNames = [ - FinancialServiceProviderAttributes.fullName, FinancialServiceProviderAttributes.phoneNumber, ...intersolveVisaAttributeNames, ]; @@ -861,6 +881,69 @@ export class PaymentsService { ); } + /** + * Creates and adds Nedbank transaction jobs. + * + * This method is responsible for creating transaction jobs for Nedbank. It fetches necessary PA data and maps it to a FSP specific DTO. + * It then adds these jobs to the transaction queue. + * + * @returns {Promise} A promise that resolves when the transaction jobs have been created and added. + * + */ + private async createAndAddNedbankTransactionJobs({ + referenceIdsAndTransactionAmounts: referenceIdsTransactionAmounts, + programId, + userId, + paymentNumber, + isRetry, + }: { + referenceIdsAndTransactionAmounts: ReferenceIdAndTransactionAmountInterface[]; + programId: number; + userId: number; + paymentNumber: number; + isRetry: boolean; + }): Promise { + const nedbankAttributes = getFinancialServiceProviderSettingByNameOrThrow( + FinancialServiceProviders.nedbank, + ).attributes; + const nedbankAttributeNames = nedbankAttributes.map((q) => q.name); + const registrationViews = await this.getRegistrationViews( + referenceIdsTransactionAmounts, + nedbankAttributeNames, + programId, + ); + + // Convert the array into a map for increased performace (hashmap lookup) + const transactionAmountsMap = new Map( + referenceIdsTransactionAmounts.map((item) => [ + item.referenceId, + item.transactionAmount, + ]), + ); + + const nedbankTransferJobs: NedbankTransactionJobDto[] = + registrationViews.map((registrationView): NedbankTransactionJobDto => { + return { + programId, + paymentNumber, + referenceId: registrationView.referenceId, + programFinancialServiceProviderConfigurationId: + registrationView.programFinancialServiceProviderConfigurationId, + transactionAmount: transactionAmountsMap.get( + registrationView.referenceId, + )!, + isRetry, + userId, + bulkSize: referenceIdsTransactionAmounts.length, + phoneNumber: + registrationView[FinancialServiceProviderAttributes.phoneNumber]!, + }; + }); + await this.transactionQueuesService.addNedbankTransactionJobs( + nedbankTransferJobs, + ); + } + private async getRegistrationViews( referenceIdsTransactionAmounts: ReferenceIdAndTransactionAmountInterface[], dataFieldNames: string[], diff --git a/services/121-service/src/payments/transactions/transaction.repository.ts b/services/121-service/src/payments/transactions/transaction.repository.ts index 451f6874a5..483c536d54 100644 --- a/services/121-service/src/payments/transactions/transaction.repository.ts +++ b/services/121-service/src/payments/transactions/transaction.repository.ts @@ -1,7 +1,7 @@ import { Inject } from '@nestjs/common'; import { REQUEST } from '@nestjs/core'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { Equal, Repository } from 'typeorm'; import { GetAuditedTransactionDto } from '@121-service/src/payments/transactions/dto/get-audited-transaction.dto'; import { TransactionStatusEnum } from '@121-service/src/payments/transactions/enums/transaction-status.enum'; @@ -107,4 +107,20 @@ export class TransactionScopedRepository extends ScopedRepository 5 is a conservative limit, we can increase this later if needed + duration: 1000, // per duration in milliseconds + }, + }), BullModule.registerQueue({ name: TransactionJobQueueNames.safaricom, processors: [ diff --git a/services/121-service/src/queues-registry/queues-registry.service.ts b/services/121-service/src/queues-registry/queues-registry.service.ts index 12e603529a..dc0b71dfbf 100644 --- a/services/121-service/src/queues-registry/queues-registry.service.ts +++ b/services/121-service/src/queues-registry/queues-registry.service.ts @@ -26,6 +26,8 @@ export class QueuesRegistryService implements OnModuleInit { public transactionJobCommercialBankEthiopiaQueue: Queue, @InjectQueue(TransactionJobQueueNames.safaricom) public transactionJobSafaricomQueue: Queue, + @InjectQueue(TransactionJobQueueNames.nedbank) + public transactionJobNedbankQueue: Queue, @InjectQueue(SafaricomCallbackQueueNames.transfer) public safaricomTransferCallbackQueue: Queue, diff --git a/services/121-service/src/reconciliation/nedbank-reconciliation/nedbank-reconciliation.controller.ts b/services/121-service/src/reconciliation/nedbank-reconciliation/nedbank-reconciliation.controller.ts new file mode 100644 index 0000000000..0d950b175b --- /dev/null +++ b/services/121-service/src/reconciliation/nedbank-reconciliation/nedbank-reconciliation.controller.ts @@ -0,0 +1,35 @@ +import { Controller, HttpStatus, Patch } from '@nestjs/common'; +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; + +import { AuthenticatedUser } from '@121-service/src/guards/authenticated-user.decorator'; +import { NedbankReconciliationService } from '@121-service/src/reconciliation/nedbank-reconciliation/nedbank-reconciliation.service'; + +@Controller() +export class NedbankReconciliationController { + public constructor( + private nedbankReconciliationService: NedbankReconciliationService, + ) {} + @ApiTags('financial-service-providers/nedbank') + @AuthenticatedUser({ isAdmin: true }) + @ApiOperation({ + summary: + '[CRON] Retrieve and update Nedbank vouchers and update transaction statuses', + }) + @ApiResponse({ + status: HttpStatus.NO_CONTENT, + description: 'Nedbank vouchers and transaction update process started', + }) + @Patch('financial-service-providers/nedbank') + public async doNedbankReconciliation(): Promise { + console.info('Started: Nedbank Reconciliation'); + this.nedbankReconciliationService + .doNedbankReconciliation() + .then(() => { + console.info('Complete: Nedbank Reconciliation'); + return; + }) + .catch((error) => { + throw new Error(`Failed: Nedbank Reconciliation - ${error}`); + }); + } +} diff --git a/services/121-service/src/reconciliation/nedbank-reconciliation/nedbank-reconciliation.module.ts b/services/121-service/src/reconciliation/nedbank-reconciliation/nedbank-reconciliation.module.ts new file mode 100644 index 0000000000..d87732d3cd --- /dev/null +++ b/services/121-service/src/reconciliation/nedbank-reconciliation/nedbank-reconciliation.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; + +import { NedbankModule } from '@121-service/src/payments/fsp-integration/nedbank/nedbank.module'; +import { TransactionsModule } from '@121-service/src/payments/transactions/transactions.module'; +import { NedbankReconciliationController } from '@121-service/src/reconciliation/nedbank-reconciliation/nedbank-reconciliation.controller'; +import { NedbankReconciliationService } from '@121-service/src/reconciliation/nedbank-reconciliation/nedbank-reconciliation.service'; + +@Module({ + imports: [NedbankModule, TransactionsModule], + providers: [NedbankReconciliationService], + controllers: [NedbankReconciliationController], +}) +export class NedbankReconciliationModule {} diff --git a/services/121-service/src/reconciliation/nedbank-reconciliation/nedbank-reconciliation.service.ts b/services/121-service/src/reconciliation/nedbank-reconciliation/nedbank-reconciliation.service.ts new file mode 100644 index 0000000000..092da5c4a1 --- /dev/null +++ b/services/121-service/src/reconciliation/nedbank-reconciliation/nedbank-reconciliation.service.ts @@ -0,0 +1,77 @@ +import { Injectable } from '@nestjs/common'; +import { In, IsNull, Not } from 'typeorm'; + +import { NedbankVoucherStatus } from '@121-service/src/payments/fsp-integration/nedbank/enums/nedbank-voucher-status.enum'; +import { NedbankService } from '@121-service/src/payments/fsp-integration/nedbank/nedbank.service'; +import { NedbankVoucherScopedRepository } from '@121-service/src/payments/fsp-integration/nedbank/repositories/nedbank-voucher.scoped.repository'; +import { TransactionStatusEnum } from '@121-service/src/payments/transactions/enums/transaction-status.enum'; +import { TransactionScopedRepository } from '@121-service/src/payments/transactions/transaction.repository'; + +@Injectable() +export class NedbankReconciliationService { + public constructor( + private readonly nedbankService: NedbankService, + private readonly nedbankVoucherScopedRepository: NedbankVoucherScopedRepository, + private readonly transactionScopedRepository: TransactionScopedRepository, + ) {} + + public async doNedbankReconciliation(): Promise { + const vouchers = await this.nedbankVoucherScopedRepository.find({ + select: ['id', 'orderCreateReference', 'transactionId'], + where: [ + { status: IsNull() }, + { + status: Not( + In([ + NedbankVoucherStatus.REDEEMED, + NedbankVoucherStatus.REFUNDED, + NedbankVoucherStatus.FAILED, + ]), + ), + }, + ], + }); + + for (const voucher of vouchers) { + const voucherStatus = + await this.nedbankService.retrieveAndUpdateVoucherStatus( + voucher.orderCreateReference, + ); + + switch (voucherStatus) { + case NedbankVoucherStatus.REDEEMED: + await this.transactionScopedRepository.update( + { id: voucher.transactionId }, + { status: TransactionStatusEnum.success }, + ); + break; + + case NedbankVoucherStatus.REFUNDED: + await this.transactionScopedRepository.update( + { id: voucher.transactionId }, + { + status: TransactionStatusEnum.error, + errorMessage: + 'Voucher has been refunded by Nedbank. If you retry this transfer, the person will receive a new voucher.', + }, + ); + break; + + case NedbankVoucherStatus.FAILED: + await this.transactionScopedRepository.update( + { id: voucher.transactionId }, + { + status: TransactionStatusEnum.error, + errorMessage: + 'Nedbank voucher was not found, something went wrong when creating the voucher. Please retry the transfer.', + }, + ); + break; + + default: + // Do nothing if another voucher status is returned + break; + } + } + } +} diff --git a/services/121-service/src/scripts/enum/seed-script.enum.ts b/services/121-service/src/scripts/enum/seed-script.enum.ts index eb6e22c503..b1120655b0 100644 --- a/services/121-service/src/scripts/enum/seed-script.enum.ts +++ b/services/121-service/src/scripts/enum/seed-script.enum.ts @@ -6,5 +6,5 @@ export enum SeedScript { cbeProgram = 'cbe-program', safaricomProgram = 'safari-program', // excelProgram = 'excel-program', - // nedbankProgram = 'nedbank-program', + nedbankProgram = 'nedbank-program', } diff --git a/services/121-service/src/scripts/seed-configuration.const.ts b/services/121-service/src/scripts/seed-configuration.const.ts index ac5cf55e51..13bb888326 100644 --- a/services/121-service/src/scripts/seed-configuration.const.ts +++ b/services/121-service/src/scripts/seed-configuration.const.ts @@ -55,6 +55,16 @@ export const SEED_CONFIGURATION_SETTINGS: SeedConfigurationDto[] = [ }, ], }, + { + name: SeedScript.nedbankProgram, + organization: 'organization-generic.json', + programs: [ + { + program: 'program-nedbank.json', + messageTemplate: 'message-template-generic.json', + }, + ], + }, { name: SeedScript.testMultiple, organization: 'organization-generic.json', diff --git a/services/121-service/src/seed-data/organization/organization-sarc.json b/services/121-service/src/seed-data/organization/organization-sarc.json new file mode 100644 index 0000000000..a9f27ba684 --- /dev/null +++ b/services/121-service/src/seed-data/organization/organization-sarc.json @@ -0,0 +1,6 @@ +{ + "name": "SARCS", + "displayName": { + "en": "SARCS" + } +} diff --git a/services/121-service/src/seed-data/program/program-nedbank.json b/services/121-service/src/seed-data/program/program-nedbank.json new file mode 100644 index 0000000000..3da0fbf9ca --- /dev/null +++ b/services/121-service/src/seed-data/program/program-nedbank.json @@ -0,0 +1,65 @@ +{ + "published": true, + "validation": true, + "location": "South Africa", + "ngo": "SARCS", + "titlePortal": { + "en": "South Africa - Nedbank" + }, + "description": { + "en": "" + }, + "startDate": "2021-01-11T12:00:00Z", + "endDate": "2022-12-31T12:00:00Z", + "currency": "ZAR", + "distributionFrequency": "week", + "distributionDuration": 90, + "fixedTransferValue": 100, + "targetNrRegistrations": 250, + "tryWhatsAppFirst": true, + "programRegistrationAttributes": [ + { + "name": "fullName", + "label": { + "en": "Full Name" + }, + "type": "text", + "pattern": null, + "options": null, + "export": ["all-people-affected", "included"], + "scoring": {}, + "editableInPortal": true, + "showInPeopleAffectedTable": false + }, + { + "name": "phoneNumber", + "label": { + "en": "Phone Number" + }, + "placeholder": { + "en": "+31 6 00 00 00 00" + }, + "type": "tel", + "pattern": null, + "options": null, + "export": [], + "scoring": {}, + "duplicateCheck": true, + "editableInPortal": false, + "showInPeopleAffectedTable": false + } + ], + "aboutProgram": { + "en": "Program to test Nedbank with" + }, + "fullnameNamingConvention": ["fullName"], + "languages": ["en", "nl"], + "enableMaxPayments": true, + "enableScope": true, + "allowEmptyPhoneNumber": true, + "programFinancialServiceProviderConfigurations": [ + { + "financialServiceProvider": "Nedbank" + } + ] +} diff --git a/services/121-service/src/shared/services/custom-http.service.spec.ts b/services/121-service/src/shared/services/custom-http.service.spec.ts new file mode 100644 index 0000000000..f7985e34d9 --- /dev/null +++ b/services/121-service/src/shared/services/custom-http.service.spec.ts @@ -0,0 +1,48 @@ +import * as fs from 'fs'; +import * as https from 'https'; + +import { CustomHttpService } from '@121-service/src/shared/services/custom-http.service'; + +jest.mock('fs'); + +// ##TODO: Are these tests useful? + +describe('CustomHttpService', () => { + let service: CustomHttpService; + + beforeEach(() => { + service = new CustomHttpService({} as any); + }); + + describe('createHttpsAgentWithCertificate', () => { + it('should create an HTTPS agent with a certificate and passphrase', () => { + const certificatePath = 'path/to/certificate.p12'; + const password = 'test-password'; + const dummyCertificate = Buffer.from('dummy-certificate'); + + (fs.readFileSync as jest.Mock).mockReturnValue(dummyCertificate); + + const agent = service.createHttpsAgentWithCertificate( + certificatePath, + password, + ); + + expect(agent).toBeInstanceOf(https.Agent); + expect(agent.options.pfx).toBe(dummyCertificate); + expect(agent.options.passphrase).toBe(password); + }); + + it('should create an HTTPS agent with a certificate without passphrase', () => { + const certificatePath = 'path/to/certificate.p12'; + const dummyCertificate = Buffer.from('dummy-certificate'); + + (fs.readFileSync as jest.Mock).mockReturnValue(dummyCertificate); + + const agent = service.createHttpsAgentWithCertificate(certificatePath); + + expect(agent).toBeInstanceOf(https.Agent); + expect(agent.options.pfx).toBe(dummyCertificate); + expect(agent.options.passphrase).toBeUndefined(); + }); + }); +}); diff --git a/services/121-service/src/shared/services/custom-http.service.ts b/services/121-service/src/shared/services/custom-http.service.ts index 8d3a03e3ab..469494b5bf 100644 --- a/services/121-service/src/shared/services/custom-http.service.ts +++ b/services/121-service/src/shared/services/custom-http.service.ts @@ -1,9 +1,13 @@ import { HttpService } from '@nestjs/axios'; import { Injectable } from '@nestjs/common'; +import { AxiosRequestConfig } from '@nestjs/terminus/dist/health-indicator/http/axios.interfaces'; import { defaultClient, TelemetryClient } from 'applicationinsights'; +import fs from 'fs'; +import * as https from 'https'; import { isPlainObject } from 'lodash'; import { catchError, lastValueFrom, map, of } from 'rxjs'; +import { DEBUG } from '@121-service/src/config'; import { CookieNames } from '@121-service/src/shared/enum/cookie.enums'; import { maskValueKeepStart } from '@121-service/src/utils/mask-value.helper'; @@ -132,20 +136,28 @@ export class CustomHttpService { url, payload, headers, + httpsAgent, }: { method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; url: string; payload?: unknown; headers?: Header[]; + httpsAgent?: https.Agent; }): Promise { + const params: AxiosRequestConfig = { + method, + url, + headers: this.createHeaders(headers), + }; + if (payload) { + params.data = payload; // If payload is null on a GET, axios will throw an error + } + if (httpsAgent) { + params.httpsAgent = httpsAgent; + } return await lastValueFrom( this.httpService - .request({ - method, - url, - data: payload, - headers: this.createHeaders(headers), - }) + .request(params) // .pipe( map((response) => { this.logMessageRequest({ headers, url, payload }, response); @@ -216,6 +228,9 @@ export class CustomHttpService { request: Partial, error: Partial, ): void { + if (DEBUG) { + console.log(error.data); + } if (this.defaultClient) { try { const requestPayload = this.stringify( @@ -244,6 +259,22 @@ export class CustomHttpService { } } + /** + * Create an HTTPS agent with a certificate. + * @param certificatePath The path to the certificate. + * @param password The passphrase for the certificate. + * @returns The HTTPS agent. + */ + public createHttpsAgentWithCertificate( + certificatePath: string, + password?: string, + ): https.Agent { + return new https.Agent({ + pfx: fs.readFileSync(certificatePath), + passphrase: password, + }); + } + private flushLogs(methodName: string): void { try { this.defaultClient.flush(); diff --git a/services/121-service/src/transaction-job-processors/processors/transaction-job-nedbank.processor.spec.ts b/services/121-service/src/transaction-job-processors/processors/transaction-job-nedbank.processor.spec.ts new file mode 100644 index 0000000000..c4d74a7453 --- /dev/null +++ b/services/121-service/src/transaction-job-processors/processors/transaction-job-nedbank.processor.spec.ts @@ -0,0 +1,83 @@ +import { TestBed } from '@automock/jest'; +import { Job } from 'bull'; +import Redis from 'ioredis'; + +import { REDIS_CLIENT } from '@121-service/src/payments/redis/redis-client'; +import { TransactionJobProcessorNedbank } from '@121-service/src/transaction-job-processors/processors/transaction-job-nedbank.processor'; +import { TransactionJobProcessorsService } from '@121-service/src/transaction-job-processors/transaction-job-processors.service'; +import { NedbankTransactionJobDto } from '@121-service/src/transaction-queues/dto/nedbank-transaction-job.dto'; +import { registrationNedbank } from '@121-service/test/registrations/pagination/pagination-data'; + +const mockPaymentJob: NedbankTransactionJobDto = { + programId: 3, + userId: 1, + paymentNumber: 3, + referenceId: '40bde7dc-29a9-4af0-81ca-1c426dccdd29', + transactionAmount: 25, + isRetry: false, + bulkSize: 10, + phoneNumber: registrationNedbank.phoneNumber, + programFinancialServiceProviderConfigurationId: 1, +}; +const testJob = { data: mockPaymentJob } as Job; + +describe('TransactionJobProcessorNedbank', () => { + let transactionJobProcessorsService: jest.Mocked; + let processor: TransactionJobProcessorNedbank; + let redisClient: jest.Mocked; + + beforeEach(() => { + jest.clearAllMocks(); // To esnure the call count is not influenced by other tests + + const { unit, unitRef } = TestBed.create(TransactionJobProcessorNedbank) + .mock(TransactionJobProcessorsService) + .using(transactionJobProcessorsService) + .mock(REDIS_CLIENT) + .using(redisClient) + .compile(); + + processor = unit; + transactionJobProcessorsService = unitRef.get( + TransactionJobProcessorsService, + ); + redisClient = unitRef.get(REDIS_CLIENT); + }); + + it('should call processNedbankTransactionJob and remove job from Redis set', async () => { + // Arrange + transactionJobProcessorsService.processNedbankTransactionJob.mockResolvedValue(); + + // Act + await processor.handleNedbankTransactionJob(testJob); + + // Assert + expect( + transactionJobProcessorsService.processNedbankTransactionJob, + ).toHaveBeenCalledTimes(1); + expect( + transactionJobProcessorsService.processNedbankTransactionJob, + ).toHaveBeenCalledWith(mockPaymentJob); + + expect(redisClient.srem).toHaveBeenCalledTimes(1); + }); + + it('should handle errors and still remove job from Redis set', async () => { + // Arrange + const error = new Error('Test error'); + transactionJobProcessorsService.processNedbankTransactionJob.mockRejectedValue( + error, + ); + + // Act & Assert + await expect( + processor.handleNedbankTransactionJob(testJob), + ).rejects.toThrow(error); + expect( + transactionJobProcessorsService.processNedbankTransactionJob, + ).toHaveBeenCalledTimes(1); + expect( + transactionJobProcessorsService.processNedbankTransactionJob, + ).toHaveBeenCalledWith(mockPaymentJob); + expect(redisClient.srem).toHaveBeenCalledTimes(1); + }); +}); diff --git a/services/121-service/src/transaction-job-processors/processors/transaction-job-nedbank.processor.ts b/services/121-service/src/transaction-job-processors/processors/transaction-job-nedbank.processor.ts new file mode 100644 index 0000000000..bb08d01667 --- /dev/null +++ b/services/121-service/src/transaction-job-processors/processors/transaction-job-nedbank.processor.ts @@ -0,0 +1,35 @@ +import { Process, Processor } from '@nestjs/bull'; +import { Inject } from '@nestjs/common'; +import { Job } from 'bull'; +import Redis from 'ioredis'; + +import { + getRedisSetName, + REDIS_CLIENT, +} from '@121-service/src/payments/redis/redis-client'; +import { TransactionJobQueueNames } from '@121-service/src/queues-registry/enum/transaction-job-queue-names.enum'; +import { JobNames } from '@121-service/src/shared/enum/job-names.enum'; +import { TransactionJobProcessorsService } from '@121-service/src/transaction-job-processors/transaction-job-processors.service'; + +@Processor(TransactionJobQueueNames.nedbank) +export class TransactionJobProcessorNedbank { + constructor( + private readonly transactionJobProcessorsService: TransactionJobProcessorsService, + @Inject(REDIS_CLIENT) + private readonly redisClient: Redis, + ) {} + + @Process(JobNames.default) + async handleNedbankTransactionJob(job: Job): Promise { + try { + await this.transactionJobProcessorsService.processNedbankTransactionJob( + job.data, + ); + } catch (error) { + console.log(error); + throw error; + } finally { + await this.redisClient.srem(getRedisSetName(job.data.programId), job.id); + } + } +} diff --git a/services/121-service/src/transaction-job-processors/transaction-job-processors.module.ts b/services/121-service/src/transaction-job-processors/transaction-job-processors.module.ts index 1b617e9c6e..058cef12b3 100644 --- a/services/121-service/src/transaction-job-processors/transaction-job-processors.module.ts +++ b/services/121-service/src/transaction-job-processors/transaction-job-processors.module.ts @@ -5,23 +5,24 @@ import { FinancialServiceProvidersModule } from '@121-service/src/financial-serv import { MessageQueuesModule } from '@121-service/src/notifications/message-queues/message-queues.module'; import { MessageTemplateModule } from '@121-service/src/notifications/message-template/message-template.module'; import { IntersolveVisaModule } from '@121-service/src/payments/fsp-integration/intersolve-visa/intersolve-visa.module'; +import { NedbankModule } from '@121-service/src/payments/fsp-integration/nedbank/nedbank.module'; import { SafaricomModule } from '@121-service/src/payments/fsp-integration/safaricom/safaricom.module'; import { RedisModule } from '@121-service/src/payments/redis/redis.module'; -import { TransactionEntity } from '@121-service/src/payments/transactions/transaction.entity'; import { TransactionsModule } from '@121-service/src/payments/transactions/transactions.module'; import { ProgramFinancialServiceProviderConfigurationsModule } from '@121-service/src/program-financial-service-provider-configurations/program-financial-service-provider-configurations.module'; import { ProgramModule } from '@121-service/src/programs/programs.module'; import { RegistrationsModule } from '@121-service/src/registration/registrations.module'; import { TransactionJobProcessorIntersolveVisa } from '@121-service/src/transaction-job-processors/processors/transaction-job-intersolve-visa.processor'; +import { TransactionJobProcessorNedbank } from '@121-service/src/transaction-job-processors/processors/transaction-job-nedbank.processor'; import { TransactionJobProcessorSafaricom } from '@121-service/src/transaction-job-processors/processors/transaction-job-safaricom.processor'; import { TransactionJobProcessorsService } from '@121-service/src/transaction-job-processors/transaction-job-processors.service'; -import { createScopedRepositoryProvider } from '@121-service/src/utils/scope/createScopedRepositoryProvider.helper'; @Module({ imports: [ RedisModule, IntersolveVisaModule, SafaricomModule, + NedbankModule, ProgramFinancialServiceProviderConfigurationsModule, RegistrationsModule, ProgramModule, @@ -35,7 +36,7 @@ import { createScopedRepositoryProvider } from '@121-service/src/utils/scope/cre TransactionJobProcessorsService, TransactionJobProcessorIntersolveVisa, TransactionJobProcessorSafaricom, - createScopedRepositoryProvider(TransactionEntity), + TransactionJobProcessorNedbank, ], }) export class TransactionJobProcessorsModule {} diff --git a/services/121-service/src/transaction-job-processors/transaction-job-processors.service.spec.ts b/services/121-service/src/transaction-job-processors/transaction-job-processors.service.spec.ts index 7f9d034f22..1db8984051 100644 --- a/services/121-service/src/transaction-job-processors/transaction-job-processors.service.spec.ts +++ b/services/121-service/src/transaction-job-processors/transaction-job-processors.service.spec.ts @@ -1,19 +1,23 @@ import { TestBed } from '@automock/jest'; import { UpdateResult } from 'typeorm'; +import { NedbankVoucherStatus } from '@121-service/src/payments/fsp-integration/nedbank/enums/nedbank-voucher-status.enum'; +import { NedbankError } from '@121-service/src/payments/fsp-integration/nedbank/errors/nedbank.error'; +import { NedbankService } from '@121-service/src/payments/fsp-integration/nedbank/nedbank.service'; +import { NedbankVoucherScopedRepository } from '@121-service/src/payments/fsp-integration/nedbank/repositories/nedbank-voucher.scoped.repository'; import { SafaricomApiError } from '@121-service/src/payments/fsp-integration/safaricom/errors/safaricom-api.error'; import { SafaricomService } from '@121-service/src/payments/fsp-integration/safaricom/safaricom.service'; import { TransactionStatusEnum } from '@121-service/src/payments/transactions/enums/transaction-status.enum'; import { LatestTransactionRepository } from '@121-service/src/payments/transactions/repositories/latest-transaction.repository'; -import { TransactionEntity } from '@121-service/src/payments/transactions/transaction.entity'; +import { TransactionScopedRepository } from '@121-service/src/payments/transactions/transaction.repository'; import { ProgramEntity } from '@121-service/src/programs/program.entity'; import { ProgramRepository } from '@121-service/src/programs/repositories/program.repository'; import { RegistrationEntity } from '@121-service/src/registration/registration.entity'; import { RegistrationScopedRepository } from '@121-service/src/registration/repositories/registration-scoped.repository'; -import { ScopedRepository } from '@121-service/src/scoped.repository'; import { TransactionJobProcessorsService } from '@121-service/src/transaction-job-processors/transaction-job-processors.service'; +import { NedbankTransactionJobDto } from '@121-service/src/transaction-queues/dto/nedbank-transaction-job.dto'; import { SafaricomTransactionJobDto } from '@121-service/src/transaction-queues/dto/safaricom-transaction-job.dto'; -import { getScopedRepositoryProviderName } from '@121-service/src/utils/scope/createScopedRepositoryProvider.helper'; +import { registrationNedbank } from '@121-service/test/registrations/pagination/pagination-data'; const mockedRegistration: RegistrationEntity = { id: 1, @@ -23,14 +27,9 @@ const mockedRegistration: RegistrationEntity = { preferredLanguage: 'en', } as unknown as RegistrationEntity; -const mockedTransaction: TransactionEntity = { - id: 1, - amount: 25, - status: TransactionStatusEnum.waiting, - userId: 1, -} as TransactionEntity; +const mockedTransactionId = 1; -const mockedTransactionJob: SafaricomTransactionJobDto = { +const mockedSafaricomTransactionJob: SafaricomTransactionJobDto = { programId: 3, paymentNumber: 3, referenceId: 'a3d1f489-2718-4430-863f-5abc14523691', @@ -55,12 +54,14 @@ const mockedProgram = { describe('TransactionJobProcessorsService', () => { let safaricomService: SafaricomService; + let nedbankService: NedbankService; let transactionJobProcessorsService: TransactionJobProcessorsService; let programRepository: ProgramRepository; let registrationScopedRepository: RegistrationScopedRepository; let latestTransactionRepository: LatestTransactionRepository; - let transactionScopedRepository: ScopedRepository; + let transactionScopedRepository: TransactionScopedRepository; + let nedbankVoucherScopedRepository: NedbankVoucherScopedRepository; beforeEach(async () => { const { unit, unitRef } = TestBed.create( @@ -70,26 +71,24 @@ describe('TransactionJobProcessorsService', () => { transactionJobProcessorsService = unit; safaricomService = unitRef.get(SafaricomService); + nedbankService = unitRef.get(NedbankService); programRepository = unitRef.get(ProgramRepository); registrationScopedRepository = unitRef.get( RegistrationScopedRepository, ); - - transactionScopedRepository = unitRef.get< - ScopedRepository - >(getScopedRepositoryProviderName(TransactionEntity)); + transactionScopedRepository = unitRef.get( + TransactionScopedRepository, + ); latestTransactionRepository = unitRef.get( LatestTransactionRepository, ); - }); - - it('should be defined', () => { - expect(transactionJobProcessorsService).toBeDefined(); - }); + nedbankVoucherScopedRepository = + unitRef.get( + NedbankVoucherScopedRepository, + ); - it('[Idempotency] safaricom transaction job processing should fail when using same originatorConversationId', async () => { jest .spyOn(registrationScopedRepository, 'getByReferenceId') .mockResolvedValueOnce(mockedRegistration); @@ -104,8 +103,21 @@ describe('TransactionJobProcessorsService', () => { jest .spyOn(transactionScopedRepository, 'save') - .mockResolvedValueOnce([mockedTransaction]); + .mockResolvedValueOnce({ id: mockedTransactionId } as any); + jest + .spyOn( + transactionScopedRepository, + 'getFailedTransactionsCountForPaymentAndRegistration', + ) + .mockResolvedValueOnce(0); + }); + + it('should be defined', () => { + expect(transactionJobProcessorsService).toBeDefined(); + }); + + it('[Idempotency] safaricom transaction job processing should fail when using same originatorConversationId', async () => { jest .spyOn(latestTransactionRepository, 'insertOrUpdateFromTransaction') .mockResolvedValueOnce(); @@ -124,19 +136,121 @@ describe('TransactionJobProcessorsService', () => { // Call the service method await transactionJobProcessorsService.processSafaricomTransactionJob( - mockedTransactionJob, + mockedSafaricomTransactionJob, ); expect(registrationScopedRepository.getByReferenceId).toHaveBeenCalledWith({ - referenceId: mockedTransactionJob.referenceId, + referenceId: mockedSafaricomTransactionJob.referenceId, }); expect(safaricomService.doTransfer).toHaveBeenCalledWith( expect.objectContaining({ - transferAmount: mockedTransactionJob.transactionAmount, - phoneNumber: mockedTransactionJob.phoneNumber, - idNumber: mockedTransactionJob.idNumber, - originatorConversationId: mockedTransactionJob.originatorConversationId, + transferAmount: mockedSafaricomTransactionJob.transactionAmount, + phoneNumber: mockedSafaricomTransactionJob.phoneNumber, + idNumber: mockedSafaricomTransactionJob.idNumber, + originatorConversationId: + mockedSafaricomTransactionJob.originatorConversationId, }), ); }); + + // ##TODO: Discuss if this adds anything beyond the integration tests + describe('Nedbank transaction job processing', () => { + const mockedNedbankTransactionJob: NedbankTransactionJobDto = { + programId: 3, + paymentNumber: 3, + referenceId: registrationNedbank.referenceId, + transactionAmount: 25, + isRetry: false, + userId: 1, + bulkSize: 10, + phoneNumber: registrationNedbank.phoneNumber, + programFinancialServiceProviderConfigurationId: 1, + }; + + const mockedCreateOrderReturn = NedbankVoucherStatus.PENDING; + + it('should process Nedbank transaction job successfully', async () => { + jest + .spyOn(registrationScopedRepository, 'getByReferenceId') + .mockResolvedValueOnce(mockedRegistration); + + jest + .spyOn(nedbankService, 'createVoucher') + .mockResolvedValueOnce(mockedCreateOrderReturn); + + jest + .spyOn(nedbankVoucherScopedRepository, 'storeVoucher') + .mockResolvedValueOnce(undefined); + + await transactionJobProcessorsService.processNedbankTransactionJob( + mockedNedbankTransactionJob, + ); + + expect( + registrationScopedRepository.getByReferenceId, + ).toHaveBeenCalledWith({ + referenceId: mockedNedbankTransactionJob.referenceId, + }); + expect( + transactionScopedRepository.getFailedTransactionsCountForPaymentAndRegistration, + ).toHaveBeenCalledWith({ + registrationId: mockedRegistration.id, + payment: mockedNedbankTransactionJob.paymentNumber, + }); + expect(nedbankService.createVoucher).toHaveBeenCalledWith({ + transferAmount: mockedNedbankTransactionJob.transactionAmount, + phoneNumber: mockedNedbankTransactionJob.phoneNumber, + orderCreateReference: expect.any(String), + }); + + expect(nedbankVoucherScopedRepository.storeVoucher).toHaveBeenCalledWith({ + transactionId: mockedTransactionId, + orderCreateReference: expect.any(String), + }); + expect(transactionScopedRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + payment: mockedNedbankTransactionJob.paymentNumber, + status: TransactionStatusEnum.waiting, + }), + ); + expect(nedbankVoucherScopedRepository.update).toHaveBeenCalledWith( + { orderCreateReference: expect.any(String) }, + { status: mockedCreateOrderReturn }, + ); + }); + + it('should handle NedbankError when creating order', async () => { + jest + .spyOn(registrationScopedRepository, 'getByReferenceId') + .mockResolvedValueOnce(mockedRegistration); + + const errorMessage = 'Nedbank error occurred'; + const nedbankError = new NedbankError(errorMessage); + jest + .spyOn(nedbankService, 'createVoucher') + .mockRejectedValueOnce(nedbankError); + + await transactionJobProcessorsService.processNedbankTransactionJob( + mockedNedbankTransactionJob, + ); + + expect(transactionScopedRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + payment: mockedNedbankTransactionJob.paymentNumber, + status: TransactionStatusEnum.waiting, + }), + ); + expect(transactionScopedRepository.update).toHaveBeenCalledWith( + { id: expect.any(Number) }, + { + status: TransactionStatusEnum.error, + errorMessage: expect.stringContaining(errorMessage), + }, + ); + expect(nedbankVoucherScopedRepository.update).toHaveBeenCalledWith( + { orderCreateReference: expect.any(String) }, + { status: NedbankVoucherStatus.FAILED }, + ); + }); + }); }); diff --git a/services/121-service/src/transaction-job-processors/transaction-job-processors.service.ts b/services/121-service/src/transaction-job-processors/transaction-job-processors.service.ts index 91ce36e8d5..9b9bffe190 100644 --- a/services/121-service/src/transaction-job-processors/transaction-job-processors.service.ts +++ b/services/121-service/src/transaction-job-processors/transaction-job-processors.service.ts @@ -1,6 +1,7 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { Equal } from 'typeorm'; +import { NedbankVoucherStatus } from '@121-service//src/payments/fsp-integration/nedbank/enums/nedbank-voucher-status.enum'; import { EventsService } from '@121-service/src/events/events.service'; import { FinancialServiceProviderConfigurationProperties } from '@121-service/src/financial-service-providers/enum/financial-service-provider-name.enum'; import { MessageContentType } from '@121-service/src/notifications/enum/message-type.enum'; @@ -11,6 +12,9 @@ import { MessageTemplateService } from '@121-service/src/notifications/message-t import { DoTransferOrIssueCardReturnType } from '@121-service/src/payments/fsp-integration/intersolve-visa/interfaces/do-transfer-or-issue-card-return-type.interface'; import { IntersolveVisaService } from '@121-service/src/payments/fsp-integration/intersolve-visa/intersolve-visa.service'; import { IntersolveVisaApiError } from '@121-service/src/payments/fsp-integration/intersolve-visa/intersolve-visa-api.error'; +import { NedbankError } from '@121-service/src/payments/fsp-integration/nedbank/errors/nedbank.error'; +import { NedbankService } from '@121-service/src/payments/fsp-integration/nedbank/nedbank.service'; +import { NedbankVoucherScopedRepository } from '@121-service/src/payments/fsp-integration/nedbank/repositories/nedbank-voucher.scoped.repository'; import { SafaricomTransferEntity } from '@121-service/src/payments/fsp-integration/safaricom/entities/safaricom-transfer.entity'; import { DuplicateOriginatorConversationIdError } from '@121-service/src/payments/fsp-integration/safaricom/errors/duplicate-originator-conversation-id.error'; import { SafaricomApiError } from '@121-service/src/payments/fsp-integration/safaricom/errors/safaricom-api.error'; @@ -19,23 +23,24 @@ import { SafaricomService } from '@121-service/src/payments/fsp-integration/safa import { TransactionStatusEnum } from '@121-service/src/payments/transactions/enums/transaction-status.enum'; import { LatestTransactionRepository } from '@121-service/src/payments/transactions/repositories/latest-transaction.repository'; import { TransactionEntity } from '@121-service/src/payments/transactions/transaction.entity'; +import { TransactionScopedRepository } from '@121-service/src/payments/transactions/transaction.repository'; import { ProgramFinancialServiceProviderConfigurationRepository } from '@121-service/src/program-financial-service-provider-configurations/program-financial-service-provider-configurations.repository'; import { ProgramRepository } from '@121-service/src/programs/repositories/program.repository'; import { RegistrationStatusEnum } from '@121-service/src/registration/enum/registration-status.enum'; import { RegistrationEntity } from '@121-service/src/registration/registration.entity'; import { RegistrationViewEntity } from '@121-service/src/registration/registration-view.entity'; import { RegistrationScopedRepository } from '@121-service/src/registration/repositories/registration-scoped.repository'; -import { ScopedRepository } from '@121-service/src/scoped.repository'; import { LanguageEnum } from '@121-service/src/shared/enum/language.enums'; import { IntersolveVisaTransactionJobDto } from '@121-service/src/transaction-queues/dto/intersolve-visa-transaction-job.dto'; +import { NedbankTransactionJobDto } from '@121-service/src/transaction-queues/dto/nedbank-transaction-job.dto'; import { SafaricomTransactionJobDto } from '@121-service/src/transaction-queues/dto/safaricom-transaction-job.dto'; -import { getScopedRepositoryProviderName } from '@121-service/src/utils/scope/createScopedRepositoryProvider.helper'; +import { generateUUIDFromSeed } from '@121-service/src/utils/uuid.helpers'; interface ProcessTransactionResultInput { programId: number; paymentNumber: number; userId: number; - calculatedTransferAmountInMajorUnit: number; + transferAmountInMajorUnit: number; programFinancialServiceProviderConfigurationId: number; registration: RegistrationEntity; oldRegistration: RegistrationEntity; @@ -49,13 +54,14 @@ export class TransactionJobProcessorsService { public constructor( private readonly intersolveVisaService: IntersolveVisaService, private readonly safaricomService: SafaricomService, + private readonly nedbankService: NedbankService, private readonly messageTemplateService: MessageTemplateService, private readonly programFinancialServiceProviderConfigurationRepository: ProgramFinancialServiceProviderConfigurationRepository, private readonly registrationScopedRepository: RegistrationScopedRepository, private readonly safaricomTransferScopedRepository: SafaricomTransferScopedRepository, + private readonly nedbankVoucherScopedRepository: NedbankVoucherScopedRepository, private readonly queueMessageService: MessageQueuesService, - @Inject(getScopedRepositoryProviderName(TransactionEntity)) - private readonly transactionScopedRepository: ScopedRepository, + private readonly transactionScopedRepository: TransactionScopedRepository, private readonly latestTransactionRepository: LatestTransactionRepository, private readonly programRepository: ProgramRepository, private readonly eventsService: EventsService, @@ -82,8 +88,7 @@ export class TransactionJobProcessorsService { programId: input.programId, paymentNumber: input.paymentNumber, userId: input.userId, - calculatedTransferAmountInMajorUnit: - input.transactionAmountInMajorUnit, // Use the original amount here since we were unable to calculate the transfer amount. The error message is also clear enough so users should not be confused about the potentially high amount. + transferAmountInMajorUnit: input.transactionAmountInMajorUnit, // Use the original amount here since we were unable to calculate the transfer amount. The error message is also clear enough so users should not be confused about the potentially high amount. programFinancialServiceProviderConfigurationId: input.programFinancialServiceProviderConfigurationId, registration, @@ -149,7 +154,7 @@ export class TransactionJobProcessorsService { programId: input.programId, paymentNumber: input.paymentNumber, userId: input.userId, - calculatedTransferAmountInMajorUnit: transferAmountInMajorUnit, + transferAmountInMajorUnit, programFinancialServiceProviderConfigurationId: input.programFinancialServiceProviderConfigurationId, registration, @@ -185,7 +190,7 @@ export class TransactionJobProcessorsService { programId: input.programId, paymentNumber: input.paymentNumber, userId: input.userId, - calculatedTransferAmountInMajorUnit: + transferAmountInMajorUnit: intersolveVisaDoTransferOrIssueCardReturnDto.amountTransferredInMajorUnit, programFinancialServiceProviderConfigurationId: input.programFinancialServiceProviderConfigurationId, @@ -221,7 +226,7 @@ export class TransactionJobProcessorsService { programId: transactionJob.programId, paymentNumber: transactionJob.paymentNumber, userId: transactionJob.userId, - calculatedTransferAmountInMajorUnit: transactionJob.transactionAmount, + transferAmountInMajorUnit: transactionJob.transactionAmount, programFinancialServiceProviderConfigurationId: transactionJob.programFinancialServiceProviderConfigurationId, registration, @@ -271,6 +276,113 @@ export class TransactionJobProcessorsService { // 5. No transaction stored or updated after API-call, because waiting transaction is already stored earlier and will remain 'waiting' at this stage (to be updated via callback) } + public async processNedbankTransactionJob( + transactionJob: NedbankTransactionJobDto, + ): Promise { + // 1. Get the registration to log changes to it later in event table + const registration = await this.getRegistrationOrThrow( + transactionJob.referenceId, + ); + const oldRegistration = structuredClone(registration); + + let orderCreateReference: string; + let transactionId: number; + + // 2. Check if there is an existing voucher/orderCreateReference without status if not create orderCreateReference, the nedbank voucher and the related transaction + // This should almost never happen, only when we have a server crash or when we got a timeout from the nedbank API when creating the order + // but if it does, we should use the same orderCreateReference to avoid creating a new voucher + const voucherWithoutStatus = + await this.nedbankVoucherScopedRepository.getVoucherWithoutStatus({ + paymentId: transactionJob.paymentNumber, + registrationId: registration.id, + }); + + if (voucherWithoutStatus) { + orderCreateReference = voucherWithoutStatus.orderCreateReference; + transactionId = voucherWithoutStatus.transactionId; + } else { + // Create transaction and update registration + // Note: The transaction is created before the voucher is created, so it can be linked to the generated orderCreateReference + // before the create voucher order API call to Nedbank is made + const transaction = await this.createTransactionAndUpdateRegistration({ + programId: transactionJob.programId, + paymentNumber: transactionJob.paymentNumber, + userId: transactionJob.userId, + transferAmountInMajorUnit: transactionJob.transactionAmount, + programFinancialServiceProviderConfigurationId: + transactionJob.programFinancialServiceProviderConfigurationId, + registration, + oldRegistration, + isRetry: transactionJob.isRetry, + status: TransactionStatusEnum.waiting, + }); + transactionId = transaction.id; + + // Get count of failed transactions to create orderCreateReference + const failedTransactionsCount = + await this.transactionScopedRepository.getFailedTransactionsCountForPaymentAndRegistration( + { + registrationId: registration.id, + payment: transactionJob.paymentNumber, + }, + ); + // orderCreateReference is generated using: (referenceId + paymentNr + current failed transactions) + // Using this count to generate the OrderReferenceId ensures that: + // a. On payment retry, a new reference is generated (needed because a new reference is required by nedbank if a failed order was created). + // b. Queue Retry: on queue retry, the same OrderReferenceId is generated, which is beneficial because the old successful/failed Order response would be returned. + orderCreateReference = generateUUIDFromSeed( + `ReferenceId=${transactionJob.referenceId},PaymentNumber=${transactionJob.paymentNumber},Attempt=${failedTransactionsCount}`, + ).replace(/^(.{14})5/, '$14'); + + // THIS IS MOCK FUNCTIONONALITY FOR TESTING PURPOSES ONLY + if ( + process.env.MOCK_NEDBANK && + transactionJob.referenceId.includes('mock') + ) { + // If mock, add the referenceId to the orderCreateReference + // This way you can add one of the nedbank voucher statusses to the orderCreateReference + // to simulate a specific statusses in responses from the nedbank API on getOrderByOrderCreateReference + orderCreateReference = `${transactionJob.referenceId}-${orderCreateReference}`; + } + + await this.nedbankVoucherScopedRepository.storeVoucher({ + orderCreateReference, + transactionId, + }); + } + + // 3. Create the voucher via Nedbank API and update the transaction if an error occurs + // Updating the transaction on succesfull voucher creation is not needed as it is already in the 'waiting' state + // and will be updated to success (or error) via the reconciliation process + let nedbankVoucherStatus: NedbankVoucherStatus; + try { + nedbankVoucherStatus = await this.nedbankService.createVoucher({ + transferAmount: transactionJob.transactionAmount, + phoneNumber: transactionJob.phoneNumber, + orderCreateReference, + }); + } catch (error) { + if (error instanceof NedbankError) { + nedbankVoucherStatus = NedbankVoucherStatus.FAILED; + await this.transactionScopedRepository.update( + { id: transactionId }, + { status: TransactionStatusEnum.error, errorMessage: error?.message }, + ); + // Update the status to failed so we don't try to create the voucher again + // NedbankVoucherStatus.FAILED is introduced to differentiate between + // a) a voucher that failed to be created, while we got a response from nedbbank and b) a voucher of which the status is unknown due to a timout/server crash + } else { + throw error; + } + } + + // 4. Store the status of the nedbank voucher + await this.nedbankVoucherScopedRepository.update( + { orderCreateReference }, + { status: nedbankVoucherStatus }, + ); + } + private async getRegistrationOrThrow( referenceId: string, ): Promise { @@ -290,7 +402,7 @@ export class TransactionJobProcessorsService { programId, paymentNumber, userId, - calculatedTransferAmountInMajorUnit, + transferAmountInMajorUnit: calculatedTransferAmountInMajorUnit, programFinancialServiceProviderConfigurationId, registration, oldRegistration, diff --git a/services/121-service/src/transaction-queues/dto/nedbank-transaction-job.dto.ts b/services/121-service/src/transaction-queues/dto/nedbank-transaction-job.dto.ts new file mode 100644 index 0000000000..ea4eeb2569 --- /dev/null +++ b/services/121-service/src/transaction-queues/dto/nedbank-transaction-job.dto.ts @@ -0,0 +1,12 @@ +// TODO: Why is this called Dto and not interface? (also for safaricom and visa?) +export interface NedbankTransactionJobDto { + readonly programId: number; + readonly programFinancialServiceProviderConfigurationId: number; + readonly paymentNumber: number; + readonly referenceId: string; + readonly transactionAmount: number; + readonly isRetry: boolean; + readonly userId: number; + readonly bulkSize: number; + readonly phoneNumber: string; +} diff --git a/services/121-service/src/transaction-queues/transaction-queues.service.ts b/services/121-service/src/transaction-queues/transaction-queues.service.ts index 037a0220d9..d0ddb93ca3 100644 --- a/services/121-service/src/transaction-queues/transaction-queues.service.ts +++ b/services/121-service/src/transaction-queues/transaction-queues.service.ts @@ -8,6 +8,7 @@ import { import { QueuesRegistryService } from '@121-service/src/queues-registry/queues-registry.service'; import { JobNames } from '@121-service/src/shared/enum/job-names.enum'; import { IntersolveVisaTransactionJobDto } from '@121-service/src/transaction-queues/dto/intersolve-visa-transaction-job.dto'; +import { NedbankTransactionJobDto } from '@121-service/src/transaction-queues/dto/nedbank-transaction-job.dto'; import { SafaricomTransactionJobDto } from '@121-service/src/transaction-queues/dto/safaricom-transaction-job.dto'; @Injectable() @@ -42,4 +43,16 @@ export class TransactionQueuesService { await this.redisClient.sadd(getRedisSetName(job.data.programId), job.id); } } + + public async addNedbankTransactionJobs( + nedbankTransactionJobs: NedbankTransactionJobDto[], + ): Promise { + for (const nedbankTransactionJob of nedbankTransactionJobs) { + const job = await this.queuesService.transactionJobNedbankQueue.add( + JobNames.default, + nedbankTransactionJob, + ); + await this.redisClient.sadd(getRedisSetName(job.data.programId), job.id); + } + } } diff --git a/services/121-service/src/utils/uuid.helpers.ts b/services/121-service/src/utils/uuid.helpers.ts new file mode 100644 index 0000000000..52ef25c063 --- /dev/null +++ b/services/121-service/src/utils/uuid.helpers.ts @@ -0,0 +1,12 @@ +import { v4 as uuid, v5 as uuidv5 } from 'uuid'; + +const UUID_NAMESPACE = process.env.UUID_NAMESPACE || uuid(); + +/** + * Generate a UUID v5 based on a seed. + * @param seed The seed to generate the UUID. + * @returns The generated UUID. + */ +export function generateUUIDFromSeed(seed: string): string { + return uuidv5(seed, UUID_NAMESPACE); +} diff --git a/services/121-service/swagger.json b/services/121-service/swagger.json index 552596e4cc..091ef0ee83 100644 --- a/services/121-service/swagger.json +++ b/services/121-service/swagger.json @@ -33,13 +33,17 @@ { "method": "put", "path": "/api/roles/{userRoleId}", - "params": ["userRoleId"], + "params": [ + "userRoleId" + ], "returnType": "UserRoleResponseDTO" }, { "method": "delete", "path": "/api/roles/{userRoleId}", - "params": ["userRoleId"], + "params": [ + "userRoleId" + ], "returnType": "UserRoleResponseDTO" }, { @@ -76,13 +80,17 @@ { "method": "delete", "path": "/api/users/{userId}", - "params": ["userId"], + "params": [ + "userId" + ], "returnType": "UserEntity" }, { "method": "patch", "path": "/api/users/{userId}", - "params": ["userId"] + "params": [ + "userId" + ] }, { "method": "get", @@ -92,36 +100,53 @@ { "method": "get", "path": "/api/programs/{programId}/users/search", - "params": ["programId", "username"] + "params": [ + "programId", + "username" + ] }, { "method": "get", "path": "/api/programs/{programId}/users/{userId}", - "params": ["programId", "userId"], + "params": [ + "programId", + "userId" + ], "returnType": "AssignmentResponseDTO" }, { "method": "put", "path": "/api/programs/{programId}/users/{userId}", - "params": ["programId", "userId"], + "params": [ + "programId", + "userId" + ], "returnType": "AssignmentResponseDTO" }, { "method": "patch", "path": "/api/programs/{programId}/users/{userId}", - "params": ["programId", "userId"], + "params": [ + "programId", + "userId" + ], "returnType": "AssignmentResponseDTO" }, { "method": "delete", "path": "/api/programs/{programId}/users/{userId}", - "params": ["programId", "userId"], + "params": [ + "programId", + "userId" + ], "returnType": "AssignmentResponseDTO" }, { "method": "get", "path": "/api/programs/{programId}/users", - "params": ["programId"] + "params": [ + "programId" + ] }, { "method": "post", @@ -139,7 +164,9 @@ { "method": "post", "path": "/api/scripts/duplicate-registrations", - "params": ["mockPowerNumberRegistrations"] + "params": [ + "mockPowerNumberRegistrations" + ] }, { "method": "post", @@ -149,62 +176,93 @@ { "method": "get", "path": "/api/notifications/{programId}/message-templates", - "params": ["programId"] + "params": [ + "programId" + ] }, { "method": "post", "path": "/api/notifications/{programId}/message-templates", - "params": ["programId"] + "params": [ + "programId" + ] }, { "method": "patch", "path": "/api/notifications/{programId}/message-templates/{type}/{language}", - "params": ["language", "type", "programId"], + "params": [ + "language", + "type", + "programId" + ], "returnType": "MessageTemplateEntity" }, { "method": "delete", "path": "/api/notifications/{programId}/message-templates/{type}", - "params": ["language", "type", "programId"], + "params": [ + "language", + "type", + "programId" + ], "returnType": "DeleteResult" }, { "method": "get", "path": "/api/programs/{programId}", - "params": ["programId", "formatProgramReturnDto"] + "params": [ + "programId", + "formatProgramReturnDto" + ] }, { "method": "delete", "path": "/api/programs/{programId}", - "params": ["programId"] + "params": [ + "programId" + ] }, { "method": "patch", "path": "/api/programs/{programId}", - "params": ["programId"], + "params": [ + "programId" + ], "returnType": "ProgramReturnDto" }, { "method": "post", "path": "/api/programs", - "params": ["importFromKobo", "koboToken", "koboAssetId"], + "params": [ + "importFromKobo", + "koboToken", + "koboAssetId" + ], "returnType": "ProgramEntity" }, { "method": "post", "path": "/api/programs/{programId}/registration-attributes", - "params": ["programId"] + "params": [ + "programId" + ] }, { "method": "patch", "path": "/api/programs/{programId}/registration-attributes/{programRegistrationAttributeName}", - "params": ["programId", "programRegistrationAttributeName"], + "params": [ + "programId", + "programRegistrationAttributeName" + ], "returnType": "ProgramRegistrationAttributeEntity" }, { "method": "delete", "path": "/api/programs/{programId}/registration-attributes/{programRegistrationAttributeId}", - "params": ["programId", "programRegistrationAttributeId"] + "params": [ + "programId", + "programRegistrationAttributeId" + ] }, { "method": "get", @@ -219,18 +277,25 @@ { "method": "get", "path": "/api/programs/{programId}/financial-service-providers/intersolve-visa/funding-wallet", - "params": ["programId"] + "params": [ + "programId" + ] }, { "method": "get", "path": "/api/programs/{programId}/actions", - "params": ["programId", "actionType"], + "params": [ + "programId", + "actionType" + ], "returnType": "ActionReturnDto" }, { "method": "post", "path": "/api/programs/{programId}/actions", - "params": ["programId"], + "params": [ + "programId" + ], "returnType": "ActionReturnDto" }, { @@ -242,58 +307,94 @@ { "method": "get", "path": "/api/financial-service-providers/{financialServiceProviderName}", - "params": ["financialServiceProviderName"], + "params": [ + "financialServiceProviderName" + ], "returnType": "FinancialServiceProviderDto" }, { "method": "get", "path": "/api/programs/{programId}/financial-service-provider-configurations", - "params": ["programId"] + "params": [ + "programId" + ] }, { "method": "post", "path": "/api/programs/{programId}/financial-service-provider-configurations", - "params": ["programId"] + "params": [ + "programId" + ] }, { "method": "patch", "path": "/api/programs/{programId}/financial-service-provider-configurations/{name}", - "params": ["programId", "name"] + "params": [ + "programId", + "name" + ] }, { "method": "delete", "path": "/api/programs/{programId}/financial-service-provider-configurations/{name}", - "params": ["programId", "name"] + "params": [ + "programId", + "name" + ] }, { "method": "post", "path": "/api/programs/{programId}/financial-service-provider-configurations/{name}/properties", - "params": ["programId", "name"] + "params": [ + "programId", + "name" + ] }, { "method": "patch", "path": "/api/programs/{programId}/financial-service-provider-configurations/{name}/properties/{propertyName}", - "params": ["programId", "name", "propertyName"] + "params": [ + "programId", + "name", + "propertyName" + ] }, { "method": "delete", "path": "/api/programs/{programId}/financial-service-provider-configurations/{name}/properties/{propertyName}", - "params": ["programId", "name", "propertyName"] + "params": [ + "programId", + "name", + "propertyName" + ] }, { "method": "get", "path": "/api/programs/{programId}/transactions", - "params": ["programId", "referenceId", "payment"] + "params": [ + "programId", + "referenceId", + "payment" + ] }, { "method": "get", "path": "/api/programs/{programId}/events", - "params": ["programId", "format", "toDate", "fromDate", "referenceId"] + "params": [ + "programId", + "format", + "toDate", + "fromDate", + "referenceId" + ] }, { "method": "get", "path": "/api/programs/{programId}/registrations/{registrationId}/events", - "params": ["registrationId", "programId"] + "params": [ + "registrationId", + "programId" + ] }, { "method": "patch", @@ -324,37 +425,55 @@ { "method": "get", "path": "/api/notifications/whatsapp/templates/{sessionId}", - "params": ["sessionId"] + "params": [ + "sessionId" + ] }, { "method": "get", "path": "/api/notifications/imageCode/{secret}", - "params": ["secret"] + "params": [ + "secret" + ] }, { "method": "get", "path": "/api/programs/{programId}/financial-service-providers/intersolve-voucher/vouchers", - "params": ["programId", "referenceId", "payment"] + "params": [ + "programId", + "referenceId", + "payment" + ] }, { "method": "get", "path": "/api/programs/{programId}/financial-service-providers/intersolve-voucher/vouchers/balance", - "params": ["programId", "referenceId", "payment"] + "params": [ + "programId", + "referenceId", + "payment" + ] }, { "method": "get", "path": "/api/programs/{programId}/financial-service-providers/intersolve-voucher/instructions", - "params": ["programId"] + "params": [ + "programId" + ] }, { "method": "post", "path": "/api/programs/{programId}/financial-service-providers/intersolve-voucher/instructions", - "params": ["programId"] + "params": [ + "programId" + ] }, { "method": "post", "path": "/api/programs/{programId}/financial-service-providers/intersolve-voucher/batch-jobs", - "params": ["programId"] + "params": [ + "programId" + ] }, { "method": "post", @@ -418,27 +537,37 @@ { "method": "get", "path": "/api/programs/{programId}/metrics/payment-state-sums", - "params": ["programId"] + "params": [ + "programId" + ] }, { "method": "get", "path": "/api/programs/{programId}/metrics/program-stats-summary", - "params": ["programId"] + "params": [ + "programId" + ] }, { "method": "get", "path": "/api/programs/{programId}/metrics/registration-status", - "params": ["programId"] + "params": [ + "programId" + ] }, { "method": "post", "path": "/api/programs/{programId}/registrations/import", - "params": ["programId"] + "params": [ + "programId" + ] }, { "method": "post", "path": "/api/programs/{programId}/registrations", - "params": ["programId"] + "params": [ + "programId" + ] }, { "method": "get", @@ -476,7 +605,9 @@ { "method": "patch", "path": "/api/programs/{programId}/registrations", - "params": ["programId"] + "params": [ + "programId" + ] }, { "method": "delete", @@ -516,7 +647,9 @@ { "method": "get", "path": "/api/programs/{programId}/registrations/import/template", - "params": ["programId"] + "params": [ + "programId" + ] }, { "method": "patch", @@ -556,12 +689,17 @@ { "method": "patch", "path": "/api/programs/{programId}/registrations/{referenceId}", - "params": ["programId", "referenceId"] + "params": [ + "programId", + "referenceId" + ] }, { "method": "get", "path": "/api/registrations", - "params": ["phonenumber"] + "params": [ + "phonenumber" + ] }, { "method": "post", @@ -601,49 +739,77 @@ { "method": "get", "path": "/api/programs/{programId}/registrations/{referenceId}/messages", - "params": ["referenceId", "programId"] + "params": [ + "referenceId", + "programId" + ] }, { "method": "get", "path": "/api/programs/{programId}/registrations/referenceid/{paId}", - "params": ["paId", "programId"] + "params": [ + "paId", + "programId" + ] }, { "method": "post", "path": "/api/programs/{programId}/registrations/{referenceId}/financial-service-providers/intersolve-visa/wallet/cards", - "params": ["programId", "referenceId"] + "params": [ + "programId", + "referenceId" + ] }, { "method": "patch", "path": "/api/programs/{programId}/registrations/{referenceId}/financial-service-providers/intersolve-visa/wallet/cards/{tokenCode}", - "params": ["programId", "referenceId", "tokenCode", "pause"] + "params": [ + "programId", + "referenceId", + "tokenCode", + "pause" + ] }, { "method": "patch", "path": "/api/programs/{programId}/registrations/{referenceId}/financial-service-providers/intersolve-visa/wallet", - "params": ["referenceId", "programId"], + "params": [ + "referenceId", + "programId" + ], "returnType": "IntersolveVisaWalletDto" }, { "method": "get", "path": "/api/programs/{programId}/registrations/{referenceId}/financial-service-providers/intersolve-visa/wallet", - "params": ["referenceId", "programId"], + "params": [ + "referenceId", + "programId" + ], "returnType": "IntersolveVisaWalletDto" }, { "method": "post", "path": "/api/programs/{programId}/registrations/{referenceId}/financial-service-providers/intersolve-visa/contact-information", - "params": ["programId", "referenceId"] + "params": [ + "programId", + "referenceId" + ] }, { "method": "get", "path": "/api/programs/{programId}/registrations/{id}", - "params": ["programId", "id"] + "params": [ + "programId", + "id" + ] }, { "method": "get", "path": "/api/programs/{programId}/payments", - "params": ["programId"] + "params": [ + "programId" + ] }, { "method": "post", @@ -683,33 +849,48 @@ { "method": "patch", "path": "/api/programs/{programId}/payments", - "params": ["programId"] + "params": [ + "programId" + ] }, { "method": "get", "path": "/api/programs/{programId}/payments/status", - "params": ["programId"] + "params": [ + "programId" + ] }, { "method": "get", "path": "/api/programs/{programId}/payments/{payment}", - "params": ["payment", "programId"], + "params": [ + "payment", + "programId" + ], "returnType": "PaymentReturnDto" }, { "method": "get", "path": "/api/programs/{programId}/payments/{payment}/fsp-instructions", - "params": ["programId", "payment"] + "params": [ + "programId", + "payment" + ] }, { "method": "get", "path": "/api/programs/{programId}/payments/fsp-reconciliation/import-template", - "params": ["programId"] + "params": [ + "programId" + ] }, { "method": "post", "path": "/api/programs/{programId}/payments/{payment}/fsp-reconciliation", - "params": ["programId", "payment"] + "params": [ + "programId", + "payment" + ] }, { "method": "post", @@ -724,13 +905,17 @@ { "method": "get", "path": "/api/programs/{programId}/financial-service-providers/commercial-bank-ethiopia/account-enquiries", - "params": ["programId"], + "params": [ + "programId" + ], "returnType": "CommercialBankEthiopiaValidationReportDto" }, { "method": "post", "path": "/api/programs/{programId}/financial-service-providers/commercial-bank-ethiopia/account-enquiries/validation", - "params": ["programId"] + "params": [ + "programId" + ] }, { "method": "post", @@ -752,19 +937,33 @@ "path": "/api/notifications/sms/status", "params": [] }, + { + "method": "patch", + "path": "/api/financial-service-providers/nedbank", + "params": [] + }, { "method": "post", "path": "/api/programs/{programId}/registrations/{referenceId}/notes", - "params": ["programId", "referenceId"] + "params": [ + "programId", + "referenceId" + ] }, { "method": "get", "path": "/api/programs/{programId}/registrations/{referenceId}/notes", - "params": ["programId", "referenceId"] + "params": [ + "programId", + "referenceId" + ] }, { "method": "get", "path": "/api/programs/{programId}/registrations/{registrationId}/activities", - "params": ["programId", "registrationId"] + "params": [ + "programId", + "registrationId" + ] } -] +] \ No newline at end of file diff --git a/services/121-service/test/helpers/program.helper.ts b/services/121-service/test/helpers/program.helper.ts index 950bbf107b..ea251c5fd7 100644 --- a/services/121-service/test/helpers/program.helper.ts +++ b/services/121-service/test/helpers/program.helper.ts @@ -236,19 +236,25 @@ function jsonArrayToCsv(json: object[]): string { return csv.join('\r\n'); } -export async function exportList( - programId: number, - exportType: string, - accessToken: string, - fromDate?: string, - toDate?: string, -): Promise { +export async function exportList({ + programId, + exportType, + accessToken, + options = {}, +}: { + programId: number; + exportType: string; + accessToken: string; + options?: { + fromDate?: string; + toDate?: string; + minPayment?: number; + maxPayment?: number; + }; +}): Promise { const queryParams = {}; - if (fromDate) { - queryParams['fromDate'] = fromDate; - } - if (toDate) { - queryParams['toDate'] = toDate; + for (const [key, value] of Object.entries(options)) { + queryParams[key] = value; } return await getServer() .get(`/programs/${programId}/metrics/export-list/${exportType}`) diff --git a/services/121-service/test/helpers/registration.helper.ts b/services/121-service/test/helpers/registration.helper.ts index eae8771913..692956a206 100644 --- a/services/121-service/test/helpers/registration.helper.ts +++ b/services/121-service/test/helpers/registration.helper.ts @@ -1,5 +1,6 @@ import * as request from 'supertest'; +import { TransactionStatusEnum } from '@121-service/src/payments/transactions/enums/transaction-status.enum'; import { RegistrationStatusEnum } from '@121-service/src/registration/enum/registration-status.enum'; import { waitFor } from '@121-service/src/utils/waitFor.helper'; import { @@ -376,7 +377,7 @@ export async function seedPaidRegistrations( const accessToken = await getAccessToken(); await seedIncludedRegistrations(registrations, programId, accessToken); - await doPayment(programId, 1, 25, [], accessToken, { + await doPayment(programId, 1, 20, [], accessToken, { 'filter.status': '$in:included', }); @@ -387,6 +388,7 @@ export async function seedPaidRegistrations( registrationReferenceIds, accessToken, 30_000, + [TransactionStatusEnum.success, TransactionStatusEnum.waiting], ); } diff --git a/services/121-service/test/helpers/utility.helper.ts b/services/121-service/test/helpers/utility.helper.ts index 51fea3264d..1c0cb4be4a 100644 --- a/services/121-service/test/helpers/utility.helper.ts +++ b/services/121-service/test/helpers/utility.helper.ts @@ -103,6 +103,13 @@ export async function removeProgramAssignment( .send(); } +export async function runCronjobUpdateNedbankVoucherStatus(): Promise { + const accessToken = await getAccessToken(); + await getServer() + .patch('/financial-service-providers/nedbank') + .set('Cookie', [accessToken]); +} + export async function updatePermissionsOfRole( userRoleId: number, roleToUpdate: UpdateUserRoleDto, diff --git a/services/121-service/test/payment/__snapshots__/do-payment-fsp-nedbank.test.ts.snap b/services/121-service/test/payment/__snapshots__/do-payment-fsp-nedbank.test.ts.snap new file mode 100644 index 0000000000..f193ddbe38 --- /dev/null +++ b/services/121-service/test/payment/__snapshots__/do-payment-fsp-nedbank.test.ts.snap @@ -0,0 +1,5 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Do payment to PA(s) with FSP: Nedbank when create order API call gives a valid response should fail pay-out when debitor account number is missing 1`] = `"Errors: Request Validation Error - TPP account configuration mismatch (Message: NB.APIM.Field.Invalid, Code: NB.APIM.Field.Invalid, Id: 1d3e3076-9e1c-4933-aa7f-69290941ec70)"`; + +exports[`Do payment to PA(s) with FSP: Nedbank when create order API call gives a valid response should fail pay-out when we make a payment with a payment amount of over 5000 1`] = `"Errors: Request Validation Error - Instructed amount is invalid (Message: NB.APIM.Field.Invalid, Code: NB.APIM.Field.Invalid, Id: 1d3e3076-9e1c-4933-aa7f-69290941ec70)"`; diff --git a/services/121-service/test/payment/do-payment-fsp-nedbank.test.ts b/services/121-service/test/payment/do-payment-fsp-nedbank.test.ts new file mode 100644 index 0000000000..d2fc7fea9d --- /dev/null +++ b/services/121-service/test/payment/do-payment-fsp-nedbank.test.ts @@ -0,0 +1,464 @@ +import { HttpStatus } from '@nestjs/common'; + +import { ExportType } from '@121-service/src/metrics/enum/export-type.enum'; +import { NedbankVoucherStatus } from '@121-service/src/payments/fsp-integration/nedbank/enums/nedbank-voucher-status.enum'; +import { TransactionStatusEnum } from '@121-service/src/payments/transactions/enums/transaction-status.enum'; +import { ImportRegistrationsDto } from '@121-service/src/registration/dto/bulk-import.dto'; +import { SeedScript } from '@121-service/src/scripts/enum/seed-script.enum'; +import { adminOwnerDto } from '@121-service/test/fixtures/user-owner'; +import { + doPayment, + exportList, + getTransactions, + retryPayment, + waitForPaymentTransactionsToComplete, +} from '@121-service/test/helpers/program.helper'; +import { + seedIncludedRegistrations, + seedPaidRegistrations, + updateRegistration, +} from '@121-service/test/helpers/registration.helper'; +import { + getAccessToken, + resetDB, + runCronjobUpdateNedbankVoucherStatus, +} from '@121-service/test/helpers/utility.helper'; +import { registrationNedbank } from '@121-service/test/registrations/pagination/pagination-data'; + +const programId = 1; +const payment = 1; +const amount = 200; + +enum NedbankMockNumber { + failDebitorAccountIncorrect = '27000000001', + failTimoutSimulate = '27000000002', +} + +enum NebankGetOrderMockReference { + orderNotFound = 'mock-order-not-found', + mock = 'mock', +} + +describe('Do payment to PA(s)', () => { + describe('with FSP: Nedbank', () => { + let accessToken: string; + + beforeEach(async () => { + await resetDB(SeedScript.nedbankProgram); + accessToken = await getAccessToken(); + }); + + describe('when create order API call gives a valid response', () => { + it('should succesfully pay-out', async () => { + // Arrange + const paymentReferenceIds = [registrationNedbank.referenceId]; + await seedIncludedRegistrations( + [registrationNedbank], + programId, + accessToken, + ); + + // Act + const doPaymentResponse = await doPayment( + programId, + payment, + amount, + paymentReferenceIds, + accessToken, + ); + + await waitForPaymentTransactionsToComplete( + programId, + paymentReferenceIds, + accessToken, + 30_000, + [ + TransactionStatusEnum.success, + TransactionStatusEnum.error, + TransactionStatusEnum.waiting, + ], + ); + + const getTransactionsBeforeCronjob = await getTransactions( + programId, + payment, + registrationNedbank.referenceId, + accessToken, + ); + const transactionBeforeCronJob = getTransactionsBeforeCronjob.body[0]; + + // Cronjob should update the status of the transaction + await runCronjobUpdateNedbankVoucherStatus(); + await waitForPaymentTransactionsToComplete( + programId, + paymentReferenceIds, + accessToken, + 6_000, + [TransactionStatusEnum.success, TransactionStatusEnum.error], + ); + + const getTransactionsAfterCronjob = await getTransactions( + programId, + payment, + registrationNedbank.referenceId, + accessToken, + ); + const transactionAfterCronJob = getTransactionsAfterCronjob.body[0]; + + const exportPaymentResponse = await exportList({ + programId, + exportType: ExportType.payment, + accessToken, + options: { + minPayment: 0, + maxPayment: 1, + }, + }); + const exportPayment = exportPaymentResponse.body.data[0]; + + // Assert + expect(doPaymentResponse.status).toBe(HttpStatus.ACCEPTED); + expect(doPaymentResponse.body.applicableCount).toBe( + paymentReferenceIds.length, + ); + expect(doPaymentResponse.body.totalFilterCount).toBe( + paymentReferenceIds.length, + ); + expect(doPaymentResponse.body.nonApplicableCount).toBe(0); + expect(doPaymentResponse.body.sumPaymentAmountMultiplier).toBe( + registrationNedbank.paymentAmountMultiplier, + ); + expect(transactionBeforeCronJob.status).toBe( + TransactionStatusEnum.waiting, + ); + expect(transactionBeforeCronJob.errorMessage).toBe(null); + expect(transactionBeforeCronJob.user).toMatchObject(adminOwnerDto); + expect(transactionAfterCronJob.status).toBe( + TransactionStatusEnum.success, + ); + expect(exportPayment.nedbankVoucherStatus).toBe( + NedbankVoucherStatus.REDEEMED, + ); + expect(exportPayment.nedbankOrderCreateReference).toBeDefined(); + }); + + it('should fail pay-out when debitor account number is missing', async () => { + const registrationFailDebitorAccount = { + ...registrationNedbank, + phoneNumber: NedbankMockNumber.failDebitorAccountIncorrect, + }; + const paymentReferenceIds = [ + registrationFailDebitorAccount.referenceId, + ]; + await seedIncludedRegistrations( + [registrationFailDebitorAccount], + programId, + accessToken, + ); + + // Act + await doPayment( + programId, + payment, + amount, + paymentReferenceIds, + accessToken, + ); + + await waitForPaymentTransactionsToComplete( + programId, + paymentReferenceIds, + accessToken, + 30_000, + [ + TransactionStatusEnum.success, + TransactionStatusEnum.error, + TransactionStatusEnum.waiting, + ], + ); + + const getTransactionsBody = ( + await getTransactions( + programId, + payment, + registrationFailDebitorAccount.referenceId, + accessToken, + ) + ).body; + + // Assert + expect(getTransactionsBody[0].status).toBe(TransactionStatusEnum.error); + expect(getTransactionsBody[0].errorMessage).toMatchSnapshot(); + }); + + it('should fail pay-out when we make a payment with a payment amount of over 5000', async () => { + const amountOver6000 = 6000; + const paymentReferenceIds = [registrationNedbank.referenceId]; + await seedIncludedRegistrations( + [registrationNedbank], + programId, + accessToken, + ); + + // Act + await doPayment( + programId, + payment, + amountOver6000, + paymentReferenceIds, + accessToken, + ); + + await waitForPaymentTransactionsToComplete( + programId, + paymentReferenceIds, + accessToken, + 30_000, + [ + TransactionStatusEnum.success, + TransactionStatusEnum.error, + TransactionStatusEnum.waiting, + ], + ); + + const getTransactionsBody = ( + await getTransactions( + programId, + payment, + registrationNedbank.referenceId, + accessToken, + ) + ).body; + + // Assert + expect(getTransactionsBody[0].status).toBe(TransactionStatusEnum.error); + expect(getTransactionsBody[0].errorMessage).toMatchSnapshot(); + }); + + // This test is needed because if the Nedbank create order api is called with the same reference it will return the same response the second time + // So we need to make sure that the order reference is different on a retry payment if the first create order failed + it('should create a new order reference on a retry payment', async () => { + // Arrange + const registrationFailDebitorAccount = { + ...registrationNedbank, + phoneNumber: NedbankMockNumber.failDebitorAccountIncorrect, + }; + await seedIncludedRegistrations( + [registrationFailDebitorAccount], + programId, + accessToken, + ); + await doPayment( + programId, + payment, + amount, + [registrationFailDebitorAccount.referenceId], + accessToken, + ); + await waitForPaymentTransactionsToComplete( + programId, + [registrationFailDebitorAccount.referenceId], + accessToken, + 5_000, + [TransactionStatusEnum.error], + ); + const exportPaymentBeforeRetryResponse = await exportList({ + programId, + exportType: ExportType.payment, + accessToken, + options: { + minPayment: 0, + maxPayment: 1, + }, + }); + const orderReferenceBeforeRetry = + exportPaymentBeforeRetryResponse.body.data[0] + .nedbankOrderCreateReference; + + await updateRegistration( + programId, + registrationFailDebitorAccount.referenceId, + { phoneNumber: '27000000000' }, + 'to make payment work this time', + accessToken, + ); + await retryPayment(programId, payment, accessToken); + await waitForPaymentTransactionsToComplete( + programId, + [registrationFailDebitorAccount.referenceId], + accessToken, + 5_000, + [TransactionStatusEnum.waiting], + ); + const exportPaymentAfterRetryReponse = await exportList({ + programId, + exportType: ExportType.payment, + accessToken, + options: { + minPayment: 0, + maxPayment: 1, + }, + }); + const orderReferenceAfterRetry = + exportPaymentAfterRetryReponse.body.data[0] + .nedbankOrderCreateReference; + expect(orderReferenceBeforeRetry).not.toBe(orderReferenceAfterRetry); + }); + + it('should return the correct TransactionStatus for each NedbankVoucherStatus', async () => { + // Arrange + const nedbanVoucherStatusToTransactionStatus = { + [NedbankVoucherStatus.PENDING]: TransactionStatusEnum.waiting, + [NedbankVoucherStatus.PROCESSING]: TransactionStatusEnum.waiting, + [NedbankVoucherStatus.REDEEMABLE]: TransactionStatusEnum.waiting, + [NedbankVoucherStatus.REDEEMED]: TransactionStatusEnum.success, + [NedbankVoucherStatus.REFUNDED]: TransactionStatusEnum.error, + }; + const registrations: ImportRegistrationsDto[] = []; + for (const status in nedbanVoucherStatusToTransactionStatus) { + const registration = { + ...registrationNedbank, + referenceId: `${NebankGetOrderMockReference.mock}-${status}`, + }; + registrations.push(registration); + } + await seedPaidRegistrations(registrations, programId); + + // Act + await runCronjobUpdateNedbankVoucherStatus(); + const getExportListResponse = await exportList({ + programId, + exportType: ExportType.payment, + accessToken, + options: { + minPayment: 0, + maxPayment: 1, + }, + }); + const exportListData = getExportListResponse.body.data; + for (const exportData of exportListData) { + const expectedStatus = + nedbanVoucherStatusToTransactionStatus[ + exportData.nedbankVoucherStatus + ]; + expect(exportData.status).toBe(expectedStatus); + } + }); + }); + + describe('when the create order API call times out', () => { + it('should update the transaction status to succes in the nedbank cronjob if the voucher is redeemed', async () => { + // Arrange + const registrationFailTimeout = { + ...registrationNedbank, + phoneNumber: NedbankMockNumber.failTimoutSimulate, // This phone number will simulate a time-out in our mock service + }; + + // Act + await seedPaidRegistrations([registrationFailTimeout], programId); + const paymentExportBeforeCronResponse = await exportList({ + programId, + exportType: ExportType.payment, + accessToken, + options: { + minPayment: 0, + maxPayment: 1, + }, + }); + const paymentExportBeforeCron = + paymentExportBeforeCronResponse.body.data[0]; + + await updateRegistration( + programId, + registrationFailTimeout.referenceId, + { phoneNumber: '27000000000' }, + 'to make payment work this time', + accessToken, + ); + + await runCronjobUpdateNedbankVoucherStatus(); + const paymentExportAfterCronResponse = await exportList({ + programId, + exportType: ExportType.payment, + accessToken, + options: { + minPayment: 0, + maxPayment: 1, + }, + }); + const paymentExportAfterCron = + paymentExportAfterCronResponse.body.data[0]; + + // Assert + expect(paymentExportBeforeCron.nedbankVoucherStatus).toBe(null); + expect(paymentExportBeforeCron.status).toBe( + TransactionStatusEnum.waiting, + ); + expect(paymentExportAfterCron.nedbankOrderCreateReference).toBe( + paymentExportBeforeCron.nedbankOrderCreateReference, + ); + expect(paymentExportAfterCron.nedbankVoucherStatus).toBe( + NedbankVoucherStatus.REDEEMED, + ); + expect(paymentExportAfterCron.status).toBe( + TransactionStatusEnum.success, + ); + }); + + it('should update the transaction status to failed in the nedbank cronjob if the voucher is not found', async () => { + // Arrange + const registrationFailTimeout = { + ...registrationNedbank, + phoneNumber: NedbankMockNumber.failTimoutSimulate, // This phone number will simulate a time-out in our mock service + referenceId: NebankGetOrderMockReference.orderNotFound, // This referenceId will be copied to the orderCreateReference and this will simulate a not found order in our mock service when we try to get the order + }; + await seedPaidRegistrations([registrationFailTimeout], programId); + const paymentExportBeforeCronResponse = await exportList({ + programId, + exportType: ExportType.payment, + accessToken, + options: { + minPayment: 0, + maxPayment: 1, + }, + }); + const paymentExportBeforeCron = + paymentExportBeforeCronResponse.body.data[0]; + + await updateRegistration( + programId, + registrationFailTimeout.referenceId, + { phoneNumber: '27000000000' }, + 'to make payment work this time', + accessToken, + ); + + await runCronjobUpdateNedbankVoucherStatus(); + const paymentExportAfterCronResponse = await exportList({ + programId, + exportType: ExportType.payment, + accessToken, + options: { + minPayment: 0, + maxPayment: 1, + }, + }); + const paymentExportAfterCron = + paymentExportAfterCronResponse.body.data[0]; + + // Assert + expect(paymentExportBeforeCron.nedbankVoucherStatus).toBe(null); + expect(paymentExportBeforeCron.status).toBe( + TransactionStatusEnum.waiting, + ); + expect(paymentExportAfterCron.nedbankOrderCreateReference).toBe( + paymentExportBeforeCron.nedbankOrderCreateReference, + ); + expect(paymentExportAfterCron.nedbankVoucherStatus).toBe( + NedbankVoucherStatus.FAILED, + ); + expect(paymentExportAfterCron.status).toBe(TransactionStatusEnum.error); + }); + }); + }); +}); diff --git a/services/121-service/test/registrations/pagination/pagination-data.ts b/services/121-service/test/registrations/pagination/pagination-data.ts index 1059e33cda..93795679d3 100644 --- a/services/121-service/test/registrations/pagination/pagination-data.ts +++ b/services/121-service/test/registrations/pagination/pagination-data.ts @@ -336,3 +336,13 @@ export const registrationsPvExcel = [ registrationPvExcel3, registrationPvExcel4, ]; + +export const registrationNedbank = { + referenceId: 'registration-nedbank-1', + phoneNumber: '39231855170', + preferredLanguage: LanguageEnum.en, + paymentAmountMultiplier: 1, + programFinancialServiceProviderConfigurationName: + FinancialServiceProviders.nedbank, + maxPayments: 3, +}; diff --git a/services/121-service/test/visa-card/export-report.test.ts b/services/121-service/test/visa-card/export-report.test.ts index 4658ba1b09..f4e5ef478d 100644 --- a/services/121-service/test/visa-card/export-report.test.ts +++ b/services/121-service/test/visa-card/export-report.test.ts @@ -1,3 +1,4 @@ +import { ExportType } from '@121-service/src/metrics/enum/export-type.enum'; import { SeedScript } from '@121-service/src/scripts/enum/seed-script.enum'; import { programIdVisa, @@ -44,14 +45,16 @@ describe('Export Visa debit card report', () => { accessToken, ); - const exportResult = await exportList( + const exportResult = await exportList({ programId, - 'intersolve-visa-card-details', + exportType: ExportType.intersolveVisaCardDetails, accessToken, - ); + }); // Assert - expect(exportResult.body.fileName).toBe('intersolve-visa-card-details'); + expect(exportResult.body.fileName).toBe( + ExportType.intersolveVisaCardDetails, + ); // we remove issuedDate and cardNumber, because always changes const results = exportResult.body.data.map( ({ issuedDate: _issuedDate, cardNumber: _cardNumber, ...rest }) => rest, diff --git a/services/mock-service/src/app.module.ts b/services/mock-service/src/app.module.ts index d1b43f98ef..f3c8f5e7e1 100644 --- a/services/mock-service/src/app.module.ts +++ b/services/mock-service/src/app.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { ExchangeRatesMockModule } from '@mock-service/src/exchange-rates/exchange-rates-mock.module'; import { IntersolveVisaMockModule } from '@mock-service/src/fsp-integration/intersolve-visa/intersolve-visa.mock.module'; +import { NedbankMockModule } from '@mock-service/src/fsp-integration/nedbank/nedbank.mock.module'; import { SafaricomMockModule } from '@mock-service/src/fsp-integration/safaricom/safaricom.mock.module'; import { InstanceModule } from '@mock-service/src/instance.module'; import { ResetModule } from '@mock-service/src/reset/reset.module'; @@ -15,6 +16,7 @@ import { TwilioModule } from '@mock-service/src/twilio/twilio.module'; ResetModule, IntersolveVisaMockModule, ExchangeRatesMockModule, + NedbankMockModule, ], controllers: [], providers: [], diff --git a/services/mock-service/src/fsp-integration/nedbank/nedbank.mock.controller.ts b/services/mock-service/src/fsp-integration/nedbank/nedbank.mock.controller.ts new file mode 100644 index 0000000000..3083fe85fa --- /dev/null +++ b/services/mock-service/src/fsp-integration/nedbank/nedbank.mock.controller.ts @@ -0,0 +1,53 @@ +import { Body, Controller, Get, Param, Post } from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { NedbankMockService } from '@mock-service/src/fsp-integration/nedbank/nedbank.mock.service'; + +// Only contains the values that are used in the mock service +export class NedbankCreateOrderMockPayload { + Data: { + Initiation: { + InstructionIdentification: string; + InstructedAmount: { + Amount: string; + Currency: string; + }; + DebtorAccount: { + SchemeName: string; + Identification: string; + Name: string; + SecondaryIdentification: string; + }; + CreditorAccount: { + SchemeName: string; + Identification: string; + Name: string; + SecondaryIdentification: string; + }; + }; + ExpirationDateTime: string; + }; + Risk: { + OrderCreateReference: string; + OrderDateTime: string; + }; +} +@ApiTags('fsp/nedbank') +@Controller('fsp/nedbank') +export class NedbankMockController { + public constructor(private readonly nedbankMockService: NedbankMockService) {} + + @ApiOperation({ summary: 'Make create order call' }) + @Post('/v1/orders') + public createOrder(@Body() body: NedbankCreateOrderMockPayload): object { + return this.nedbankMockService.createOrder(body); + } + + @ApiOperation({ summary: 'Get order status by reference id' }) + @Get('v1/orders/references/:orderCreateReference') + public getOrder( + @Param('orderCreateReference') orderCreateReference: string, + ): object { + return this.nedbankMockService.getOrder(orderCreateReference); + } +} diff --git a/services/mock-service/src/fsp-integration/nedbank/nedbank.mock.module.ts b/services/mock-service/src/fsp-integration/nedbank/nedbank.mock.module.ts new file mode 100644 index 0000000000..3ec8fcd975 --- /dev/null +++ b/services/mock-service/src/fsp-integration/nedbank/nedbank.mock.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; + +import { NedbankMockController } from '@mock-service/src/fsp-integration/nedbank/nedbank.mock.controller'; +import { NedbankMockService } from '@mock-service/src/fsp-integration/nedbank/nedbank.mock.service'; + +@Module({ + imports: [], + providers: [NedbankMockService], + controllers: [NedbankMockController], + exports: [NedbankMockService], +}) +export class NedbankMockModule {} diff --git a/services/mock-service/src/fsp-integration/nedbank/nedbank.mock.service.ts b/services/mock-service/src/fsp-integration/nedbank/nedbank.mock.service.ts new file mode 100644 index 0000000000..15302feb12 --- /dev/null +++ b/services/mock-service/src/fsp-integration/nedbank/nedbank.mock.service.ts @@ -0,0 +1,139 @@ +import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; + +import { NedbankCreateOrderMockPayload } from '@mock-service/src/fsp-integration/nedbank/nedbank.mock.controller'; + +export enum NedbankVoucherStatus { // Would be great if we could import this for the 121-service, unfortunately there is no easy way to do this yet. + PENDING = 'PENDING', + PROCESSING = 'PROCESSING', + REDEEMABLE = 'REDEEMABLE', + REDEEMED = 'REDEEMED', + REFUNDED = 'REFUNDED', +} + +// +27 (the prefix of SA phonenumber) is not included in the number here because the (real nedbank) API accepts both with and without the prefix +enum NedbankMockNumber { + failDebitorAccountIncorrect = '27000000001', + failTimoutSimulate = '27000000002', +} + +enum NebankGetOrderMockReference { + orderNotFound = 'mock-order-not-found', + mock = 'mock', +} + +@Injectable() +export class NedbankMockService { + public async getOrder(orderCreateReference: string): Promise { + if (orderCreateReference.includes(NebankGetOrderMockReference.mock)) { + if ( + orderCreateReference.includes(NebankGetOrderMockReference.orderNotFound) + ) { + return { + Message: 'BUSINESS ERROR', + Code: 'NB.APIM.Resource.NotFound', + Id: 'f1efff05-bfb2-422b-8b3c-08d3eda34da9', + Errors: [ + { + ErrorCode: 'NB.APIM.Resource.NotFound', + Message: 'Order not found', + Path: '', + Url: '', + }, + ], + }; + } + // Loop over NedbankVoucherStatus enum and check if the orderCreateReference is conttains the enum value + for (const status in NedbankVoucherStatus) { + if (orderCreateReference.includes(NedbankVoucherStatus[status])) { + return { + Data: { + Transactions: { + Voucher: { + Status: NedbankVoucherStatus[status], + }, + }, + }, + }; + } + } + } + return { + Data: { + Transactions: { + Voucher: { + Status: NedbankVoucherStatus.REDEEMED, + }, + }, + }, + }; + } + + public async createOrder( + createOrderPayload: NedbankCreateOrderMockPayload, + ): Promise> { + const { Data } = createOrderPayload; + const { Initiation } = Data; + + // Scenario Incorrect DebtorAccount Identification + if ( + !Initiation.DebtorAccount.Identification || + Data.Initiation.CreditorAccount.Identification.includes( + NedbankMockNumber.failDebitorAccountIncorrect, + ) + ) { + return { + Message: 'BUSINESS ERROR', + Code: 'NB.APIM.Field.Invalid', + Id: '1d3e3076-9e1c-4933-aa7f-69290941ec70', + Errors: [ + { + ErrorCode: 'NB.APIM.Field.Invalid', + Message: + 'Request Validation Error - TPP account configuration mismatch', + Path: '', + Url: '', + }, + ], + }; + } + if ( + Data.Initiation.CreditorAccount.Identification.includes( + NedbankMockNumber.failTimoutSimulate, + ) + ) { + // Simulate a timeout error + // This is not an actual timeout since testing an actual timeout would make our automated test runs slower + // This is just a way to simulate the result of a timout error which would be that the order status would never be updated + // ##TODO: discuss if we can find a more elegant way to simulate a timeout error + if ( + Data.Initiation.CreditorAccount.Identification.includes( + NedbankMockNumber.failTimoutSimulate, + ) + ) { + throw new HttpException( + 'Simulated timeout', + HttpStatus.REQUEST_TIMEOUT, + ); + } + } + if (Number(Data.Initiation.InstructedAmount.Amount.slice(0, -3)) > 5000) { + return { + Message: 'BUSINESS ERROR', + Code: 'NB.APIM.Field.Invalid', + Id: '1d3e3076-9e1c-4933-aa7f-69290941ec70', + Errors: [ + { + ErrorCode: 'NB.APIM.Field.Invalid', + Message: 'Request Validation Error - Instructed amount is invalid', + }, + ], + }; + } + + return { + Data: { + OrderStatus: NedbankVoucherStatus.PENDING, + }, + }; + } +} From fbc912cb3ca71c2361f6144f85d652a61f635170 Mon Sep 17 00:00:00 2001 From: Ruben Date: Tue, 21 Jan 2025 16:28:52 +0100 Subject: [PATCH 02/13] env example nedbank --- services/.env.example | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/.env.example b/services/.env.example index abc5c23a06..8ce696c2f7 100644 --- a/services/.env.example +++ b/services/.env.example @@ -305,7 +305,7 @@ NEDBANK_CLIENT_SECRET=test-NEDBANK_CLIENT_SECRET NEDBANK_API_URL=https://b2b-api.nedbank.co.za/apimarket/b2b-sb/ # To use a mock version of the NEDBANK API, use: `TRUE` to enable, leave empty or out to disable. MOCK_NEDBANK=TRUE -##TODO: Add some documentation about the certificate +# The nedbank certificate is used to authenticate with the Nedbank API NEDBANK_CERTIFICATE_PATH=cert/APIMTPP_redcross_sandbox.pfx NEDBANK_CERTIFICATE_PASSWORD= From 19b5ee2723070aea28d260a1c357d1aa5dbaf4c1 Mon Sep 17 00:00:00 2001 From: Ruben Date: Wed, 22 Jan 2025 11:52:31 +0100 Subject: [PATCH 03/13] typo --- .../nedbank/services/nedbank-api.helper.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/121-service/src/payments/fsp-integration/nedbank/services/nedbank-api.helper.service.ts b/services/121-service/src/payments/fsp-integration/nedbank/services/nedbank-api.helper.service.ts index 49431b00dc..a8c439c663 100644 --- a/services/121-service/src/payments/fsp-integration/nedbank/services/nedbank-api.helper.service.ts +++ b/services/121-service/src/payments/fsp-integration/nedbank/services/nedbank-api.helper.service.ts @@ -29,7 +29,7 @@ export class NedbankApiHelperService { }): Promise> { if (!this.httpsAgent) { throw new NedbankError( - 'Nedbank certificate has not been read. It could be that NEDBANK_CERTIFICATE_PATH or NEDBANK_CERTIFICATE_PASSWORD are not set or that certificate has not been uploaded to the server. Please contact 121 support', + 'Nedbank certificate has not been read. It could be that NEDBANK_CERTIFICATE_PATH or NEDBANK_CERTIFICATE_PASSWORD are not set or that the certificate has not been uploaded to the server. Please contact 121 support', ); } const headers = this.createHeaders(); From 19a9c94e007088dc382fbe77d0b705f321c0dfb0 Mon Sep 17 00:00:00 2001 From: Ruben Date: Wed, 22 Jan 2025 14:32:14 +0100 Subject: [PATCH 04/13] Added payment reference --- .../src/metrics/metrics.service.ts | 5 +++ .../src/migration/1736155425026-nedbank.ts | 8 ++++- .../nedbank-create-voucher-params.ts | 1 + .../nedbank/nedbank-voucher.entity.ts | 6 +++- .../nedbank/nedbank.service.spec.ts | 9 ++--- .../nedbank/nedbank.service.ts | 2 ++ .../nedbank-voucher.scoped.repository.ts | 3 ++ .../nedbank/services/nedbank-api.service.ts | 9 +++-- ...transaction-job-processors.service.spec.ts | 35 ++++++++++++------- .../transaction-job-processors.service.ts | 6 ++++ .../payment/do-payment-fsp-nedbank.test.ts | 3 ++ .../nedbank/nedbank.mock.service.ts | 21 +++++++++++ 12 files changed, 88 insertions(+), 20 deletions(-) diff --git a/services/121-service/src/metrics/metrics.service.ts b/services/121-service/src/metrics/metrics.service.ts index c4581d0b63..d2d1bfe68a 100644 --- a/services/121-service/src/metrics/metrics.service.ts +++ b/services/121-service/src/metrics/metrics.service.ts @@ -917,6 +917,11 @@ export class MetricsService { attribute: 'orderCreateReference', alias: 'nedbankOrderCreateReference', }, + { + entityJoinedToTransaction: NedbankVoucherEntity, //TODO: should we move this to financial-service-providers-settings.const.ts? + attribute: 'paymentReference', + alias: 'nedbankPaymentReference', + }, ], ]; } diff --git a/services/121-service/src/migration/1736155425026-nedbank.ts b/services/121-service/src/migration/1736155425026-nedbank.ts index e4e74096f9..4e00710703 100644 --- a/services/121-service/src/migration/1736155425026-nedbank.ts +++ b/services/121-service/src/migration/1736155425026-nedbank.ts @@ -5,7 +5,7 @@ export class Nedbank1736155425026 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( - `CREATE TABLE "121-service"."nedbank_voucher" ("id" SERIAL NOT NULL, "created" TIMESTAMP NOT NULL DEFAULT now(), "updated" TIMESTAMP NOT NULL DEFAULT now(), "orderCreateReference" character varying NOT NULL, "status" character varying, "transactionId" integer NOT NULL, CONSTRAINT "UQ_3a31e9cd76bd9c06826c016c130" UNIQUE ("orderCreateReference"), CONSTRAINT "REL_739b726eaa8f29ede851906edd" UNIQUE ("transactionId"), CONSTRAINT "PK_85d56d9ed997ba24b53b3aa36e7" PRIMARY KEY ("id"))`, + `CREATE TABLE "121-service"."nedbank_voucher" ("id" SERIAL NOT NULL, "created" TIMESTAMP NOT NULL DEFAULT now(), "updated" TIMESTAMP NOT NULL DEFAULT now(), "orderCreateReference" character varying NOT NULL, "paymentReference" character varying NOT NULL, "status" character varying, "transactionId" integer NOT NULL, CONSTRAINT "UQ_3a31e9cd76bd9c06826c016c130" UNIQUE ("orderCreateReference"), CONSTRAINT "REL_739b726eaa8f29ede851906edd" UNIQUE ("transactionId"), CONSTRAINT "PK_85d56d9ed997ba24b53b3aa36e7" PRIMARY KEY ("id"))`, ); await queryRunner.query( `CREATE INDEX "IDX_0db7adee73f8cc5c9d44a77e7b" ON "121-service"."nedbank_voucher" ("created") `, @@ -13,6 +13,12 @@ export class Nedbank1736155425026 implements MigrationInterface { await queryRunner.query( `ALTER TABLE "121-service"."nedbank_voucher" ADD CONSTRAINT "FK_739b726eaa8f29ede851906edd3" FOREIGN KEY ("transactionId") REFERENCES "121-service"."transaction"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, ); + await queryRunner.query( + `CREATE INDEX "IDX_3a31e9cd76bd9c06826c016c13" ON "121-service"."nedbank_voucher" ("orderCreateReference") `, + ); + await queryRunner.query( + `CREATE VIEW "121-service"."registration_view" AS SELECT "registration"."id" AS "id", "registration"."created" AS "registrationCreated", "registration"."programId" AS "programId", "registration"."registrationStatus" AS "status", "registration"."referenceId" AS "referenceId", "registration"."phoneNumber" AS "phoneNumber", "registration"."preferredLanguage" AS "preferredLanguage", "registration"."inclusionScore" AS "inclusionScore", "registration"."paymentAmountMultiplier" AS "paymentAmountMultiplier", "registration"."maxPayments" AS "maxPayments", "registration"."paymentCount" AS "paymentCount", "registration"."scope" AS "scope", "fspconfig"."label" AS "programFinancialServiceProviderConfigurationLabel", CAST(CONCAT('PA #',registration."registrationProgramId") as VARCHAR) AS "personAffectedSequence", registration."registrationProgramId" AS "registrationProgramId", TO_CHAR("registration"."created",'yyyy-mm-dd') AS "registrationCreatedDate", fspconfig."name" AS "programFinancialServiceProviderConfigurationName", fspconfig."id" AS "programFinancialServiceProviderConfigurationId", fspconfig."financialServiceProviderName" AS "financialServiceProviderName", "registration"."maxPayments" - "registration"."paymentCount" AS "paymentCountRemaining", COALESCE("message"."type" || ': ' || "message"."status",'no messages yet') AS "lastMessageStatus" FROM "121-service"."registration" "registration" LEFT JOIN "121-service"."program_financial_service_provider_configuration" "fspconfig" ON "fspconfig"."id"="registration"."programFinancialServiceProviderConfigurationId" LEFT JOIN "121-service"."latest_message" "latestMessage" ON "latestMessage"."registrationId"="registration"."id" LEFT JOIN "121-service"."twilio_message" "message" ON "message"."id"="latestMessage"."messageId" ORDER BY "registration"."registrationProgramId" ASC`, + ); } public async down(queryRunner: QueryRunner): Promise { diff --git a/services/121-service/src/payments/fsp-integration/nedbank/interfaces/nedbank-create-voucher-params.ts b/services/121-service/src/payments/fsp-integration/nedbank/interfaces/nedbank-create-voucher-params.ts index 0cfb86bcf6..e369a19efb 100644 --- a/services/121-service/src/payments/fsp-integration/nedbank/interfaces/nedbank-create-voucher-params.ts +++ b/services/121-service/src/payments/fsp-integration/nedbank/interfaces/nedbank-create-voucher-params.ts @@ -2,4 +2,5 @@ export interface NedbankCreateVoucherParams { transferAmount: number; phoneNumber: string; orderCreateReference: string; + paymentReference: string; } diff --git a/services/121-service/src/payments/fsp-integration/nedbank/nedbank-voucher.entity.ts b/services/121-service/src/payments/fsp-integration/nedbank/nedbank-voucher.entity.ts index 67fff64881..6c943448cf 100644 --- a/services/121-service/src/payments/fsp-integration/nedbank/nedbank-voucher.entity.ts +++ b/services/121-service/src/payments/fsp-integration/nedbank/nedbank-voucher.entity.ts @@ -1,4 +1,4 @@ -import { Column, Entity, JoinColumn, OneToOne } from 'typeorm'; +import { Column, Entity, Index, JoinColumn, OneToOne } from 'typeorm'; import { Base121Entity } from '@121-service/src/base.entity'; import { NedbankVoucherStatus } from '@121-service/src/payments/fsp-integration/nedbank/enums/nedbank-voucher-status.enum'; @@ -6,12 +6,16 @@ import { TransactionEntity } from '@121-service/src/payments/transactions/transa @Entity('nedbank_voucher') export class NedbankVoucherEntity extends Base121Entity { + @Index() @Column({ unique: true }) public orderCreateReference: string; @Column({ nullable: true, type: 'character varying' }) public status: NedbankVoucherStatus; + @Column({ type: 'character varying' }) + public paymentReference: string; + @OneToOne(() => TransactionEntity, { onDelete: 'CASCADE', }) diff --git a/services/121-service/src/payments/fsp-integration/nedbank/nedbank.service.spec.ts b/services/121-service/src/payments/fsp-integration/nedbank/nedbank.service.spec.ts index cd537da4fe..5c914901d6 100644 --- a/services/121-service/src/payments/fsp-integration/nedbank/nedbank.service.spec.ts +++ b/services/121-service/src/payments/fsp-integration/nedbank/nedbank.service.spec.ts @@ -9,10 +9,7 @@ import { NedbankApiService } from '@121-service/src/payments/fsp-integration/ned import { registrationNedbank } from '@121-service/test/registrations/pagination/pagination-data'; const orderCreateReference = `mock-uuid`; - -jest.mock('./nedbank-api.service'); -jest.mock('./repositories/nedbank-voucher.scoped.repository'); -jest.mock('@121-service/src/utils/uuid.helpers'); +const paymentReference = `pj1-pay1-00270000000`; describe('NedbankService', () => { let service: NedbankService; @@ -57,6 +54,7 @@ describe('NedbankService', () => { transferAmount: amount, phoneNumber: registrationNedbank.phoneNumber, orderCreateReference, + paymentReference, }); expect(result).toEqual(NedbankVoucherStatus.PENDING); @@ -65,6 +63,7 @@ describe('NedbankService', () => { transferAmount: amount, phoneNumber: registrationNedbank.phoneNumber, orderCreateReference, + paymentReference, }); }); @@ -75,6 +74,7 @@ describe('NedbankService', () => { transferAmount: amount, // Not a multiple of 10 phoneNumber: registrationNedbank.phoneNumber, orderCreateReference, + paymentReference, }), ).rejects.toThrow(NedbankError); @@ -83,6 +83,7 @@ describe('NedbankService', () => { transferAmount: amount, // Not a multiple of 10 phoneNumber: registrationNedbank.phoneNumber, orderCreateReference, + paymentReference, }), ).rejects.toThrow('Amount must be a multiple of 10'); diff --git a/services/121-service/src/payments/fsp-integration/nedbank/nedbank.service.ts b/services/121-service/src/payments/fsp-integration/nedbank/nedbank.service.ts index 4903b3cee2..97498b6844 100644 --- a/services/121-service/src/payments/fsp-integration/nedbank/nedbank.service.ts +++ b/services/121-service/src/payments/fsp-integration/nedbank/nedbank.service.ts @@ -34,6 +34,7 @@ export class NedbankService transferAmount, phoneNumber, orderCreateReference, + paymentReference, }: NedbankCreateVoucherParams): Promise { const isAmountMultipleOf10 = transferAmount % 10 === 0; if (!isAmountMultipleOf10) { @@ -44,6 +45,7 @@ export class NedbankService transferAmount, phoneNumber, orderCreateReference, + paymentReference, }); } diff --git a/services/121-service/src/payments/fsp-integration/nedbank/repositories/nedbank-voucher.scoped.repository.ts b/services/121-service/src/payments/fsp-integration/nedbank/repositories/nedbank-voucher.scoped.repository.ts index 9dad1de401..b5ec2bd425 100644 --- a/services/121-service/src/payments/fsp-integration/nedbank/repositories/nedbank-voucher.scoped.repository.ts +++ b/services/121-service/src/payments/fsp-integration/nedbank/repositories/nedbank-voucher.scoped.repository.ts @@ -18,15 +18,18 @@ export class NedbankVoucherScopedRepository extends ScopedRepository { const nedbankVoucherEntity = this.create({ + paymentReference, orderCreateReference, status: voucherStatus, transactionId, diff --git a/services/121-service/src/payments/fsp-integration/nedbank/services/nedbank-api.service.ts b/services/121-service/src/payments/fsp-integration/nedbank/services/nedbank-api.service.ts index 336a588075..7751d2182b 100644 --- a/services/121-service/src/payments/fsp-integration/nedbank/services/nedbank-api.service.ts +++ b/services/121-service/src/payments/fsp-integration/nedbank/services/nedbank-api.service.ts @@ -22,15 +22,18 @@ export class NedbankApiService { transferAmount, phoneNumber, orderCreateReference, + paymentReference, }: { transferAmount: number; phoneNumber: string; orderCreateReference: string; + paymentReference: string; }): Promise { const payload = this.createOrderPayload({ transferAmount, phoneNumber, orderCreateReference, + paymentReference, }); const createOrderResponse = await this.makeCreateOrderCall(payload); @@ -41,10 +44,12 @@ export class NedbankApiService { transferAmount, phoneNumber, orderCreateReference, + paymentReference, }: { transferAmount: number; phoneNumber: string; orderCreateReference: string; + paymentReference: string; }): NedbankCreateOrderRequestBodyDto { const currentDate = new Date(); const expirationDateIsoString = new Date( @@ -62,12 +67,12 @@ export class NedbankApiService { DebtorAccount: { SchemeName: 'account', // should always be 'account' Identification: process.env.NEDBANK_ACCOUNT_NUMBER!, // ##TODO should we check somewhere if the .env is set? - Name: 'MyRefOnceOffQATrx', // ##TODO Not sure what to set here. Quote from the API word document from didirik: 'This is what shows on the SARCS statement. We can set this value for (manual) reconciliation purposes.' + Name: paymentReference, // ##TODO Not sure what to set here. Quote from the API word document from didirik: 'This is what shows on the SARCS statement. We can set this value for (manual) reconciliation purposes.' }, CreditorAccount: { SchemeName: 'recipient', Identification: phoneNumber, - Name: 'MyRefOnceOffQATrx', // Name cannot be left empty so set it to a default value found on nedbank api documentation + Name: paymentReference, // Name cannot be left empty so set it to a default value found on nedbank api documentation }, }, ExpirationDateTime: expirationDateIsoString, diff --git a/services/121-service/src/transaction-job-processors/transaction-job-processors.service.spec.ts b/services/121-service/src/transaction-job-processors/transaction-job-processors.service.spec.ts index 1db8984051..81b956195b 100644 --- a/services/121-service/src/transaction-job-processors/transaction-job-processors.service.spec.ts +++ b/services/121-service/src/transaction-job-processors/transaction-job-processors.service.spec.ts @@ -91,19 +91,19 @@ describe('TransactionJobProcessorsService', () => { jest .spyOn(registrationScopedRepository, 'getByReferenceId') - .mockResolvedValueOnce(mockedRegistration); + .mockResolvedValue(mockedRegistration); jest .spyOn(registrationScopedRepository, 'updateUnscoped') - .mockResolvedValueOnce({} as UpdateResult); + .mockResolvedValue({} as UpdateResult); jest .spyOn(programRepository, 'findByIdOrFail') - .mockResolvedValueOnce(mockedProgram as ProgramEntity); + .mockResolvedValue(mockedProgram as ProgramEntity); jest .spyOn(transactionScopedRepository, 'save') - .mockResolvedValueOnce({ id: mockedTransactionId } as any); + .mockResolvedValue({ id: mockedTransactionId } as any); jest .spyOn( @@ -170,10 +170,6 @@ describe('TransactionJobProcessorsService', () => { const mockedCreateOrderReturn = NedbankVoucherStatus.PENDING; it('should process Nedbank transaction job successfully', async () => { - jest - .spyOn(registrationScopedRepository, 'getByReferenceId') - .mockResolvedValueOnce(mockedRegistration); - jest .spyOn(nedbankService, 'createVoucher') .mockResolvedValueOnce(mockedCreateOrderReturn); @@ -201,11 +197,13 @@ describe('TransactionJobProcessorsService', () => { transferAmount: mockedNedbankTransactionJob.transactionAmount, phoneNumber: mockedNedbankTransactionJob.phoneNumber, orderCreateReference: expect.any(String), + paymentReference: expect.any(String), }); expect(nedbankVoucherScopedRepository.storeVoucher).toHaveBeenCalledWith({ transactionId: mockedTransactionId, orderCreateReference: expect.any(String), + paymentReference: expect.any(String), }); expect(transactionScopedRepository.save).toHaveBeenCalledWith( expect.objectContaining({ @@ -220,10 +218,6 @@ describe('TransactionJobProcessorsService', () => { }); it('should handle NedbankError when creating order', async () => { - jest - .spyOn(registrationScopedRepository, 'getByReferenceId') - .mockResolvedValueOnce(mockedRegistration); - const errorMessage = 'Nedbank error occurred'; const nedbankError = new NedbankError(errorMessage); jest @@ -252,5 +246,22 @@ describe('TransactionJobProcessorsService', () => { { status: NedbankVoucherStatus.FAILED }, ); }); + + it('should create different payment reference for different payments, programs and phonenumbers in Nedbank Transaction Job', async () => { + const variousNedbankPartialJobs = [ + { programId: 1, paymentNumber: 1, phoneNumber: '12364532423' }, + { programId: 5, paymentNumber: 2, phoneNumber: '876543' }, + { programId: 3, paymentNumber: 1, phoneNumber: '456' }, + ]; + for (const jobVariant of variousNedbankPartialJobs) { + const job = { ...mockedNedbankTransactionJob, ...jobVariant }; + await transactionJobProcessorsService.processNedbankTransactionJob(job); + expect(nedbankService.createVoucher).toHaveBeenCalledWith( + expect.objectContaining({ + paymentReference: `pj${job.programId}-pay${job.paymentNumber}-${job.phoneNumber}`, + }), + ); + } + }); }); }); diff --git a/services/121-service/src/transaction-job-processors/transaction-job-processors.service.ts b/services/121-service/src/transaction-job-processors/transaction-job-processors.service.ts index 9b9bffe190..68c90e3a9d 100644 --- a/services/121-service/src/transaction-job-processors/transaction-job-processors.service.ts +++ b/services/121-service/src/transaction-job-processors/transaction-job-processors.service.ts @@ -285,6 +285,10 @@ export class TransactionJobProcessorsService { ); const oldRegistration = structuredClone(registration); + // This is a unique identifier for each transaction, which will be shown on the bank statement which the user receives by Nedbank out of the 121-platform + // It's therefore a human readable identifier, which is unique for each transaction and can be related to the registration and transaction manually + // Payment reference cannot be longer than 30 characters + const paymentReference = `pj${transactionJob.programId}-pay${transactionJob.paymentNumber}-${transactionJob.phoneNumber}`; let orderCreateReference: string; let transactionId: number; @@ -346,6 +350,7 @@ export class TransactionJobProcessorsService { } await this.nedbankVoucherScopedRepository.storeVoucher({ + paymentReference, orderCreateReference, transactionId, }); @@ -360,6 +365,7 @@ export class TransactionJobProcessorsService { transferAmount: transactionJob.transactionAmount, phoneNumber: transactionJob.phoneNumber, orderCreateReference, + paymentReference, }); } catch (error) { if (error instanceof NedbankError) { diff --git a/services/121-service/test/payment/do-payment-fsp-nedbank.test.ts b/services/121-service/test/payment/do-payment-fsp-nedbank.test.ts index d2fc7fea9d..60f0f3d88b 100644 --- a/services/121-service/test/payment/do-payment-fsp-nedbank.test.ts +++ b/services/121-service/test/payment/do-payment-fsp-nedbank.test.ts @@ -140,6 +140,9 @@ describe('Do payment to PA(s)', () => { NedbankVoucherStatus.REDEEMED, ); expect(exportPayment.nedbankOrderCreateReference).toBeDefined(); + expect(exportPayment.nedbankPaymentReference).toBe( + `pj${programId}-pay${payment}-${registrationNedbank.phoneNumber}`, + ); }); it('should fail pay-out when debitor account number is missing', async () => { diff --git a/services/mock-service/src/fsp-integration/nedbank/nedbank.mock.service.ts b/services/mock-service/src/fsp-integration/nedbank/nedbank.mock.service.ts index 15302feb12..38939f2d9e 100644 --- a/services/mock-service/src/fsp-integration/nedbank/nedbank.mock.service.ts +++ b/services/mock-service/src/fsp-integration/nedbank/nedbank.mock.service.ts @@ -74,6 +74,27 @@ export class NedbankMockService { const { Data } = createOrderPayload; const { Initiation } = Data; + // This error of max 30 chars is currently not in the integration tests + // because it should never happen as paymentReference set using a combination of fields that should never exceed 30 characters + // However this if statement is here to catch any potential future changes that might cause this error + // So than the happy path test will fail + if ( + Data.Initiation.CreditorAccount.Name.length > 30 || + Data.Initiation.DebtorAccount.Name.length > 30 + ) { + return { + Message: 'BUSINESS ERROR', + Code: 'NB.APIM.Field.Invalid', + Id: '1d3e3076-9e1c-4933-aa7f-69290941ec70', + Errors: [ + { + ErrorCode: 'NB.APIM.Field.Invalid', + Message: 'Request Validation Error - Name is too long', + }, + ], + }; + } + // Scenario Incorrect DebtorAccount Identification if ( !Initiation.DebtorAccount.Identification || From c659cadd55aa494e0951d239f6cbda08df11f5aa Mon Sep 17 00:00:00 2001 From: Ruben Date: Thu, 23 Jan 2025 10:54:51 +0100 Subject: [PATCH 05/13] Small clarifications nedbank --- .../nedbank/enums/nedbank-error-code.enum.ts | 2 +- .../nedbank/services/nedbank-api.helper.service.ts | 2 +- .../payments/transactions/transaction.repository.ts | 2 +- .../transaction-job-processors.service.spec.ts | 7 ++----- .../transaction-job-processors.service.ts | 10 ++++------ .../dto/nedbank-transaction-job.dto.ts | 1 - 6 files changed, 9 insertions(+), 15 deletions(-) diff --git a/services/121-service/src/payments/fsp-integration/nedbank/enums/nedbank-error-code.enum.ts b/services/121-service/src/payments/fsp-integration/nedbank/enums/nedbank-error-code.enum.ts index b6c4c97f49..98c5d0d581 100644 --- a/services/121-service/src/payments/fsp-integration/nedbank/enums/nedbank-error-code.enum.ts +++ b/services/121-service/src/payments/fsp-integration/nedbank/enums/nedbank-error-code.enum.ts @@ -1,6 +1,6 @@ // This is non-exhaustive enum of error codes that can be returned by Nedbank API // It is used to identify the error code and use it for business logic in our code -// Since nedbank API is not documented, only the error codes that are specifically handled in our code are included +// Since Nedbank has no documentation of all their error codes, only the error codes that are specifically handled in our code are included export enum NedbankErrorCode { NBApimResourceNotFound = 'NB.APIM.Resource.NotFound', } diff --git a/services/121-service/src/payments/fsp-integration/nedbank/services/nedbank-api.helper.service.ts b/services/121-service/src/payments/fsp-integration/nedbank/services/nedbank-api.helper.service.ts index a8c439c663..9c29db9b74 100644 --- a/services/121-service/src/payments/fsp-integration/nedbank/services/nedbank-api.helper.service.ts +++ b/services/121-service/src/payments/fsp-integration/nedbank/services/nedbank-api.helper.service.ts @@ -57,7 +57,7 @@ export class NedbankApiHelperService { if (this.httpsAgent) { return this.httpsAgent; } - // We only check here if the NEDBANK_CERTIFICATE_PATH is set and if the NEDBANK_CERTIFICATE_PASSWORD is set + // We only check here if the NEDBANK_CERTIFICATE_PATH is set and not if the NEDBANK_CERTIFICATE_PASSWORD is set // Locally we use .pfx file which is password protected // On azure we use .pf12 file which is not password protected if (!process.env.NEDBANK_CERTIFICATE_PATH) { diff --git a/services/121-service/src/payments/transactions/transaction.repository.ts b/services/121-service/src/payments/transactions/transaction.repository.ts index 483c536d54..27c9a9da5c 100644 --- a/services/121-service/src/payments/transactions/transaction.repository.ts +++ b/services/121-service/src/payments/transactions/transaction.repository.ts @@ -108,7 +108,7 @@ export class TransactionScopedRepository extends ScopedRepository { .mockResolvedValue({ id: mockedTransactionId } as any); jest - .spyOn( - transactionScopedRepository, - 'getFailedTransactionsCountForPaymentAndRegistration', - ) + .spyOn(transactionScopedRepository, 'getFailedTransactionsCount') .mockResolvedValueOnce(0); }); @@ -188,7 +185,7 @@ describe('TransactionJobProcessorsService', () => { referenceId: mockedNedbankTransactionJob.referenceId, }); expect( - transactionScopedRepository.getFailedTransactionsCountForPaymentAndRegistration, + transactionScopedRepository.getFailedTransactionsCount, ).toHaveBeenCalledWith({ registrationId: mockedRegistration.id, payment: mockedNedbankTransactionJob.paymentNumber, diff --git a/services/121-service/src/transaction-job-processors/transaction-job-processors.service.ts b/services/121-service/src/transaction-job-processors/transaction-job-processors.service.ts index 68c90e3a9d..7c33fc267a 100644 --- a/services/121-service/src/transaction-job-processors/transaction-job-processors.service.ts +++ b/services/121-service/src/transaction-job-processors/transaction-job-processors.service.ts @@ -324,12 +324,10 @@ export class TransactionJobProcessorsService { // Get count of failed transactions to create orderCreateReference const failedTransactionsCount = - await this.transactionScopedRepository.getFailedTransactionsCountForPaymentAndRegistration( - { - registrationId: registration.id, - payment: transactionJob.paymentNumber, - }, - ); + await this.transactionScopedRepository.getFailedTransactionsCount({ + registrationId: registration.id, + payment: transactionJob.paymentNumber, + }); // orderCreateReference is generated using: (referenceId + paymentNr + current failed transactions) // Using this count to generate the OrderReferenceId ensures that: // a. On payment retry, a new reference is generated (needed because a new reference is required by nedbank if a failed order was created). diff --git a/services/121-service/src/transaction-queues/dto/nedbank-transaction-job.dto.ts b/services/121-service/src/transaction-queues/dto/nedbank-transaction-job.dto.ts index ea4eeb2569..1a3ce20480 100644 --- a/services/121-service/src/transaction-queues/dto/nedbank-transaction-job.dto.ts +++ b/services/121-service/src/transaction-queues/dto/nedbank-transaction-job.dto.ts @@ -1,4 +1,3 @@ -// TODO: Why is this called Dto and not interface? (also for safaricom and visa?) export interface NedbankTransactionJobDto { readonly programId: number; readonly programFinancialServiceProviderConfigurationId: number; From 3ebbbea9b16cc14fd970ac27536fe429a7a5cea1 Mon Sep 17 00:00:00 2001 From: Ruben Date: Thu, 23 Jan 2025 13:16:46 +0100 Subject: [PATCH 06/13] Nedbank changes for QA environment --- services/121-service/.gitignore | 2 +- .../nedbank/services/nedbank-api.helper.service.ts | 4 ++-- .../fsp-integration/nedbank/services/nedbank-api.service.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/services/121-service/.gitignore b/services/121-service/.gitignore index 7bf97ce494..7ddbe0c05b 100644 --- a/services/121-service/.gitignore +++ b/services/121-service/.gitignore @@ -21,7 +21,7 @@ swagger-spec.json # database certificate cert/DigiCertGlobalRootCA.crt.pem # nedbank integration certificate -cert/APIMTPP_redcross_sandbox.pfx +cert/*.pfx # Compodoc documentation documentation diff --git a/services/121-service/src/payments/fsp-integration/nedbank/services/nedbank-api.helper.service.ts b/services/121-service/src/payments/fsp-integration/nedbank/services/nedbank-api.helper.service.ts index 9c29db9b74..a8cbcd0779 100644 --- a/services/121-service/src/payments/fsp-integration/nedbank/services/nedbank-api.helper.service.ts +++ b/services/121-service/src/payments/fsp-integration/nedbank/services/nedbank-api.helper.service.ts @@ -78,11 +78,11 @@ export class NedbankApiHelperService { }, { name: 'x-idempotency-key', // We use OrderCreateReference as 'idempotency' key and therefore set this thing with a random value - value: Math.floor(Math.random() * 10000).toString(), + value: Math.floor(Math.random() * Number.MAX_SAFE_INTEGER).toString(), }, { name: 'x-jws-signature', - value: Math.floor(Math.random() * 10000).toString(), // Should be a random integer https://apim.nedbank.co.za/static/docs/cashout-create-order + value: Math.floor(Math.random() * Number.MAX_SAFE_INTEGER).toString(), // Should be a random integer https://apim.nedbank.co.za/static/docs/cashout-create-order }, { name: 'x-fapi-financial-id', value: 'OB/2017/001' }, // Should always be this value https://apim.nedbank.co.za/static/docs/cashout-create-order { name: 'x-fapi-customer-ip-address', value: '0.0.0.0' }, // Should be a valid ip address, it does not seem to matter which one. For now we use a 0.0.0.0 to save us the trouble of setting an env for every server diff --git a/services/121-service/src/payments/fsp-integration/nedbank/services/nedbank-api.service.ts b/services/121-service/src/payments/fsp-integration/nedbank/services/nedbank-api.service.ts index 7751d2182b..a980ac6dc1 100644 --- a/services/121-service/src/payments/fsp-integration/nedbank/services/nedbank-api.service.ts +++ b/services/121-service/src/payments/fsp-integration/nedbank/services/nedbank-api.service.ts @@ -71,7 +71,7 @@ export class NedbankApiService { }, CreditorAccount: { SchemeName: 'recipient', - Identification: phoneNumber, + Identification: `00${phoneNumber}`, // We use twilio to validate/lookup phonenumbers. Twilio return phonenumber with a '+' and country code, +27 for South Africam which we store without the + in out database. Nedbank requires the number to start with 00 + country code so 0027 Name: paymentReference, // Name cannot be left empty so set it to a default value found on nedbank api documentation }, }, From 08e299ea84e33f24ef36ced9f4ce2918f3cc3ef1 Mon Sep 17 00:00:00 2001 From: Ruben Date: Thu, 23 Jan 2025 14:32:41 +0100 Subject: [PATCH 07/13] fix migration --- services/121-service/src/migration/1736155425026-nedbank.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/services/121-service/src/migration/1736155425026-nedbank.ts b/services/121-service/src/migration/1736155425026-nedbank.ts index 4e00710703..c7e132da1d 100644 --- a/services/121-service/src/migration/1736155425026-nedbank.ts +++ b/services/121-service/src/migration/1736155425026-nedbank.ts @@ -16,9 +16,6 @@ export class Nedbank1736155425026 implements MigrationInterface { await queryRunner.query( `CREATE INDEX "IDX_3a31e9cd76bd9c06826c016c13" ON "121-service"."nedbank_voucher" ("orderCreateReference") `, ); - await queryRunner.query( - `CREATE VIEW "121-service"."registration_view" AS SELECT "registration"."id" AS "id", "registration"."created" AS "registrationCreated", "registration"."programId" AS "programId", "registration"."registrationStatus" AS "status", "registration"."referenceId" AS "referenceId", "registration"."phoneNumber" AS "phoneNumber", "registration"."preferredLanguage" AS "preferredLanguage", "registration"."inclusionScore" AS "inclusionScore", "registration"."paymentAmountMultiplier" AS "paymentAmountMultiplier", "registration"."maxPayments" AS "maxPayments", "registration"."paymentCount" AS "paymentCount", "registration"."scope" AS "scope", "fspconfig"."label" AS "programFinancialServiceProviderConfigurationLabel", CAST(CONCAT('PA #',registration."registrationProgramId") as VARCHAR) AS "personAffectedSequence", registration."registrationProgramId" AS "registrationProgramId", TO_CHAR("registration"."created",'yyyy-mm-dd') AS "registrationCreatedDate", fspconfig."name" AS "programFinancialServiceProviderConfigurationName", fspconfig."id" AS "programFinancialServiceProviderConfigurationId", fspconfig."financialServiceProviderName" AS "financialServiceProviderName", "registration"."maxPayments" - "registration"."paymentCount" AS "paymentCountRemaining", COALESCE("message"."type" || ': ' || "message"."status",'no messages yet') AS "lastMessageStatus" FROM "121-service"."registration" "registration" LEFT JOIN "121-service"."program_financial_service_provider_configuration" "fspconfig" ON "fspconfig"."id"="registration"."programFinancialServiceProviderConfigurationId" LEFT JOIN "121-service"."latest_message" "latestMessage" ON "latestMessage"."registrationId"="registration"."id" LEFT JOIN "121-service"."twilio_message" "message" ON "message"."id"="latestMessage"."messageId" ORDER BY "registration"."registrationProgramId" ASC`, - ); } public async down(queryRunner: QueryRunner): Promise { From 63f1d99f05c7cee8e4f5a36f8a9907f0da96e88d Mon Sep 17 00:00:00 2001 From: Ruben Date: Fri, 24 Jan 2025 09:47:16 +0100 Subject: [PATCH 08/13] Nedbank handle invalid phonenumbers --- .../test-registrations-Nedbank.csv | 2 +- .../nedbank.service.spec.ts.snap | 3 + .../nedbank/nedbank.service.spec.ts | 79 +++++++++++++++++-- .../nedbank/nedbank.service.ts | 58 +++++++++----- .../nedbank-reconciliation.controller.ts | 2 +- .../nedbank-reconciliation.service.ts | 74 ++++++++--------- .../shared/services/custom-http.service.ts | 4 - .../do-payment-fsp-nedbank.test.ts.snap | 6 +- .../payment/do-payment-fsp-nedbank.test.ts | 53 ++++++++++++- .../pagination/pagination-data.ts | 2 +- .../nedbank/nedbank.mock.service.ts | 23 +++++- 11 files changed, 230 insertions(+), 76 deletions(-) create mode 100644 services/121-service/src/payments/fsp-integration/nedbank/__snapshots__/nedbank.service.spec.ts.snap diff --git a/e2e/test-registration-data/test-registrations-Nedbank.csv b/e2e/test-registration-data/test-registrations-Nedbank.csv index 5f54148899..39c535d7ec 100644 --- a/e2e/test-registration-data/test-registrations-Nedbank.csv +++ b/e2e/test-registration-data/test-registrations-Nedbank.csv @@ -1,2 +1,2 @@ referenceId,programFinancialServiceProviderConfigurationName,phoneNumber,preferredLanguage,paymentAmountMultiplier,fullName -,Nedbank,1234567890,en,1,Sample Name +,Nedbank,27000000000,en,1,Sample Name diff --git a/services/121-service/src/payments/fsp-integration/nedbank/__snapshots__/nedbank.service.spec.ts.snap b/services/121-service/src/payments/fsp-integration/nedbank/__snapshots__/nedbank.service.spec.ts.snap new file mode 100644 index 0000000000..36628870f7 --- /dev/null +++ b/services/121-service/src/payments/fsp-integration/nedbank/__snapshots__/nedbank.service.spec.ts.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`NedbankService retrieveVoucherInfo should handle NBApimResourceNotFound error 1`] = `"Nedbank voucher was not found, something went wrong when creating the voucher. Please retry the transfer."`; diff --git a/services/121-service/src/payments/fsp-integration/nedbank/nedbank.service.spec.ts b/services/121-service/src/payments/fsp-integration/nedbank/nedbank.service.spec.ts index 5c914901d6..2443d38b72 100644 --- a/services/121-service/src/payments/fsp-integration/nedbank/nedbank.service.spec.ts +++ b/services/121-service/src/payments/fsp-integration/nedbank/nedbank.service.spec.ts @@ -1,6 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { UpdateResult } from 'typeorm'; +import { NedbankErrorCode } from '@121-service/src/payments/fsp-integration/nedbank/enums/nedbank-error-code.enum'; import { NedbankVoucherStatus } from '@121-service/src/payments/fsp-integration/nedbank/enums/nedbank-voucher-status.enum'; import { NedbankError } from '@121-service/src/payments/fsp-integration/nedbank/errors/nedbank.error'; import { NedbankService } from '@121-service/src/payments/fsp-integration/nedbank/nedbank.service'; @@ -89,10 +90,38 @@ describe('NedbankService', () => { expect(apiService.createOrder).not.toHaveBeenCalled(); }); + + it('should throw an error if phone number does not start with 27', async () => { + const amount = 200; + const invalidPhoneNumber = '12345678901'; // Invalid phone number + + await expect( + service.createVoucher({ + transferAmount: amount, + phoneNumber: invalidPhoneNumber, + orderCreateReference, + paymentReference, + }), + ).rejects.toThrow('Phone number must start with 27'); + }); + + it('should throw an error if phone number length is not 11', async () => { + const amount = 200; + const invalidPhoneNumber = '2712345678'; // Invalid phone number length + + await expect( + service.createVoucher({ + transferAmount: amount, + phoneNumber: invalidPhoneNumber, + orderCreateReference, + paymentReference, + }), + ).rejects.toThrow('Phone number must be 11 numbers long (including 27)'); + }); }); - describe('retrieveAndUpdateVoucherStatus', () => { - it('should retrieve and update voucher status successfully', async () => { + describe('retrieveVoucherInfo', () => { + it('should retrieve voucher info successfully', async () => { jest .spyOn(apiService, 'getOrderByOrderCreateReference') .mockResolvedValue(NedbankVoucherStatus.REDEEMABLE); @@ -100,16 +129,50 @@ describe('NedbankService', () => { .spyOn(voucherRepository, 'update') .mockResolvedValue({} as UpdateResult); - const result = - await service.retrieveAndUpdateVoucherStatus(orderCreateReference); + const result = await service.retrieveVoucherInfo(orderCreateReference); - expect(result).toBe(NedbankVoucherStatus.REDEEMABLE); + expect(result).toMatchObject({ + status: NedbankVoucherStatus.REDEEMABLE, + errorMessage: undefined, + }); expect(apiService.getOrderByOrderCreateReference).toHaveBeenCalledWith( orderCreateReference, ); - expect(voucherRepository.update).toHaveBeenCalledWith( - { orderCreateReference }, - { status: NedbankVoucherStatus.REDEEMABLE }, + }); + + it('should return a voucher status and specific error message on an error with code NBApimResourceNotFound', async () => { + const error = new NedbankError('Resource not found'); + error.code = NedbankErrorCode.NBApimResourceNotFound; + + jest + .spyOn(apiService, 'getOrderByOrderCreateReference') + .mockRejectedValue(error); + + const result = await service.retrieveVoucherInfo(orderCreateReference); + + expect(result.status).toBe(NedbankVoucherStatus.FAILED); + expect(result.errorMessage).toMatchSnapshot(); + expect(apiService.getOrderByOrderCreateReference).toHaveBeenCalledWith( + orderCreateReference, + ); + }); + + it('should return the error message and a voucher status on a Nedbank error', async () => { + const error = new NedbankError('General error'); + error.code = 'SomeOtherErrorCode'; + + jest + .spyOn(apiService, 'getOrderByOrderCreateReference') + .mockRejectedValue(error); + + const result = await service.retrieveVoucherInfo(orderCreateReference); + + expect(result).toMatchObject({ + status: NedbankVoucherStatus.FAILED, + errorMessage: 'General error', + }); + expect(apiService.getOrderByOrderCreateReference).toHaveBeenCalledWith( + orderCreateReference, ); }); }); diff --git a/services/121-service/src/payments/fsp-integration/nedbank/nedbank.service.ts b/services/121-service/src/payments/fsp-integration/nedbank/nedbank.service.ts index 97498b6844..87445c9268 100644 --- a/services/121-service/src/payments/fsp-integration/nedbank/nedbank.service.ts +++ b/services/121-service/src/payments/fsp-integration/nedbank/nedbank.service.ts @@ -6,17 +6,13 @@ import { NedbankErrorCode } from '@121-service/src/payments/fsp-integration/nedb import { NedbankVoucherStatus } from '@121-service/src/payments/fsp-integration/nedbank/enums/nedbank-voucher-status.enum'; import { NedbankError } from '@121-service/src/payments/fsp-integration/nedbank/errors/nedbank.error'; import { NedbankCreateVoucherParams } from '@121-service/src/payments/fsp-integration/nedbank/interfaces/nedbank-create-voucher-params'; -import { NedbankVoucherScopedRepository } from '@121-service/src/payments/fsp-integration/nedbank/repositories/nedbank-voucher.scoped.repository'; import { NedbankApiService } from '@121-service/src/payments/fsp-integration/nedbank/services/nedbank-api.service'; @Injectable() export class NedbankService implements FinancialServiceProviderIntegrationInterface { - public constructor( - private readonly nedbankApiService: NedbankApiService, - private readonly nedbankVoucherScopedRepository: NedbankVoucherScopedRepository, - ) {} + public constructor(private readonly nedbankApiService: NedbankApiService) {} /** * Do not use! This function was previously used to send payments. @@ -40,6 +36,14 @@ export class NedbankService if (!isAmountMultipleOf10) { throw new NedbankError('Amount must be a multiple of 10'); } + if (!phoneNumber.startsWith('27')) { + throw new NedbankError('Phone number must start with 27'); + } + if (phoneNumber.length !== 11) { + throw new NedbankError( + 'Phone number must be 11 numbers long (including 27)', + ); + } return await this.nedbankApiService.createOrder({ transferAmount, @@ -49,35 +53,47 @@ export class NedbankService }); } - public async retrieveAndUpdateVoucherStatus( + public async retrieveVoucherInfo( orderCreateReference: string, - ): Promise { - let voucherStatus: NedbankVoucherStatus; + ): Promise<{ status: NedbankVoucherStatus; errorMessage?: string }> { try { - voucherStatus = + const voucherStatus = await this.nedbankApiService.getOrderByOrderCreateReference( orderCreateReference, ); + + let errorMessage: string | undefined; + if (voucherStatus === NedbankVoucherStatus.REDEEMED) { + errorMessage = + 'Voucher has been refunded by Nedbank. If you retry this transfer, the person will receive a new voucher.'; + } + + return { + status: voucherStatus, + errorMessage, + }; } catch (error) { - if ( - error instanceof NedbankError && - error.code === NedbankErrorCode.NBApimResourceNotFound // Should we abstract this error code to a 121 error code? - ) { - // This condition handles the specific case where the voucher was never created in. + if (error instanceof NedbankError) { + // This condition handles the specific case where the voucher was never created. // This situation can occur if: // 1. The server crashed during the transaction job before the voucher was created. // 2. We never received a response from Nedbank when creating the voucher. // In this case, we update the transaction status to 'error' so that the user can retry the transfer. - voucherStatus = NedbankVoucherStatus.FAILED; + let errorMessage: string; + if (error.code === NedbankErrorCode.NBApimResourceNotFound) { + errorMessage = + 'Nedbank voucher was not found, something went wrong when creating the voucher. Please retry the transfer.'; + } else { + errorMessage = error.message; + } + + return { + status: NedbankVoucherStatus.FAILED, + errorMessage, + }; } else { throw error; } } - - await this.nedbankVoucherScopedRepository.update( - { orderCreateReference }, - { status: voucherStatus }, - ); - return voucherStatus; } } diff --git a/services/121-service/src/reconciliation/nedbank-reconciliation/nedbank-reconciliation.controller.ts b/services/121-service/src/reconciliation/nedbank-reconciliation/nedbank-reconciliation.controller.ts index 0d950b175b..ad216895cc 100644 --- a/services/121-service/src/reconciliation/nedbank-reconciliation/nedbank-reconciliation.controller.ts +++ b/services/121-service/src/reconciliation/nedbank-reconciliation/nedbank-reconciliation.controller.ts @@ -29,7 +29,7 @@ export class NedbankReconciliationController { return; }) .catch((error) => { - throw new Error(`Failed: Nedbank Reconciliation - ${error}`); + console.error(`Failed: Nedbank Reconciliation - ${error}`); }); } } diff --git a/services/121-service/src/reconciliation/nedbank-reconciliation/nedbank-reconciliation.service.ts b/services/121-service/src/reconciliation/nedbank-reconciliation/nedbank-reconciliation.service.ts index 092da5c4a1..1846990b25 100644 --- a/services/121-service/src/reconciliation/nedbank-reconciliation/nedbank-reconciliation.service.ts +++ b/services/121-service/src/reconciliation/nedbank-reconciliation/nedbank-reconciliation.service.ts @@ -17,7 +17,7 @@ export class NedbankReconciliationService { public async doNedbankReconciliation(): Promise { const vouchers = await this.nedbankVoucherScopedRepository.find({ - select: ['id', 'orderCreateReference', 'transactionId'], + select: ['orderCreateReference', 'transactionId'], where: [ { status: IsNull() }, { @@ -33,45 +33,47 @@ export class NedbankReconciliationService { }); for (const voucher of vouchers) { - const voucherStatus = - await this.nedbankService.retrieveAndUpdateVoucherStatus( - voucher.orderCreateReference, - ); + await this.reconciliateVoucherAndTransaction(voucher); + } + } + + private async reconciliateVoucherAndTransaction({ + orderCreateReference, + transactionId, + }: { + orderCreateReference: string; + transactionId: number; + }): Promise { + const voucherInfo = + await this.nedbankService.retrieveVoucherInfo(orderCreateReference); + const voucherStatus = voucherInfo.status; + + await this.nedbankVoucherScopedRepository.update( + { orderCreateReference }, + { status: voucherStatus }, + ); - switch (voucherStatus) { - case NedbankVoucherStatus.REDEEMED: - await this.transactionScopedRepository.update( - { id: voucher.transactionId }, - { status: TransactionStatusEnum.success }, - ); - break; + let newTransactionStatus: TransactionStatusEnum | undefined; + switch (voucherStatus) { + case NedbankVoucherStatus.REDEEMED: + newTransactionStatus = TransactionStatusEnum.success; + break; - case NedbankVoucherStatus.REFUNDED: - await this.transactionScopedRepository.update( - { id: voucher.transactionId }, - { - status: TransactionStatusEnum.error, - errorMessage: - 'Voucher has been refunded by Nedbank. If you retry this transfer, the person will receive a new voucher.', - }, - ); - break; + case NedbankVoucherStatus.REFUNDED: + newTransactionStatus = TransactionStatusEnum.error; + break; - case NedbankVoucherStatus.FAILED: - await this.transactionScopedRepository.update( - { id: voucher.transactionId }, - { - status: TransactionStatusEnum.error, - errorMessage: - 'Nedbank voucher was not found, something went wrong when creating the voucher. Please retry the transfer.', - }, - ); - break; + case NedbankVoucherStatus.FAILED: + newTransactionStatus = TransactionStatusEnum.error; + break; - default: - // Do nothing if another voucher status is returned - break; - } + default: + // Do nothing if another voucher status is returned + return; } + await this.transactionScopedRepository.update( + { id: transactionId }, + { status: newTransactionStatus, errorMessage: voucherInfo.errorMessage }, + ); } } diff --git a/services/121-service/src/shared/services/custom-http.service.ts b/services/121-service/src/shared/services/custom-http.service.ts index 469494b5bf..5c5c4b3f63 100644 --- a/services/121-service/src/shared/services/custom-http.service.ts +++ b/services/121-service/src/shared/services/custom-http.service.ts @@ -7,7 +7,6 @@ import * as https from 'https'; import { isPlainObject } from 'lodash'; import { catchError, lastValueFrom, map, of } from 'rxjs'; -import { DEBUG } from '@121-service/src/config'; import { CookieNames } from '@121-service/src/shared/enum/cookie.enums'; import { maskValueKeepStart } from '@121-service/src/utils/mask-value.helper'; @@ -228,9 +227,6 @@ export class CustomHttpService { request: Partial, error: Partial, ): void { - if (DEBUG) { - console.log(error.data); - } if (this.defaultClient) { try { const requestPayload = this.stringify( diff --git a/services/121-service/test/payment/__snapshots__/do-payment-fsp-nedbank.test.ts.snap b/services/121-service/test/payment/__snapshots__/do-payment-fsp-nedbank.test.ts.snap index f193ddbe38..6ff75b62bf 100644 --- a/services/121-service/test/payment/__snapshots__/do-payment-fsp-nedbank.test.ts.snap +++ b/services/121-service/test/payment/__snapshots__/do-payment-fsp-nedbank.test.ts.snap @@ -1,5 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Do payment to PA(s) with FSP: Nedbank when create order API call gives a valid response should fail pay-out when debitor account number is missing 1`] = `"Errors: Request Validation Error - TPP account configuration mismatch (Message: NB.APIM.Field.Invalid, Code: NB.APIM.Field.Invalid, Id: 1d3e3076-9e1c-4933-aa7f-69290941ec70)"`; +exports[`Do payment to PA(s) with FSP: Nedbank when create order API call gives a valid response should fail pay-out when debitor account number is missing 1`] = `"Errors: Request Validation Error - TPP account configuration mismatch (Message: BUSINESS ERROR, Code: NB.APIM.Field.Invalid, Id: 1d3e3076-9e1c-4933-aa7f-69290941ec70)"`; -exports[`Do payment to PA(s) with FSP: Nedbank when create order API call gives a valid response should fail pay-out when we make a payment with a payment amount of over 5000 1`] = `"Errors: Request Validation Error - Instructed amount is invalid (Message: NB.APIM.Field.Invalid, Code: NB.APIM.Field.Invalid, Id: 1d3e3076-9e1c-4933-aa7f-69290941ec70)"`; +exports[`Do payment to PA(s) with FSP: Nedbank when create order API call gives a valid response should fail pay-out when we make a payment with a payment amount of over 5000 1`] = `"Errors: Request Validation Error - Instructed amount is invalid (Message: BUSINESS ERROR, Code: NB.APIM.Field.Invalid, Id: 1d3e3076-9e1c-4933-aa7f-69290941ec70)"`; + +exports[`Do payment to PA(s) with FSP: Nedbank when create order API call gives a valid response should set transaction status to error in reconciliation process if phonenumber is incorrect 1`] = `"Errors: Recipient.destination recipient mobile number provided is incorrect. (Message: BUSINESS ERROR, Code: NB.APIM.Field.Invalid, Id: c63aa83f-d183-486f-9e01-9d163180dbdd)"`; diff --git a/services/121-service/test/payment/do-payment-fsp-nedbank.test.ts b/services/121-service/test/payment/do-payment-fsp-nedbank.test.ts index 60f0f3d88b..79e4fbba5e 100644 --- a/services/121-service/test/payment/do-payment-fsp-nedbank.test.ts +++ b/services/121-service/test/payment/do-payment-fsp-nedbank.test.ts @@ -37,6 +37,7 @@ enum NedbankMockNumber { enum NebankGetOrderMockReference { orderNotFound = 'mock-order-not-found', mock = 'mock', + phoneNumberIncorrect = 'mock-phone-number-incorrect', } describe('Do payment to PA(s)', () => { @@ -238,6 +239,56 @@ describe('Do payment to PA(s)', () => { expect(getTransactionsBody[0].errorMessage).toMatchSnapshot(); }); + it('should set transaction status to error in reconciliation process if phonenumber is incorrect', async () => { + // Arrange + const registrationFailPhoneNumber = { + ...registrationNedbank, + referenceId: NebankGetOrderMockReference.phoneNumberIncorrect, + }; + const paymentReferenceIds = [registrationFailPhoneNumber.referenceId]; + await seedIncludedRegistrations( + [registrationFailPhoneNumber], + programId, + accessToken, + ); + + // Act + await doPayment( + programId, + payment, + amount, + paymentReferenceIds, + accessToken, + ); + + await waitForPaymentTransactionsToComplete( + programId, + paymentReferenceIds, + accessToken, + 30_000, + [ + TransactionStatusEnum.success, + TransactionStatusEnum.error, + TransactionStatusEnum.waiting, + ], + ); + + await runCronjobUpdateNedbankVoucherStatus(); + + const getTransactionsBody = ( + await getTransactions( + programId, + payment, + registrationFailPhoneNumber.referenceId, + accessToken, + ) + ).body; + + // Assert + expect(getTransactionsBody[0].status).toBe(TransactionStatusEnum.error); + expect(getTransactionsBody[0].errorMessage).toMatchSnapshot(); + }); + // This test is needed because if the Nedbank create order api is called with the same reference it will return the same response the second time // So we need to make sure that the order reference is different on a retry payment if the first create order failed it('should create a new order reference on a retry payment', async () => { @@ -350,7 +401,7 @@ describe('Do payment to PA(s)', () => { }); describe('when the create order API call times out', () => { - it('should update the transaction status to succes in the nedbank cronjob if the voucher is redeemed', async () => { + it('should update the transaction status to succes in reconciliation process if the voucher is redeemed', async () => { // Arrange const registrationFailTimeout = { ...registrationNedbank, diff --git a/services/121-service/test/registrations/pagination/pagination-data.ts b/services/121-service/test/registrations/pagination/pagination-data.ts index 93795679d3..7a24591596 100644 --- a/services/121-service/test/registrations/pagination/pagination-data.ts +++ b/services/121-service/test/registrations/pagination/pagination-data.ts @@ -339,7 +339,7 @@ export const registrationsPvExcel = [ export const registrationNedbank = { referenceId: 'registration-nedbank-1', - phoneNumber: '39231855170', + phoneNumber: '27000000000', preferredLanguage: LanguageEnum.en, paymentAmountMultiplier: 1, programFinancialServiceProviderConfigurationName: diff --git a/services/mock-service/src/fsp-integration/nedbank/nedbank.mock.service.ts b/services/mock-service/src/fsp-integration/nedbank/nedbank.mock.service.ts index 38939f2d9e..a12cf7af0a 100644 --- a/services/mock-service/src/fsp-integration/nedbank/nedbank.mock.service.ts +++ b/services/mock-service/src/fsp-integration/nedbank/nedbank.mock.service.ts @@ -18,12 +18,33 @@ enum NedbankMockNumber { enum NebankGetOrderMockReference { orderNotFound = 'mock-order-not-found', + phoneNumberIncorrect = 'mock-phone-number-incorrect', mock = 'mock', } - @Injectable() export class NedbankMockService { public async getOrder(orderCreateReference: string): Promise { + if ( + orderCreateReference.includes( + NebankGetOrderMockReference.phoneNumberIncorrect, + ) + ) { + return { + Message: 'BUSINESS ERROR', + Code: 'NB.APIM.Field.Invalid', + Id: 'c63aa83f-d183-486f-9e01-9d163180dbdd', + Errors: [ + { + ErrorCode: 'NB.APIM.Field.Invalid', + Message: + 'Recipient.destination recipient mobile number provided is incorrect.', + Path: '', + Url: '', + }, + ], + }; + } + if (orderCreateReference.includes(NebankGetOrderMockReference.mock)) { if ( orderCreateReference.includes(NebankGetOrderMockReference.orderNotFound) From a669bbdec3e4077d1d868ebe26865ba539b3a371 Mon Sep 17 00:00:00 2001 From: Ruben Date: Fri, 24 Jan 2025 11:49:17 +0100 Subject: [PATCH 09/13] Create payment reference based on program fsp configuration --- .../financial-service-provider-name.enum.ts | 1 + ...ancial-service-providers-settings.const.ts | 7 ++- .../seed-data/program/program-nedbank.json | 8 ++- ...transaction-job-processors.service.spec.ts | 61 ++++++++++++++----- .../transaction-job-processors.service.ts | 20 ++++-- .../do-payment-fsp-nedbank.test.ts.snap | 2 + .../payment/do-payment-fsp-nedbank.test.ts | 4 +- 7 files changed, 78 insertions(+), 25 deletions(-) diff --git a/services/121-service/src/financial-service-providers/enum/financial-service-provider-name.enum.ts b/services/121-service/src/financial-service-providers/enum/financial-service-provider-name.enum.ts index d8811d1db4..4deaf34419 100644 --- a/services/121-service/src/financial-service-providers/enum/financial-service-provider-name.enum.ts +++ b/services/121-service/src/financial-service-providers/enum/financial-service-provider-name.enum.ts @@ -17,4 +17,5 @@ export enum FinancialServiceProviderConfigurationProperties { brandCode = 'brandCode', coverLetterCode = 'coverLetterCode', fundingTokenCode = 'fundingTokenCode', + paymentReferencePrefix = 'paymentReferencePrefix', } diff --git a/services/121-service/src/financial-service-providers/financial-service-providers-settings.const.ts b/services/121-service/src/financial-service-providers/financial-service-providers-settings.const.ts index d3b933b897..b859e733ff 100644 --- a/services/121-service/src/financial-service-providers/financial-service-providers-settings.const.ts +++ b/services/121-service/src/financial-service-providers/financial-service-providers-settings.const.ts @@ -191,6 +191,11 @@ export const FINANCIAL_SERVICE_PROVIDER_SETTINGS: FinancialServiceProviderDto[] isRequired: true, }, ], - configurationProperties: [], + configurationProperties: [ + { + name: FinancialServiceProviderConfigurationProperties.paymentReferencePrefix, + isRequired: true, + }, + ], }, ]; diff --git a/services/121-service/src/seed-data/program/program-nedbank.json b/services/121-service/src/seed-data/program/program-nedbank.json index 3da0fbf9ca..9045309f97 100644 --- a/services/121-service/src/seed-data/program/program-nedbank.json +++ b/services/121-service/src/seed-data/program/program-nedbank.json @@ -59,7 +59,13 @@ "allowEmptyPhoneNumber": true, "programFinancialServiceProviderConfigurations": [ { - "financialServiceProvider": "Nedbank" + "financialServiceProvider": "Nedbank", + "properties": [ + { + "name": "paymentReferencePrefix", + "value": "ref#1" + } + ] } ] } diff --git a/services/121-service/src/transaction-job-processors/transaction-job-processors.service.spec.ts b/services/121-service/src/transaction-job-processors/transaction-job-processors.service.spec.ts index 1052936cb0..9226485cdb 100644 --- a/services/121-service/src/transaction-job-processors/transaction-job-processors.service.spec.ts +++ b/services/121-service/src/transaction-job-processors/transaction-job-processors.service.spec.ts @@ -10,6 +10,7 @@ import { SafaricomService } from '@121-service/src/payments/fsp-integration/safa import { TransactionStatusEnum } from '@121-service/src/payments/transactions/enums/transaction-status.enum'; import { LatestTransactionRepository } from '@121-service/src/payments/transactions/repositories/latest-transaction.repository'; import { TransactionScopedRepository } from '@121-service/src/payments/transactions/transaction.repository'; +import { ProgramFinancialServiceProviderConfigurationRepository } from '@121-service/src/program-financial-service-provider-configurations/program-financial-service-provider-configurations.repository'; import { ProgramEntity } from '@121-service/src/programs/program.entity'; import { ProgramRepository } from '@121-service/src/programs/repositories/program.repository'; import { RegistrationEntity } from '@121-service/src/registration/registration.entity'; @@ -62,6 +63,7 @@ describe('TransactionJobProcessorsService', () => { let latestTransactionRepository: LatestTransactionRepository; let transactionScopedRepository: TransactionScopedRepository; let nedbankVoucherScopedRepository: NedbankVoucherScopedRepository; + let programFinancialServiceProviderConfigurationRepository: ProgramFinancialServiceProviderConfigurationRepository; beforeEach(async () => { const { unit, unitRef } = TestBed.create( @@ -88,6 +90,10 @@ describe('TransactionJobProcessorsService', () => { unitRef.get( NedbankVoucherScopedRepository, ); + programFinancialServiceProviderConfigurationRepository = + unitRef.get( + ProgramFinancialServiceProviderConfigurationRepository, + ); jest .spyOn(registrationScopedRepository, 'getByReferenceId') @@ -167,6 +173,13 @@ describe('TransactionJobProcessorsService', () => { const mockedCreateOrderReturn = NedbankVoucherStatus.PENDING; it('should process Nedbank transaction job successfully', async () => { + jest + .spyOn( + programFinancialServiceProviderConfigurationRepository, + 'getPropertyValueByName', + ) + .mockResolvedValue('ref#1'); + jest .spyOn(nedbankService, 'createVoucher') .mockResolvedValueOnce(mockedCreateOrderReturn); @@ -214,7 +227,13 @@ describe('TransactionJobProcessorsService', () => { ); }); - it('should handle NedbankError when creating order', async () => { + it('should create a transaction with status error and a voucher with status failed when a Nedbank error occurs', async () => { + jest + .spyOn( + programFinancialServiceProviderConfigurationRepository, + 'getPropertyValueByName', + ) + .mockResolvedValue('ref#1'); const errorMessage = 'Nedbank error occurred'; const nedbankError = new NedbankError(errorMessage); jest @@ -244,21 +263,31 @@ describe('TransactionJobProcessorsService', () => { ); }); - it('should create different payment reference for different payments, programs and phonenumbers in Nedbank Transaction Job', async () => { - const variousNedbankPartialJobs = [ - { programId: 1, paymentNumber: 1, phoneNumber: '12364532423' }, - { programId: 5, paymentNumber: 2, phoneNumber: '876543' }, - { programId: 3, paymentNumber: 1, phoneNumber: '456' }, - ]; - for (const jobVariant of variousNedbankPartialJobs) { - const job = { ...mockedNedbankTransactionJob, ...jobVariant }; - await transactionJobProcessorsService.processNedbankTransactionJob(job); - expect(nedbankService.createVoucher).toHaveBeenCalledWith( - expect.objectContaining({ - paymentReference: `pj${job.programId}-pay${job.paymentNumber}-${job.phoneNumber}`, - }), - ); - } + it('should never create a payment reference longer than 30 characters', async () => { + const longPaymentReference = '1234567890123456789012345678901234567890'; + jest + .spyOn( + programFinancialServiceProviderConfigurationRepository, + 'getPropertyValueByName', + ) + .mockResolvedValue(longPaymentReference); + + await transactionJobProcessorsService.processNedbankTransactionJob( + mockedNedbankTransactionJob, + ); + expect(nedbankService.createVoucher).toHaveBeenCalledWith( + expect.objectContaining({ + paymentReference: expect.stringMatching(new RegExp(`^.{1,30}$`)), // Check if the length is not longer than 30 characters + }), + ); + + expect(nedbankService.createVoucher).toHaveBeenCalledWith( + expect.objectContaining({ + paymentReference: expect.stringContaining( + mockedNedbankTransactionJob.phoneNumber, + ), // Check if it contains the phone number + }), + ); }); }); }); diff --git a/services/121-service/src/transaction-job-processors/transaction-job-processors.service.ts b/services/121-service/src/transaction-job-processors/transaction-job-processors.service.ts index 7c33fc267a..579f624fe3 100644 --- a/services/121-service/src/transaction-job-processors/transaction-job-processors.service.ts +++ b/services/121-service/src/transaction-job-processors/transaction-job-processors.service.ts @@ -285,16 +285,28 @@ export class TransactionJobProcessorsService { ); const oldRegistration = structuredClone(registration); + // 2. Set the payment reference // This is a unique identifier for each transaction, which will be shown on the bank statement which the user receives by Nedbank out of the 121-platform // It's therefore a human readable identifier, which is unique for each transaction and can be related to the registration and transaction manually // Payment reference cannot be longer than 30 characters - const paymentReference = `pj${transactionJob.programId}-pay${transactionJob.paymentNumber}-${transactionJob.phoneNumber}`; - let orderCreateReference: string; - let transactionId: number; + const paymentReferencePrefix = + (await this.programFinancialServiceProviderConfigurationRepository.getPropertyValueByName( + { + programFinancialServiceProviderConfigurationId: + transactionJob.programFinancialServiceProviderConfigurationId, + name: FinancialServiceProviderConfigurationProperties.paymentReferencePrefix, + }, + )) as string; // This must be a string. If it is undefined the validation in payment service should have caught it. If a user set it as an array string you should get an internal server error here, this seems like an edge case; + const paymentReference = `${paymentReferencePrefix.slice( + 0, + 18, + )}-${transactionJob.phoneNumber}`; - // 2. Check if there is an existing voucher/orderCreateReference without status if not create orderCreateReference, the nedbank voucher and the related transaction + // 3. Check if there is an existing voucher/orderCreateReference without status if not create orderCreateReference, the nedbank voucher and the related transaction // This should almost never happen, only when we have a server crash or when we got a timeout from the nedbank API when creating the order // but if it does, we should use the same orderCreateReference to avoid creating a new voucher + let orderCreateReference: string; + let transactionId: number; const voucherWithoutStatus = await this.nedbankVoucherScopedRepository.getVoucherWithoutStatus({ paymentId: transactionJob.paymentNumber, diff --git a/services/121-service/test/payment/__snapshots__/do-payment-fsp-nedbank.test.ts.snap b/services/121-service/test/payment/__snapshots__/do-payment-fsp-nedbank.test.ts.snap index 6ff75b62bf..3247a495ac 100644 --- a/services/121-service/test/payment/__snapshots__/do-payment-fsp-nedbank.test.ts.snap +++ b/services/121-service/test/payment/__snapshots__/do-payment-fsp-nedbank.test.ts.snap @@ -5,3 +5,5 @@ exports[`Do payment to PA(s) with FSP: Nedbank when create order API call gives exports[`Do payment to PA(s) with FSP: Nedbank when create order API call gives a valid response should fail pay-out when we make a payment with a payment amount of over 5000 1`] = `"Errors: Request Validation Error - Instructed amount is invalid (Message: BUSINESS ERROR, Code: NB.APIM.Field.Invalid, Id: 1d3e3076-9e1c-4933-aa7f-69290941ec70)"`; exports[`Do payment to PA(s) with FSP: Nedbank when create order API call gives a valid response should set transaction status to error in reconciliation process if phonenumber is incorrect 1`] = `"Errors: Recipient.destination recipient mobile number provided is incorrect. (Message: BUSINESS ERROR, Code: NB.APIM.Field.Invalid, Id: c63aa83f-d183-486f-9e01-9d163180dbdd)"`; + +exports[`Do payment to PA(s) with FSP: Nedbank when create order API call gives a valid response should succesfully pay-out 1`] = `"ref#1-27000000000"`; diff --git a/services/121-service/test/payment/do-payment-fsp-nedbank.test.ts b/services/121-service/test/payment/do-payment-fsp-nedbank.test.ts index 79e4fbba5e..c26facd4b9 100644 --- a/services/121-service/test/payment/do-payment-fsp-nedbank.test.ts +++ b/services/121-service/test/payment/do-payment-fsp-nedbank.test.ts @@ -141,9 +141,7 @@ describe('Do payment to PA(s)', () => { NedbankVoucherStatus.REDEEMED, ); expect(exportPayment.nedbankOrderCreateReference).toBeDefined(); - expect(exportPayment.nedbankPaymentReference).toBe( - `pj${programId}-pay${payment}-${registrationNedbank.phoneNumber}`, - ); + expect(exportPayment.nedbankPaymentReference).toMatchSnapshot(); }); it('should fail pay-out when debitor account number is missing', async () => { From 0f3487a833a6bc6601f13041e08acd24b511ad22 Mon Sep 17 00:00:00 2001 From: Ruben Date: Fri, 24 Jan 2025 12:05:40 +0100 Subject: [PATCH 10/13] Try to fix api test pfx troubles --- services/.env.example | 2 +- services/121-service/cert/APIMTPP_redcross_sandbox.pfx.example | 0 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 services/121-service/cert/APIMTPP_redcross_sandbox.pfx.example diff --git a/services/.env.example b/services/.env.example index 8ce696c2f7..e3dd968db1 100644 --- a/services/.env.example +++ b/services/.env.example @@ -306,7 +306,7 @@ NEDBANK_API_URL=https://b2b-api.nedbank.co.za/apimarket/b2b-sb/ # To use a mock version of the NEDBANK API, use: `TRUE` to enable, leave empty or out to disable. MOCK_NEDBANK=TRUE # The nedbank certificate is used to authenticate with the Nedbank API -NEDBANK_CERTIFICATE_PATH=cert/APIMTPP_redcross_sandbox.pfx +NEDBANK_CERTIFICATE_PATH=cert/APIMTPP_redcross_sandbox.pfx.example NEDBANK_CERTIFICATE_PASSWORD= # --------------------------------------------------------------------- diff --git a/services/121-service/cert/APIMTPP_redcross_sandbox.pfx.example b/services/121-service/cert/APIMTPP_redcross_sandbox.pfx.example new file mode 100644 index 0000000000..e69de29bb2 From 4c508cf31041fcc365fbc7d9067e3bb1a16363c0 Mon Sep 17 00:00:00 2001 From: Ruben Date: Fri, 24 Jan 2025 12:09:01 +0100 Subject: [PATCH 11/13] Missing snap --- .../nedbank/__snapshots__/nedbank.service.spec.ts.snap | 2 ++ 1 file changed, 2 insertions(+) diff --git a/services/121-service/src/payments/fsp-integration/nedbank/__snapshots__/nedbank.service.spec.ts.snap b/services/121-service/src/payments/fsp-integration/nedbank/__snapshots__/nedbank.service.spec.ts.snap index 36628870f7..75bca2ef4b 100644 --- a/services/121-service/src/payments/fsp-integration/nedbank/__snapshots__/nedbank.service.spec.ts.snap +++ b/services/121-service/src/payments/fsp-integration/nedbank/__snapshots__/nedbank.service.spec.ts.snap @@ -1,3 +1,5 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`NedbankService retrieveVoucherInfo should handle NBApimResourceNotFound error 1`] = `"Nedbank voucher was not found, something went wrong when creating the voucher. Please retry the transfer."`; + +exports[`NedbankService retrieveVoucherInfo should return a voucher status and specific error message on an error with code NBApimResourceNotFound 1`] = `"Nedbank voucher was not found, something went wrong when creating the voucher. Please retry the transfer."`; From 4292ca0378cb96fb1dcf8f322e1e9006e62c90fd Mon Sep 17 00:00:00 2001 From: Ruben Date: Fri, 24 Jan 2025 12:27:52 +0100 Subject: [PATCH 12/13] removed obsolete unit test --- .../nedbank/__snapshots__/nedbank.service.spec.ts.snap | 2 -- 1 file changed, 2 deletions(-) diff --git a/services/121-service/src/payments/fsp-integration/nedbank/__snapshots__/nedbank.service.spec.ts.snap b/services/121-service/src/payments/fsp-integration/nedbank/__snapshots__/nedbank.service.spec.ts.snap index 75bca2ef4b..124fddb939 100644 --- a/services/121-service/src/payments/fsp-integration/nedbank/__snapshots__/nedbank.service.spec.ts.snap +++ b/services/121-service/src/payments/fsp-integration/nedbank/__snapshots__/nedbank.service.spec.ts.snap @@ -1,5 +1,3 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`NedbankService retrieveVoucherInfo should handle NBApimResourceNotFound error 1`] = `"Nedbank voucher was not found, something went wrong when creating the voucher. Please retry the transfer."`; - exports[`NedbankService retrieveVoucherInfo should return a voucher status and specific error message on an error with code NBApimResourceNotFound 1`] = `"Nedbank voucher was not found, something went wrong when creating the voucher. Please retry the transfer."`; From 3a49aa3bbcf8bc9adca34f9028c7ee4ef25b8d4c Mon Sep 17 00:00:00 2001 From: Ruben Date: Fri, 24 Jan 2025 14:35:53 +0100 Subject: [PATCH 13/13] missing parts in unit test --- .../payments/fsp-integration/nedbank/nedbank.service.spec.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/services/121-service/src/payments/fsp-integration/nedbank/nedbank.service.spec.ts b/services/121-service/src/payments/fsp-integration/nedbank/nedbank.service.spec.ts index 2443d38b72..f3d6542317 100644 --- a/services/121-service/src/payments/fsp-integration/nedbank/nedbank.service.spec.ts +++ b/services/121-service/src/payments/fsp-integration/nedbank/nedbank.service.spec.ts @@ -103,6 +103,8 @@ describe('NedbankService', () => { paymentReference, }), ).rejects.toThrow('Phone number must start with 27'); + + expect(apiService.createOrder).not.toHaveBeenCalled(); }); it('should throw an error if phone number length is not 11', async () => { @@ -117,6 +119,8 @@ describe('NedbankService', () => { paymentReference, }), ).rejects.toThrow('Phone number must be 11 numbers long (including 27)'); + + expect(apiService.createOrder).not.toHaveBeenCalled(); }); });