diff --git a/e2e/test-registration-data/test-registrations-SARC.csv b/e2e/test-registration-data/test-registrations-SARC.csv new file mode 100644 index 0000000000..87c0378e40 --- /dev/null +++ b/e2e/test-registration-data/test-registrations-SARC.csv @@ -0,0 +1,2 @@ +referenceId,programFinancialServiceProviderConfigurationName,phoneNumber,preferredLanguage,paymentAmountMultiplier,fullName,nationalId +,Nedbank,31600000000,en,1,Sample Name,1234567890 diff --git a/services/.env.example b/services/.env.example index 454d365b44..86ffdbbea0 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 # ---------- @@ -44,6 +47,9 @@ PORT_MOCK_SERVICE=3001 # Note: Also used in Mock-Service EXTERNAL_121_SERVICE_URL=http://localhost:3000/ +# Public IP-address of the API. Currently only used for our Nedbank integration. +PUBLIC_IP= + # API Access rate-limits. # Provided values are set high for local development/testing. # Generic throttling is applied to all requests. @@ -171,6 +177,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 @@ -251,8 +262,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. @@ -286,6 +295,20 @@ 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 viseble in the Nedbank API documentation +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..89e6a27768 100644 --- a/services/121-service/.gitignore +++ b/services/121-service/.gitignore @@ -19,10 +19,11 @@ swagger-spec.json # certificates cert/DigiCertGlobalRootCA.crt.pem +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 f813cade61..41dd8e3913 100644 --- a/services/121-service/module-dependencies.md +++ b/services/121-service/module-dependencies.md @@ -73,6 +73,9 @@ graph LR PaymentsModule-->SafaricomModule SafaricomModule-->RedisModule SafaricomModule-->QueuesRegistryModule + PaymentsModule-->NedbankModule + NedbankModule-->RedisModule + NedbankModule-->QueuesRegistryModule PaymentsModule-->ExcelModule ExcelModule-->TransactionsModule ExcelModule-->RegistrationsModule @@ -103,6 +106,8 @@ graph LR MessageIncomingModule-->MessageTemplateModule MessageIncomingModule-->RegistrationDataModule MessageIncomingModule-->QueuesRegistryModule + FinancialSyncModule-->NedbankModule + FinancialSyncModule-->TransactionsModule NoteModule-->RegistrationsModule NoteModule-->UserModule ActivitiesModule-->NoteModule @@ -113,6 +118,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 fb03642709..fceee01ca5 100644 --- a/services/121-service/src/app.module.ts +++ b/services/121-service/src/app.module.ts @@ -14,6 +14,7 @@ import { THROTTLING_LIMIT_GENERIC } from '@121-service/src/config'; import { CronjobModule } from '@121-service/src/cronjob/cronjob.module'; import { EmailsModule } from '@121-service/src/emails/emails.module'; import { FinancialServiceProviderCallbackJobProcessorsModule } from '@121-service/src/financial-service-provider-callback-job-processors/financial-service-provider-callback-job-processors.module'; +import { FinancialSyncModule } from '@121-service/src/financial-sync/financial-sync.module'; import { HealthModule } from '@121-service/src/health/health.module'; import { MetricsModule } from '@121-service/src/metrics/metrics.module'; import { NoteModule } from '@121-service/src/notes/notes.module'; @@ -43,6 +44,7 @@ import { TypeOrmModule } from '@121-service/src/typeorm.module'; MessageModule, MetricsModule, MessageIncomingModule, + FinancialSyncModule, 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 9fe01a076f..2a4d455a98 100644 --- a/services/121-service/src/cronjob/cronjob.service.ts +++ b/services/121-service/src/cronjob/cronjob.service.ts @@ -84,6 +84,17 @@ export class CronjobService { await this.httpService.post(url, {}, headers); } + @Cron(CronExpression.EVERY_DAY_AT_4AM, { + disabled: !shouldBeEnabled(process.env.CRON_NEDBANK_VOUCHERS), + }) + public async cronRetrieveAndUpdateNedbankVouchers(): 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..75fc539f43 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,23 @@ export const FINANCIAL_SERVICE_PROVIDER_SETTINGS: FinancialServiceProviderDto[] attributes: [], configurationProperties: [], }, + { + name: FinancialServiceProviders.nedbank, + integrationType: FinancialServiceProviderIntegrationType.api, + defaultLabel: { + en: 'Nedbank', + }, + notifyOnTransaction: false, + attributes: [ + { + name: FinancialServiceProviderAttributes.fullName, + isRequired: true, + }, + { + name: FinancialServiceProviderAttributes.nationalId, + isRequired: true, + }, + ], + configurationProperties: [], + }, ]; diff --git a/services/121-service/src/financial-sync/financial-sync-controller.ts b/services/121-service/src/financial-sync/financial-sync-controller.ts new file mode 100644 index 0000000000..9933c073eb --- /dev/null +++ b/services/121-service/src/financial-sync/financial-sync-controller.ts @@ -0,0 +1,39 @@ +import { Controller, HttpStatus, Patch } from '@nestjs/common'; +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; + +import { FinancialSyncService } from '@121-service/src/financial-sync/financial-sync-service'; +import { AuthenticatedUser } from '@121-service/src/guards/authenticated-user.decorator'; + +@Controller() +export class FinancialSyncController { + public constructor(private financialSyncService: FinancialSyncService) {} + @ApiTags('financial-service-providers/nedbank') + @AuthenticatedUser({ isAdmin: true }) + @ApiOperation({ + summary: + '[CRON] Retrieve and update Nedbank voucher and transaction statusses', + }) + @ApiResponse({ + status: HttpStatus.CREATED, + description: 'Cached unused vouchers', + }) + @Patch('financial-service-providers/nedbank') + public async syncNedbankVoucherAndTransactionStatusses(): Promise { + console.info( + 'CronjobService - Started: updateNedbankVoucherAndTransactionStatusses', + ); + this.financialSyncService + .syncNedbankVoucherAndTransactionStatusses() + .then(() => { + console.info( + 'CronjobService - Complete: updateNedbankVoucherAndTransactionStatusses', + ); + return; + }) + .catch((error) => { + throw new Error( + `CronjobService - Failed: updateNedbankVoucherAndTransactionStatusses - ${error}`, + ); + }); + } +} diff --git a/services/121-service/src/financial-sync/financial-sync-service.ts b/services/121-service/src/financial-sync/financial-sync-service.ts new file mode 100644 index 0000000000..4e2e233598 --- /dev/null +++ b/services/121-service/src/financial-sync/financial-sync-service.ts @@ -0,0 +1,72 @@ +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 { 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 { TransactionStatusEnum } from '@121-service/src/payments/transactions/enums/transaction-status.enum'; +import { TransactionScopedRepository } from '@121-service/src/payments/transactions/transaction.repository'; + +@Injectable() +export class FinancialSyncService { + public constructor( + private readonly nedbankService: NedbankService, + private readonly nedbankVoucherScopedRepository: NedbankVoucherScopedRepository, + private readonly transactionScopedRepository: TransactionScopedRepository, + ) {} + + public async syncNedbankVoucherAndTransactionStatusses(): Promise { + const vouchers = await this.nedbankVoucherScopedRepository.find({ + select: ['id', 'orderCreateReference', 'transactionId'], + where: [ + { status: IsNull() }, + { + status: Not( + In([NedbankVoucherStatus.REDEEMED, NedbankVoucherStatus.REFUNDED]), + ), + }, + ], + }); + + for (const voucher of vouchers) { + let voucherStatus: NedbankVoucherStatus; + try { + voucherStatus = + await this.nedbankService.retrieveAndUpdateVoucherStatus( + voucher.orderCreateReference, + voucher.id, + ); + } catch (error) { + // ##TODO what extend should end the loop if something goes wrong? + if (error instanceof NedbankError) { + console.error( + `Error while getting order for voucher ${voucher.id}: ${error.message}`, + ); + continue; + } else { + throw error; + } + } + + // ##TODO: Should the NedbankService know about the TransactionModule? + // It is the case in https://miro.com/app/board/uXjVLVYmSPM=/?moveToWidget=3458764603767347191&cot=14 however I am not sure about it + if (voucherStatus === NedbankVoucherStatus.REDEEMED) { + await this.transactionScopedRepository.update( + { id: voucher.transactionId }, + { status: TransactionStatusEnum.success }, + ); + } + if (voucherStatus === NedbankVoucherStatus.REFUNDED) { + await this.transactionScopedRepository.update( + { id: voucher.transactionId }, + { + status: TransactionStatusEnum.error, + errorMessage: + 'Voucher has been refunded by Nedbank. Please contact Nedbank support.', // TODO: is this the correct ux copy? + }, + ); + } + } + } +} diff --git a/services/121-service/src/financial-sync/financial-sync.module.ts b/services/121-service/src/financial-sync/financial-sync.module.ts new file mode 100644 index 0000000000..1ea3912da6 --- /dev/null +++ b/services/121-service/src/financial-sync/financial-sync.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; + +import { FinancialSyncController } from '@121-service/src/financial-sync/financial-sync-controller'; +import { FinancialSyncService } from '@121-service/src/financial-sync/financial-sync-service'; +import { NedbankModule } from '@121-service/src/payments/fsp-integration/nedbank/nedbank.module'; +import { TransactionsModule } from '@121-service/src/payments/transactions/transactions.module'; + +@Module({ + imports: [NedbankModule, TransactionsModule], + providers: [FinancialSyncService], + controllers: [FinancialSyncController], +}) +export class FinancialSyncModule {} diff --git a/services/121-service/src/metrics/metrics.service.ts b/services/121-service/src/metrics/metrics.service.ts index 7847616ceb..a1432745f7 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}`; 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,22 @@ 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', }, ], ]; 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..dc0463cb13 --- /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, "retrivalCount" integer NOT NULL DEFAULT '0', "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 cca169efc8..973175af1c 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'; @@ -25,21 +25,10 @@ import { GetTransactionInformationReturnType } from '@121-service/src/payments/f import { IssueTokenReturnType } from '@121-service/src/payments/fsp-integration/intersolve-visa/interfaces/issue-token-return-type.interface'; import { ContactInformation } from '@121-service/src/payments/fsp-integration/intersolve-visa/interfaces/partials/contact-information.interface'; import { IntersolveVisaApiError } from '@121-service/src/payments/fsp-integration/intersolve-visa/intersolve-visa-api.error'; +import { generateUUIDFromSeed } from '@121-service/src/payments/payments.helpers'; import { CustomHttpService } from '@121-service/src/shared/services/custom-http.service'; import { formatPhoneNumber } from '@121-service/src/utils/phone-number.helpers'; -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); -} - const intersolveVisaApiUrl = process.env.MOCK_INTERSOLVE ? `${process.env.MOCK_SERVICE_URL}api/fsp/intersolve-visa` : process.env.INTERSOLVE_VISA_API_URL; diff --git a/services/121-service/src/payments/fsp-integration/nedbank/__snapshots__/nedbank-api.service.spec.ts.snap b/services/121-service/src/payments/fsp-integration/nedbank/__snapshots__/nedbank-api.service.spec.ts.snap new file mode 100644 index 0000000000..4f132890bd --- /dev/null +++ b/services/121-service/src/payments/fsp-integration/nedbank/__snapshots__/nedbank-api.service.spec.ts.snap @@ -0,0 +1,5 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`NedbankApiService createOrder should throw an error if create order fails 1`] = `[NedbankError: 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[`NedbankApiService getOrder should throw an error if get order fails 1`] = `[NedbankError: Errors: Request Validation Error - TPP account configuration mismatch (Message: NB.APIM.Field.Invalid, Code: NB.APIM.Field.Invalid, Id: 1d3e3076-9e1c-4933-aa7f-69290941ec70)]`; diff --git a/services/121-service/src/payments/fsp-integration/nedbank/dto/nedbank-create-order-payload.dto.ts b/services/121-service/src/payments/fsp-integration/nedbank/dto/nedbank-create-order-payload.dto.ts new file mode 100644 index 0000000000..ebae699ba4 --- /dev/null +++ b/services/121-service/src/payments/fsp-integration/nedbank/dto/nedbank-create-order-payload.dto.ts @@ -0,0 +1,28 @@ +export interface NedbankCreateOrderPayloadDto { + 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; + }; +} diff --git a/services/121-service/src/payments/fsp-integration/nedbank/dto/nedbank-create-order-response.dto.ts b/services/121-service/src/payments/fsp-integration/nedbank/dto/nedbank-create-order-response.dto.ts new file mode 100644 index 0000000000..f4107db55f --- /dev/null +++ b/services/121-service/src/payments/fsp-integration/nedbank/dto/nedbank-create-order-response.dto.ts @@ -0,0 +1,15 @@ +import { NedbankVoucherStatus } from '@121-service/src/payments/fsp-integration/nedbank/enums/nedbank-voucher-status.enum'; + +// Named in line with https://apim.nedbank.co.za/static/docs/cashout-create-order +// I prefixed the names with Nedbank to avoid any confusion with other services (this is convienent with auto-import and finding files with cmd+p) +// ##TODO: Should we either also prefix interfaces in other FSPs integrations or remove the prefix from this one? +export interface NedbankCreateOrderResponseDto { + Data: { + OrderId: string; + Status: NedbankVoucherStatus; + }; + Links: { + Self: string; + }; + Meta: Record; +} diff --git a/services/121-service/src/payments/fsp-integration/nedbank/dto/nedbank-get-order-reponse.dto.ts b/services/121-service/src/payments/fsp-integration/nedbank/dto/nedbank-get-order-reponse.dto.ts new file mode 100644 index 0000000000..fc8b0143c2 --- /dev/null +++ b/services/121-service/src/payments/fsp-integration/nedbank/dto/nedbank-get-order-reponse.dto.ts @@ -0,0 +1,32 @@ +import { NedbankVoucherStatus } from '@121-service/src/payments/fsp-integration/nedbank/enums/nedbank-voucher-status.enum'; + +export interface NedbankGetOrderResponseDto { + Data: { + 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-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..bad0fa3936 --- /dev/null +++ b/services/121-service/src/payments/fsp-integration/nedbank/enums/nedbank-voucher-status.enum.ts @@ -0,0 +1,7 @@ +export enum NedbankVoucherStatus { + PENDING = 'PENDING', + PROCESSING = 'PROCESSING', + REDEEMABLE = 'REDEEMABLE', + REDEEMED = 'REDEEMED', + REFUNDED = 'REFUNDED', +} 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..340bd80138 --- /dev/null +++ b/services/121-service/src/payments/fsp-integration/nedbank/errors/nedbank.error.ts @@ -0,0 +1,9 @@ +// Usage: throw new NedbankError('Error message'); +// ##TODO: Disucss: I chose not to name it NedbankApiError because it's also used to throw errors in the NedbankService +export class NedbankError extends Error { + constructor(message?: string) { + super(message); + this.name = 'NedbankError'; + Object.setPrototypeOf(this, new.target.prototype); + } +} diff --git a/services/121-service/src/payments/fsp-integration/nedbank/interfaces/nedbank-create-order-params.ts b/services/121-service/src/payments/fsp-integration/nedbank/interfaces/nedbank-create-order-params.ts new file mode 100644 index 0000000000..2ced03dc15 --- /dev/null +++ b/services/121-service/src/payments/fsp-integration/nedbank/interfaces/nedbank-create-order-params.ts @@ -0,0 +1,6 @@ +export interface NedbankCreateOrderParams { + transferAmount: number; + fullName: string; + idNumber: string; + transactionReference: string; +} diff --git a/services/121-service/src/payments/fsp-integration/nedbank/interfaces/nedbank-create-order-return.ts b/services/121-service/src/payments/fsp-integration/nedbank/interfaces/nedbank-create-order-return.ts new file mode 100644 index 0000000000..bb25ebd855 --- /dev/null +++ b/services/121-service/src/payments/fsp-integration/nedbank/interfaces/nedbank-create-order-return.ts @@ -0,0 +1,6 @@ +import { NedbankVoucherStatus } from '@121-service/src/payments/fsp-integration/nedbank/enums/nedbank-voucher-status.enum'; + +export interface NedbankCreateOrderReturn { + orderCreateReference: string; + nedbankVoucherStatus: NedbankVoucherStatus; +} diff --git a/services/121-service/src/payments/fsp-integration/nedbank/interfaces/nedbank-error-reponse.ts b/services/121-service/src/payments/fsp-integration/nedbank/interfaces/nedbank-error-reponse.ts new file mode 100644 index 0000000000..e4fe60fe17 --- /dev/null +++ b/services/121-service/src/payments/fsp-integration/nedbank/interfaces/nedbank-error-reponse.ts @@ -0,0 +1,11 @@ +export interface NedbankErrorResponse { + 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/nedbank-api.service.spec.ts b/services/121-service/src/payments/fsp-integration/nedbank/nedbank-api.service.spec.ts new file mode 100644 index 0000000000..640bb51f15 --- /dev/null +++ b/services/121-service/src/payments/fsp-integration/nedbank/nedbank-api.service.spec.ts @@ -0,0 +1,255 @@ +import { AxiosResponse } from '@nestjs/terminus/dist/health-indicator/http/axios.interfaces'; +import { Test, TestingModule } from '@nestjs/testing'; + +import { NedbankCreateOrderResponseDto } from '@121-service/src/payments/fsp-integration/nedbank/dto/nedbank-create-order-response.dto'; +import { NedbankGetOrderResponseDto } from '@121-service/src/payments/fsp-integration/nedbank/dto/nedbank-get-order-reponse.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 { NedbankErrorResponse } from '@121-service/src/payments/fsp-integration/nedbank/interfaces/nedbank-error-reponse'; +import { NedbankApiService } from '@121-service/src/payments/fsp-integration/nedbank/nedbank-api.service'; +import { CustomHttpService } from '@121-service/src/shared/services/custom-http.service'; +import { registrationNedbank } from '@121-service/test/registrations/pagination/pagination-data'; + +const amount = 250; +const orderCreateReference = '123456'; // This is a uuid generated deterministically, so we can use a fixed value + +jest.mock('@121-service/src/shared/services/custom-http.service'); +jest.mock('@121-service/src/payments/payments.helpers'); + +describe('NedbankApiService', () => { + let service: NedbankApiService; + let httpService: CustomHttpService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [NedbankApiService, CustomHttpService], + }).compile(); + + service = module.get(NedbankApiService); + httpService = module.get(CustomHttpService); + }); + + describe('createOrder', () => { + it('should create an order successfully', async () => { + const response: AxiosResponse = { + data: { + Data: { + OrderId: '', + Status: NedbankVoucherStatus.PENDING, + }, + Links: { + Self: '', + }, + Meta: {}, + }, + status: 201, + statusText: 'OK', + headers: {}, + config: {}, + }; + + jest.spyOn(httpService, 'request').mockResolvedValue(response); + + const result = await service.createOrder({ + transferAmount: amount, + fullName: registrationNedbank.fullName, + idNumber: registrationNedbank.nationalId, + orderCreateReference, + }); + expect(result).toEqual(response.data); + + const requestCallArgs = (httpService.request as jest.Mock).mock.calls[0]; + const requestCallParamObject = requestCallArgs[0]; + + // Check the URL + expect(requestCallParamObject.url).toContain('v1/orders'); // ##TODO Should we check this here or just check if it is at least a string of len > 0? + + // Check the payload + expect(requestCallParamObject.payload).toMatchObject({ + // ##TODO: this code contains some hardcoded strings, like ('recipient'), which are also hardcode in the actual service. Should we extract them to constants or just check if they are present? + Data: { + Initiation: { + CreditorAccount: { + Identification: registrationNedbank.nationalId, + Name: registrationNedbank.fullName, + SchemeName: 'recipient', + SecondaryIdentification: '1', + }, + DebtorAccount: { + Identification: process.env.NEDBANK_ACCOUNT_NUMBER, + Name: 'MyRefOnceOffQATrx', + SchemeName: 'account', + SecondaryIdentification: '1', + }, + InstructedAmount: { + Amount: `${amount.toString()}.00`, + Currency: 'ZAR', + }, + InstructionIdentification: expect.any(String), // This is generated dynamically + }, + ExpirationDateTime: expect.any(String), // This is generated dynamically + }, + Risk: { + OrderCreateReference: orderCreateReference, + OrderDateTime: expect.any(String), // This is generated dynamically + }, + }); + + // Check the headers + expect(requestCallParamObject.headers).toEqual([ + { name: 'x-ibm-client-id', value: expect.any(String) }, + { name: 'x-ibm-client-secret', value: expect.any(String) }, + { 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.any(String) }, + { name: 'x-fapi-interaction-id', value: expect.any(String) }, + { name: 'Content-Type', value: 'application/json' }, + ]); + + expect(requestCallParamObject.method).toBe('POST'); + }); + + it('should throw an error if create order fails', 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, // Nedbank returns 201 even on errors + statusText: '', + headers: {}, + config: {}, + }; + + jest.spyOn(httpService, 'request').mockResolvedValue(errorResponse); + + await expect( + service.createOrder({ + transferAmount: amount, + fullName: registrationNedbank.fullName, + idNumber: registrationNedbank.nationalId, + orderCreateReference, + }), + ).rejects.toThrow(NedbankError); + + // TODO: Not sure if this is the best/prettiest syntax to test the content of the error but it works + let errorOnCreateOrder: NedbankError | undefined; + try { + await service.createOrder({ + transferAmount: amount, + fullName: registrationNedbank.fullName, + idNumber: registrationNedbank.nationalId, + orderCreateReference, + }); + } catch (error) { + errorOnCreateOrder = error; + } + expect(errorOnCreateOrder).toMatchSnapshot(); + }); + }); + + describe('getOrder', () => { + it('should get an order successfully', async () => { + const response: AxiosResponse = { + data: { + Data: { + Transactions: { + Voucher: { + Code: '', + Status: NedbankVoucherStatus.REDEEMED, + Redeem: { + Redeemable: true, + Redeemed: false, + RedeemedOn: '', + RedeemedAt: '', + }, + Refund: { + Refundable: true, + Refunded: false, + RefundedOn: '', + }, + Pin: '', + }, + PaymentReferenceNumber: '', + OrderCreateReference: orderCreateReference, + OrderDateTime: '', + OrderExpiry: '', + }, + }, + Links: { + Self: '', + }, + Meta: {}, + }, + status: 200, + statusText: 'OK', + headers: {}, + config: {}, + }; + jest.spyOn(httpService, 'request').mockResolvedValue(response); + + const result = await service.getOrder(orderCreateReference); + expect(result).toEqual(response.data); + + const requestCallArgs = (httpService.request as jest.Mock).mock.calls[0]; + + // Check the URL + expect(requestCallArgs[0].url).toContain(`orders/references/`); + + // Check the method + expect(requestCallArgs[0].method).toBe('GET'); + + // C// Do not check the header details as this is already done in the createOrder test + expect(requestCallArgs[0].headers).toEqual(expect.any(Object)); + }); + + it('should throw an error if get order fails', 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: 'Bad Request', + headers: {}, + config: {}, + }; + + jest.spyOn(httpService, 'request').mockResolvedValue(errorResponse); + + await expect(service.getOrder('orderCreateReference')).rejects.toThrow( + NedbankError, + ); + + // Check the error message + let errorOnGetOrder: NedbankError | undefined; + try { + await service.getOrder('orderCreateReference'); + } catch (error) { + errorOnGetOrder = error; + } + expect(errorOnGetOrder).toMatchSnapshot(); + }); + }); +}); diff --git a/services/121-service/src/payments/fsp-integration/nedbank/nedbank-api.service.ts b/services/121-service/src/payments/fsp-integration/nedbank/nedbank-api.service.ts new file mode 100644 index 0000000000..186efb20ee --- /dev/null +++ b/services/121-service/src/payments/fsp-integration/nedbank/nedbank-api.service.ts @@ -0,0 +1,227 @@ +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 { NedbankCreateOrderPayloadDto } from '@121-service/src/payments/fsp-integration/nedbank/dto/nedbank-create-order-payload.dto'; +import { NedbankCreateOrderResponseDto } from '@121-service/src/payments/fsp-integration/nedbank/dto/nedbank-create-order-response.dto'; +import { NedbankGetOrderResponseDto } from '@121-service/src/payments/fsp-integration/nedbank/dto/nedbank-get-order-reponse.dto'; +import { NedbankError } from '@121-service/src/payments/fsp-integration/nedbank/errors/nedbank.error'; +import { NedbankErrorResponse } from '@121-service/src/payments/fsp-integration/nedbank/interfaces/nedbank-error-reponse'; +import { createHttpsAgentWithCertificate } from '@121-service/src/payments/payments.helpers'; +import { + CustomHttpService, + Header, +} from '@121-service/src/shared/services/custom-http.service'; + +@Injectable() +export class NedbankApiService { + public httpsAgent: https.Agent; + + public constructor(private readonly httpService: CustomHttpService) { + this.createHttpsAgent(); + } + + public async createOrder({ + transferAmount, + fullName, + idNumber, + orderCreateReference, + }): Promise { + const payload = this.createOrderPayload({ + transferAmount, + fullName, + idNumber, + orderCreateReference, + }); + + const createOrderResponse = await this.makeCreateOrderCall(payload); + + return createOrderResponse.data; + } + + private createOrderPayload({ + transferAmount, + fullName, + idNumber, + orderCreateReference, + }): NedbankCreateOrderPayloadDto { + const currentDate = new Date(); + const expirationDate = new Date( + currentDate.getTime() + 7 * 24 * 60 * 60 * 1000, // 7 days from now + ).toISOString(); + + return { + Data: { + Initiation: { + InstructionIdentification: uuid().replace(/-/g, ''), // This should be a 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.' + SecondaryIdentification: '1', // Leaving this at '1' - This is described in the online documentation but not in the word we have from Nedbank. I assume it is not use like the other SecondaryIdentification. + }, + CreditorAccount: { + SchemeName: 'recipient', + Identification: idNumber, + Name: fullName, + SecondaryIdentification: '1', // Leaving this at '1' - Additional identification of recipient, like customer number. But not used anywhere at the moment. + }, + }, + ExpirationDateTime: expirationDate, + }, + Risk: { + OrderCreateReference: orderCreateReference, + // OrderCreateReference: uuid(), + OrderDateTime: new Date().toISOString().split('T')[0], // This needs to be set to yyyy-mm-dd + }, + }; + } + + private async makeCreateOrderCall( + payload: NedbankCreateOrderPayloadDto, + ): 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.nedbankApiRequestOrThrow( + createOrderUrl, + 'POST', + payload, + ); + } + + public async getOrder( + orderCreateReference: string, + ): Promise { + const response = await this.makeGetOrderCall(orderCreateReference); + return response.data; + } + + private async makeGetOrderCall( + 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}`; + + return this.nedbankApiRequestOrThrow( + getOrderUrl, + 'GET', + null, + ); + } + + private async nedbankApiRequestOrThrow( + url: string, + method: 'POST' | 'GET', + payload: unknown, + ): Promise> { + const headers = this.createHeaders(); + + let response: AxiosResponse; + try { + response = await this.httpService.request>({ + method, + url, + payload, + headers, + httpsAgent: this.httpsAgent, + }); + } catch (error) { + console.error(`Failed to make Nedbank ${method} call`, error); + throw new NedbankError(`Error: ${error.message}`); + } + if (this.isNedbankErrorResponse(response.data)) { + const errorMessage = this.formatError(response.data); + throw new NedbankError(errorMessage); + } + return response; + } + + 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', // ##TODO: From the comments in the Nedbank API documenetation word file it's now completely clear what this should be. I assume based on this convo that we use OrderCreateReference as 'idempotency' key and I therefore set this thing randomly + 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: process.env.PUBLIC_IP! }, + { 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 | NedbankErrorResponse, + ): response is NedbankErrorResponse { + return (response as NedbankErrorResponse).Errors !== undefined; + } + + private formatError(responseBody: NedbankErrorResponse | 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.Code}`); + } + + 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; + } + + private createHttpsAgent() { + if (this.httpsAgent) { + return; + } + // ##TODO is there a smart way to throw an error here if the .env is not set but the client does want to use nedbank? Or is that overengineering? + if ( + !process.env.NEDBANK_CERTIFICATE_PATH || + !process.env.NEDBANK_CERTIFICATE_PASSWORD + ) { + return; + } + this.httpsAgent = createHttpsAgentWithCertificate( + process.env.NEDBANK_CERTIFICATE_PATH!, + process.env.NEDBANK_CERTIFICATE_PASSWORD!, + ); + } +} 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..ced1858b4f --- /dev/null +++ b/services/121-service/src/payments/fsp-integration/nedbank/nedbank-voucher.entity.ts @@ -0,0 +1,25 @@ +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; + + @Column({ type: 'integer', default: 0 }) + public retrivalCount: number; + + @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..3af7c94645 --- /dev/null +++ b/services/121-service/src/payments/fsp-integration/nedbank/nedbank.module.ts @@ -0,0 +1,29 @@ +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 { NedbankApiService } from '@121-service/src/payments/fsp-integration/nedbank/nedbank-api.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 { RedisModule } from '@121-service/src/payments/redis/redis.module'; +import { TransactionEntity } from '@121-service/src/payments/transactions/transaction.entity'; +import { QueuesRegistryModule } from '@121-service/src/queues-registry/queues-registry.module'; +import { CustomHttpService } from '@121-service/src/shared/services/custom-http.service'; + +@Module({ + imports: [ + HttpModule, + TypeOrmModule.forFeature([NedbankVoucherEntity, TransactionEntity]), + RedisModule, + QueuesRegistryModule, + ], + providers: [ + NedbankService, + NedbankApiService, + CustomHttpService, + NedbankVoucherScopedRepository, + ], + 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..aa29acfbcf --- /dev/null +++ b/services/121-service/src/payments/fsp-integration/nedbank/nedbank.service.spec.ts @@ -0,0 +1,191 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { UpdateResult } from 'typeorm'; + +import { NedbankCreateOrderResponseDto } from '@121-service/src/payments/fsp-integration/nedbank/dto/nedbank-create-order-response.dto'; +import { NedbankGetOrderResponseDto } from '@121-service/src/payments/fsp-integration/nedbank/dto/nedbank-get-order-reponse.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 { NedbankService } from '@121-service/src/payments/fsp-integration/nedbank/nedbank.service'; +import { NedbankApiService } from '@121-service/src/payments/fsp-integration/nedbank/nedbank-api.service'; +import { NedbankVoucherScopedRepository } from '@121-service/src/payments/fsp-integration/nedbank/repositories/nedbank-voucher.scoped.repository'; +import { generateUUIDFromSeed } from '@121-service/src/payments/payments.helpers'; +import { registrationNedbank } from '@121-service/test/registrations/pagination/pagination-data'; + +const transactionReference = 'transaction123'; + +jest.mock('./nedbank-api.service'); +jest.mock('./repositories/nedbank-voucher.scoped.repository'); +jest.mock('@121-service/src/payments/payments.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(), + getOrder: jest.fn(), + }, + }, + { + provide: NedbankVoucherScopedRepository, + useValue: { + update: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(NedbankService); + apiService = module.get(NedbankApiService); + voucherRepository = module.get( + NedbankVoucherScopedRepository, + ); + }); + + describe('createOrder', () => { + it('should create an order successfully', async () => { + const amount = 200; + + const mockUUID = '12345678901234567890123456789012'; // Mock UUID + (generateUUIDFromSeed as jest.Mock).mockReturnValue(mockUUID); + + const response: NedbankCreateOrderResponseDto = { + Data: { + OrderId: 'orderId', + Status: NedbankVoucherStatus.PENDING, + }, + Links: { + Self: '', + }, + Meta: {}, + }; + + jest.spyOn(apiService, 'createOrder').mockResolvedValue(response); + + const result = await service.createOrder({ + transferAmount: amount, + fullName: registrationNedbank.fullName, + idNumber: registrationNedbank.nationalId, + transactionReference, + }); + + expect(result).toEqual({ + orderCreateReference: mockUUID.replace(/^(.{14})5/, '$14'), + nedbankVoucherStatus: NedbankVoucherStatus.PENDING, + }); + + expect(apiService.createOrder).toHaveBeenCalledWith({ + transferAmount: amount, + fullName: registrationNedbank.fullName, + idNumber: registrationNedbank.nationalId, + orderCreateReference: mockUUID.replace(/^(.{14})5/, '$14'), + }); + }); + + it('should throw an error if amount is not a multiple of 10', async () => { + const amount = 25; + await expect( + service.createOrder({ + transferAmount: amount, // Not a multiple of 10 + fullName: registrationNedbank.fullName, + idNumber: registrationNedbank.nationalId, + transactionReference, + }), + ).rejects.toThrow(NedbankError); + + await expect( + service.createOrder({ + transferAmount: amount, // Not a multiple of 10 + fullName: registrationNedbank.fullName, + idNumber: registrationNedbank.nationalId, + transactionReference, + }), + ).rejects.toThrow('Amount must be a multiple of 10'); + + expect(apiService.createOrder).not.toHaveBeenCalled(); + }); + + it('should throw an error if amount exceeds the maximum limit', async () => { + await expect( + service.createOrder({ + transferAmount: 6000, // Exceeds the maximum limit + fullName: registrationNedbank.fullName, + idNumber: registrationNedbank.nationalId, + transactionReference, + }), + ).rejects.toThrow(NedbankError); + + await expect( + service.createOrder({ + transferAmount: 6000, // Exceeds the maximum limit + fullName: registrationNedbank.fullName, + idNumber: registrationNedbank.nationalId, + transactionReference, + }), + ).rejects.toThrow('Amount must be equal or less than 5000, got 6000'); + expect(apiService.createOrder).not.toHaveBeenCalled(); + }); + }); + + describe('retrieveAndUpdateVoucherStatus', () => { + const getOrderResponse: NedbankGetOrderResponseDto = { + Data: { + Transactions: { + Voucher: { + Code: '', + Status: NedbankVoucherStatus.REDEEMABLE, + Redeem: { + Redeemable: true, + Redeemed: false, + RedeemedOn: '', + RedeemedAt: '', + }, + Refund: { + Refundable: true, + Refunded: false, + RefundedOn: '', + }, + Pin: '', + }, + PaymentReferenceNumber: '', + OrderCreateReference: '', + OrderDateTime: '', + OrderExpiry: '', + }, + }, + Links: { + Self: '', + }, + Meta: {}, + }; + + it('should retrieve and update voucher status successfully', async () => { + const orderCreateReference = 'orderCreateReference'; + const voucherId = 1; + + jest.spyOn(apiService, 'getOrder').mockResolvedValue(getOrderResponse); + jest + .spyOn(voucherRepository, 'update') + .mockResolvedValue({} as UpdateResult); + + const result = await service.retrieveAndUpdateVoucherStatus( + orderCreateReference, + voucherId, + ); + + expect(result).toBe(NedbankVoucherStatus.REDEEMABLE); + expect(apiService.getOrder).toHaveBeenCalledWith(orderCreateReference); + expect(voucherRepository.update).toHaveBeenCalledWith( + { id: voucherId }, + { 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..c6fb85e2b9 --- /dev/null +++ b/services/121-service/src/payments/fsp-integration/nedbank/nedbank.service.ts @@ -0,0 +1,115 @@ +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 { NedbankVoucherStatus } from '@121-service/src/payments/fsp-integration/nedbank/enums/nedbank-voucher-status.enum'; +import { NedbankError as NedbankError } from '@121-service/src/payments/fsp-integration/nedbank/errors/nedbank.error'; +import { NedbankCreateOrderParams } from '@121-service/src/payments/fsp-integration/nedbank/interfaces/nedbank-create-order-params'; +import { NedbankCreateOrderReturn } from '@121-service/src/payments/fsp-integration/nedbank/interfaces/nedbank-create-order-return'; +import { NedbankApiService } from '@121-service/src/payments/fsp-integration/nedbank/nedbank-api.service'; +import { NedbankVoucherScopedRepository } from '@121-service/src/payments/fsp-integration/nedbank/repositories/nedbank-voucher.scoped.repository'; +import { generateUUIDFromSeed } from '@121-service/src/payments/payments.helpers'; + +@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 createOrder({ + transferAmount, + fullName, + idNumber, + transactionReference, + }: NedbankCreateOrderParams): Promise { + const isAmountMultipleOf10 = + this.isCashoutAmountMultipleOf10(transferAmount); + if (!isAmountMultipleOf10) { + throw new NedbankError('Amount must be a multiple of 10'); + } + const maxAmount = 5000; + if (transferAmount >= maxAmount) { + // ##TODO: If check here for 5000 and Nebank changes the max amount we need to change it here as well + // How often would it happen that user try to cashout more than 5000? So how valuable is it that we maintain this check? + throw new NedbankError( + `Amount must be equal or less than ${maxAmount}, got ${transferAmount}`, + ); + } + + // ##TODO Find a way to properly cover this with a test to ensure this keeps working if we refactor transactions to have one per payment + // I wanted to use a deterministic UUID for the orderCreateReference (so use uuid v5 instead of v4) + // However nedbank has a check in place to check if the orderCreateReference has 4 in the 15th position (which is uuid v4) + // So I made some code to replace the 5 with a 4 which does work but feels a bit hacky + // No sure if this is the best way to go about it + // However it seems very useful to have a deterministic UUID for the orderCreateReference so you can gerenate the same orderCreateReference for the same transaction + // So if for some reason you trigger the same payment twice the second time you can just use the same orderCreateReference + const orderCreateReference = generateUUIDFromSeed( + transactionReference, + ).replace(/^(.{14})5/, '$14'); + + const cashoutResult = await this.nedbankApiService.createOrder({ + transferAmount, + fullName, + idNumber, + orderCreateReference, + }); + return { + orderCreateReference, + nedbankVoucherStatus: cashoutResult.Data.Status, + }; + } + + public async storeVoucher({ + // Should this function live in the repository only? + orderCreateReference, + voucherStatus, + transactionId, + }: { + orderCreateReference: string; + voucherStatus: NedbankVoucherStatus; + transactionId: number; + }): Promise { + const nedbankVoucherEntity = this.nedbankVoucherScopedRepository.create({ + orderCreateReference, + status: voucherStatus, + transactionId, + }); + await this.nedbankVoucherScopedRepository.save(nedbankVoucherEntity); + } + + public async retrieveAndUpdateVoucherStatus( + orderCreateReference: string, + voucherId: number, + ): Promise { + const getOrderReponseBody = + await this.nedbankApiService.getOrder(orderCreateReference); + + // ##TODO: How to mock if another response comes back than status REDEEMABLE from the sandbox? + const voucherResponse = getOrderReponseBody.Data.Transactions.Voucher; + + await this.nedbankVoucherScopedRepository.update( + { id: voucherId }, + { status: voucherResponse.Status }, + ); + return voucherResponse.Status; + } + + private isCashoutAmountMultipleOf10(amount: number): boolean { + return amount % 10 === 0; + } +} 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..eee979f147 --- /dev/null +++ b/services/121-service/src/payments/fsp-integration/nedbank/repositories/nedbank-voucher.scoped.repository.ts @@ -0,0 +1,18 @@ +import { Inject } from '@nestjs/common'; +import { REQUEST } from '@nestjs/core'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +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); + } +} diff --git a/services/121-service/src/payments/payments.helpers.ts b/services/121-service/src/payments/payments.helpers.ts new file mode 100644 index 0000000000..82096516b1 --- /dev/null +++ b/services/121-service/src/payments/payments.helpers.ts @@ -0,0 +1,30 @@ +import fs from 'fs'; +import * as https from 'https'; +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); +} + +/** + * 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. + */ +export function createHttpsAgentWithCertificate( + certificatePath: string, + password: string, +): https.Agent { + return new https.Agent({ + pfx: fs.readFileSync(certificatePath), + passphrase: password, + }); +} 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..5a97acaf5b 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( @@ -861,6 +882,71 @@ 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, + fullName: + registrationView[FinancialServiceProviderAttributes.fullName]!, + idNumber: + registrationView[FinancialServiceProviderAttributes.nationalId]!, + }; + }); + 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..f5e80713d7 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 { + // ***** CREATE PROGRAM ***** + const programEntitySarcNedbank = await this.seedHelper.addProgram( + programSarcNedbank, + isApiTests, + ); + + // ***** CREATE MESSAGE TEMPLATES ***** + await this.seedHelper.addMessageTemplates( + messageTemplateBaringo, + programEntitySarcNedbank, + ); + + // ***** ASSIGN AIDWORKER TO PROGRAM WITH ROLES ***** + await this.seedHelper.addDefaultUsers(programEntitySarcNedbank); + + // ***** CREATE ORGANIZATION ***** + // Technically multiple organizations could be loaded, but that should not be done + await this.seedHelper.addOrganization(organizationSARCS); + } +} diff --git a/services/121-service/src/scripts/seed-script.enum.ts b/services/121-service/src/scripts/seed-script.enum.ts index 4e6816549b..fe68420d72 100644 --- a/services/121-service/src/scripts/seed-script.enum.ts +++ b/services/121-service/src/scripts/seed-script.enum.ts @@ -8,5 +8,6 @@ export enum SeedScript { nlrcMultipleMock = 'nlrc-multiple-mock-data', ethJointResponse = 'eth-joint-response', krcsMultiple = 'krcs-multiple', + sarcsNedbank = 'sarcs-nedbank', oneAdmin = 'one-admin', } diff --git a/services/121-service/src/seed-data/message-template/message-template-sarcs.json b/services/121-service/src/seed-data/message-template/message-template-sarcs.json new file mode 100644 index 0000000000..6a48bfbd73 --- /dev/null +++ b/services/121-service/src/seed-data/message-template/message-template-sarcs.json @@ -0,0 +1,9 @@ +{ + "registered": { + "isSendMessageTemplate": false, + "isWhatsappTemplate": false, + "message": { + "en": "This is a message from SARCS.\n\nThank you for your registration. We will inform you later if you are included in this project or not." + } + } +} 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-sarc-nedbank.json b/services/121-service/src/seed-data/program/program-sarc-nedbank.json new file mode 100644 index 0000000000..f054d5729b --- /dev/null +++ b/services/121-service/src/seed-data/program/program-sarc-nedbank.json @@ -0,0 +1,61 @@ +{ + "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": "nationalId", + "label": { + "en": "National ID" + }, + "type": "text", + "pattern": null, + "options": null, + "export": ["all-people-affected", "included"], + "scoring": {}, + "editableInPortal": true, + "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.ts b/services/121-service/src/shared/services/custom-http.service.ts index 8d3a03e3ab..a4408e461e 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,12 @@ 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 * 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 +135,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 +227,9 @@ 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/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..ba427228a8 --- /dev/null +++ b/services/121-service/src/transaction-job-processors/processors/transaction-job-nedbank.processor.spec.ts @@ -0,0 +1,84 @@ +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, + fullName: registrationNedbank.fullName, + idNumber: registrationNedbank.nationalId, + programFinancialServiceProviderConfigurationId: 1, +}; +const testJob = { data: mockPaymentJob } as Job; + +describe('TransactionJobProcessorNedbank', () => { + let transactionJobProcessorsService: jest.Mocked; + let paymentProcessor: 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(); + + paymentProcessor = 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 paymentProcessor.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( + paymentProcessor.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..8bfdaa27a8 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 { NedbankCreateOrderReturn } from '@121-service/src/payments/fsp-integration/nedbank/interfaces/nedbank-create-order-return'; +import { NedbankService } from '@121-service/src/payments/fsp-integration/nedbank/nedbank.service'; 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,13 @@ 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; beforeEach(async () => { const { unit, unitRef } = TestBed.create( @@ -70,26 +70,20 @@ 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(); - }); - it('[Idempotency] safaricom transaction job processing should fail when using same originatorConversationId', async () => { jest .spyOn(registrationScopedRepository, 'getByReferenceId') .mockResolvedValueOnce(mockedRegistration); @@ -104,8 +98,21 @@ describe('TransactionJobProcessorsService', () => { jest .spyOn(transactionScopedRepository, 'save') - .mockResolvedValueOnce([mockedTransaction]); + .mockResolvedValueOnce({ id: mockedTransactionId } as any); + jest + .spyOn( + transactionScopedRepository, + 'getFailedTransactionAttemptsForPaymentAndRegistration', + ) + .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 +131,107 @@ 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, }), ); }); + + describe('Nedbank transaction job processing', () => { + const mockedNedbankTransactionJob: NedbankTransactionJobDto = { + programId: 3, + paymentNumber: 3, + referenceId: registrationNedbank.referenceId, + transactionAmount: 25, + isRetry: false, + userId: 1, + bulkSize: 10, + idNumber: registrationNedbank.nationalId, + fullName: registrationNedbank.fullName, + programFinancialServiceProviderConfigurationId: 1, + }; + + const mockedCreateOrderReturn: NedbankCreateOrderReturn = { + orderCreateReference: 'orderCreateReference', + nedbankVoucherStatus: NedbankVoucherStatus.PENDING, + }; + + it('should process Nedbank transaction job successfully', async () => { + jest + .spyOn(registrationScopedRepository, 'getByReferenceId') + .mockResolvedValueOnce(mockedRegistration); + + jest + .spyOn(nedbankService, 'createOrder') + .mockResolvedValueOnce(mockedCreateOrderReturn); + + jest + .spyOn(nedbankService, 'storeVoucher') + .mockResolvedValueOnce(undefined); + + await transactionJobProcessorsService.processNedbankTransactionJob( + mockedNedbankTransactionJob, + ); + + expect( + registrationScopedRepository.getByReferenceId, + ).toHaveBeenCalledWith({ + referenceId: mockedNedbankTransactionJob.referenceId, + }); + expect( + transactionScopedRepository.getFailedTransactionAttemptsForPaymentAndRegistration, + ).toHaveBeenCalledWith({ + registrationId: mockedRegistration.id, + payment: mockedNedbankTransactionJob.paymentNumber, + }); + expect(nedbankService.createOrder).toHaveBeenCalledWith({ + transferAmount: mockedNedbankTransactionJob.transactionAmount, + fullName: mockedNedbankTransactionJob.fullName, + idNumber: mockedNedbankTransactionJob.idNumber, + transactionReference: `ReferenceId=${mockedNedbankTransactionJob.referenceId},PaymentNumber=${mockedSafaricomTransactionJob.paymentNumber},Attempt=0`, + }); + + expect(nedbankService.storeVoucher).toHaveBeenCalledWith({ + transactionId: mockedTransactionId, + orderCreateReference: mockedCreateOrderReturn.orderCreateReference, + voucherStatus: mockedCreateOrderReturn.nedbankVoucherStatus, + }); + }); + + it('should handle NedbankError when creating order', async () => { + jest + .spyOn(registrationScopedRepository, 'getByReferenceId') + .mockResolvedValueOnce(mockedRegistration); + + const nedbankError = new NedbankError('Nedbank error occurred'); + jest + .spyOn(nedbankService, 'createOrder') + .mockRejectedValueOnce(nedbankError); + + await transactionJobProcessorsService.processNedbankTransactionJob( + mockedNedbankTransactionJob, + ); + + expect(transactionScopedRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + payment: mockedNedbankTransactionJob.paymentNumber, + status: TransactionStatusEnum.error, + errorMessage: nedbankError.message, + }), + ); + // Store voucher is not called + expect(nedbankService.storeVoucher).not.toHaveBeenCalled(); + }); + }); }); 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..602feeb968 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,4 +1,4 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { Equal } from 'typeorm'; import { EventsService } from '@121-service/src/events/events.service'; @@ -11,6 +11,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 { NedbankCreateOrderReturn } from '@121-service/src/payments/fsp-integration/nedbank/interfaces/nedbank-create-order-return'; +import { NedbankService } from '@121-service/src/payments/fsp-integration/nedbank/nedbank.service'; 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 +22,23 @@ 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'; interface ProcessTransactionResultInput { programId: number; paymentNumber: number; userId: number; - calculatedTransferAmountInMajorUnit: number; + transferAmountInMajorUnit: number; programFinancialServiceProviderConfigurationId: number; registration: RegistrationEntity; oldRegistration: RegistrationEntity; @@ -49,13 +52,13 @@ 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 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 +85,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 +151,7 @@ export class TransactionJobProcessorsService { programId: input.programId, paymentNumber: input.paymentNumber, userId: input.userId, - calculatedTransferAmountInMajorUnit: transferAmountInMajorUnit, + transferAmountInMajorUnit, programFinancialServiceProviderConfigurationId: input.programFinancialServiceProviderConfigurationId, registration, @@ -185,7 +187,7 @@ export class TransactionJobProcessorsService { programId: input.programId, paymentNumber: input.paymentNumber, userId: input.userId, - calculatedTransferAmountInMajorUnit: + transferAmountInMajorUnit: intersolveVisaDoTransferOrIssueCardReturnDto.amountTransferredInMajorUnit, programFinancialServiceProviderConfigurationId: input.programFinancialServiceProviderConfigurationId, @@ -221,7 +223,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 +273,78 @@ 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 registration to log changes to it later in event table + const registration = await this.getRegistrationOrThrow( + transactionJob.referenceId, + ); + const oldRegistration = structuredClone(registration); + + // 2. Get number of failed transaction to gerenerate the transaction reference + const failedTransactionsCount = + await this.transactionScopedRepository.getFailedTransactionAttemptsForPaymentAndRegistration( + { + registrationId: registration.id, + payment: transactionJob.paymentNumber, + }, + ); + + let createOrderReturn: NedbankCreateOrderReturn; + try { + createOrderReturn = await this.nedbankService.createOrder({ + transferAmount: transactionJob.transactionAmount, + fullName: transactionJob.fullName, + idNumber: transactionJob.idNumber, + transactionReference: `ReferenceId=${transactionJob.referenceId},PaymentNumber=${transactionJob.paymentNumber},Attempt=${failedTransactionsCount}`, // ##TODO Should we start from 1 or 0? + }); + } catch (error) { + if (error instanceof NedbankError) { + 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.error, + errorText: error?.message, + }); + + return; + } else { + throw error; + } + } + + // 3. Store the transactions + 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, // This will only go to 'success' nightly cronjob + }); + + // 4. Store the nedbank voucher + // ##TODO discuss: We could also store the voucher in the createOrder function + // However I think it makes more sense to store it after the transaction is created in the database so we can link the voucher to the transaction with an non-nullable foreign key + await this.nedbankService.storeVoucher({ + transactionId: transaction.id, + orderCreateReference: createOrderReturn.orderCreateReference, + voucherStatus: createOrderReturn.nedbankVoucherStatus, + }); + } + private async getRegistrationOrThrow( referenceId: string, ): Promise { @@ -290,7 +364,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..55d1d44f32 --- /dev/null +++ b/services/121-service/src/transaction-queues/dto/nedbank-transaction-job.dto.ts @@ -0,0 +1,13 @@ +// 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 fullName: string; + readonly idNumber: 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/swagger.json b/services/121-service/swagger.json index 85c4ac7fc9..88db1f485d 100644 --- a/services/121-service/swagger.json +++ b/services/121-service/swagger.json @@ -23,17 +23,13 @@ { "method": "put", "path": "/api/roles/{userRoleId}", - "params": [ - "userRoleId" - ], + "params": ["userRoleId"], "returnType": "UserRoleResponseDTO" }, { "method": "delete", "path": "/api/roles/{userRoleId}", - "params": [ - "userRoleId" - ], + "params": ["userRoleId"], "returnType": "UserRoleResponseDTO" }, { @@ -70,17 +66,13 @@ { "method": "delete", "path": "/api/users/{userId}", - "params": [ - "userId" - ], + "params": ["userId"], "returnType": "UserEntity" }, { "method": "patch", "path": "/api/users/{userId}", - "params": [ - "userId" - ] + "params": ["userId"] }, { "method": "get", @@ -90,53 +82,36 @@ { "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", @@ -154,9 +129,7 @@ { "method": "post", "path": "/api/scripts/duplicate-registrations", - "params": [ - "mockPowerNumberRegistrations" - ] + "params": ["mockPowerNumberRegistrations"] }, { "method": "post", @@ -166,93 +139,62 @@ { "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", @@ -267,25 +209,18 @@ { "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" }, { @@ -297,94 +232,58 @@ { "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", @@ -415,55 +314,37 @@ { "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", @@ -527,37 +408,27 @@ { "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", @@ -595,9 +466,7 @@ { "method": "patch", "path": "/api/programs/{programId}/registrations", - "params": [ - "programId" - ] + "params": ["programId"] }, { "method": "delete", @@ -637,9 +506,7 @@ { "method": "get", "path": "/api/programs/{programId}/registrations/import/template", - "params": [ - "programId" - ] + "params": ["programId"] }, { "method": "patch", @@ -679,17 +546,12 @@ { "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", @@ -729,77 +591,49 @@ { "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", @@ -839,48 +673,33 @@ { "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", @@ -895,17 +714,13 @@ { "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", @@ -927,28 +742,24 @@ "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/utility.helper.ts b/services/121-service/test/helpers/utility.helper.ts index 4a4e1bcaa3..8794c94fe3 100644 --- a/services/121-service/test/helpers/utility.helper.ts +++ b/services/121-service/test/helpers/utility.helper.ts @@ -92,6 +92,13 @@ export async function getAccessTokenCvaManager(): Promise { ); } +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..b64e02e07c --- /dev/null +++ b/services/121-service/test/payment/__snapshots__/do-payment-fsp-nedbank.test.ts.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Do payment to 1 PA with FSP: Nedbank 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)"`; 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..e7ead4e85e --- /dev/null +++ b/services/121-service/test/payment/do-payment-fsp-nedbank.test.ts @@ -0,0 +1,192 @@ +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 { SeedScript } from '@121-service/src/scripts/seed-script.enum'; +import { adminOwnerDto } from '@121-service/test/fixtures/user-owner'; +import { + doPayment, + exportList, + getTransactions, + waitForPaymentTransactionsToComplete, +} from '@121-service/test/helpers/program.helper'; +import { seedIncludedRegistrations } 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; + +describe('Do payment to 1 PA', () => { + describe('with FSP: Nedbank', () => { + let accessToken: string; + + beforeEach(async () => { + await resetDB(SeedScript.sarcsNedbank); + accessToken = await getAccessToken(); + }); + + 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 getTransactionsBodyBeforeCronjob = ( + await getTransactions( + programId, + payment, + registrationNedbank.referenceId, + accessToken, + ) + ).body; + + // Cronjob should update the status of the transaction + await runCronjobUpdateNedbankVoucherStatus(); + await waitForPaymentTransactionsToComplete( + programId, + paymentReferenceIds, + accessToken, + 6_000, + [TransactionStatusEnum.success, TransactionStatusEnum.error], + ); + + const getTransactionsBodyAfterCronjob = ( + await getTransactions( + programId, + payment, + registrationNedbank.referenceId, + accessToken, + ) + ).body; + + const exportPayment = await exportList({ + programId, + exportType: ExportType.payment, + accessToken, + options: { + minPayment: 0, + maxPayment: 1, + }, + }); + + // 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(getTransactionsBodyBeforeCronjob[0].status).toBe( + TransactionStatusEnum.waiting, + ); + expect(getTransactionsBodyBeforeCronjob[0].errorMessage).toBe(null); + expect(getTransactionsBodyBeforeCronjob[0].user).toMatchObject( + adminOwnerDto, + ); + expect(getTransactionsBodyAfterCronjob[0].status).toBe( + TransactionStatusEnum.success, + ); + expect(exportPayment.body.data[0].nedbankVoucherStatus).toBe( + NedbankVoucherStatus.REDEEMED, + ); + }); + + it('should fail pay-out when debitor account number is missing', async () => { + const registrationFailDebitorAccount = { + ...registrationNedbank, + fullName: 'failDebitorAccountIncorrect', + }; + const paymentReferenceIds = [registrationFailDebitorAccount.referenceId]; + await seedIncludedRegistrations( + [registrationFailDebitorAccount], + 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 getTransactionsBodyBeforeCronjob = ( + await getTransactions( + programId, + payment, + registrationFailDebitorAccount.referenceId, + accessToken, + ) + ).body; + + // 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( + registrationFailDebitorAccount.paymentAmountMultiplier, + ); + expect(getTransactionsBodyBeforeCronjob[0].status).toBe( + TransactionStatusEnum.error, + ); + expect( + getTransactionsBodyBeforeCronjob[0].errorMessage, + ).toMatchSnapshot(); + }); + }); +}); diff --git a/services/121-service/test/registrations/pagination/pagination-data.ts b/services/121-service/test/registrations/pagination/pagination-data.ts index cba98848d7..5bc0c9490b 100644 --- a/services/121-service/test/registrations/pagination/pagination-data.ts +++ b/services/121-service/test/registrations/pagination/pagination-data.ts @@ -439,3 +439,15 @@ export const registrationsPvExcel = [ registrationPvExcel3, registrationPvExcel4, ]; + +export const registrationNedbank = { + referenceId: 'registration-nedbank-1', + phoneNumber: '14155238886', + preferredLanguage: LanguageEnum.en, + paymentAmountMultiplier: 1, + programFinancialServiceProviderConfigurationName: + FinancialServiceProviders.nedbank, + maxPayments: 3, + fullName: 'ANDUALEM MOHAMMED YIMER', + nationalId: '39231855170', +}; 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 5a0ae98140..24a6a108fc 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/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 588abf1211..eaf07c15a3 100644 --- a/services/mock-service/src/app.module.ts +++ b/services/mock-service/src/app.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; 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'; @@ -13,6 +14,7 @@ import { TwilioModule } from '@mock-service/src/twilio/twilio.module'; SafaricomMockModule, ResetModule, IntersolveVisaMockModule, + 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..37a4f7c891 --- /dev/null +++ b/services/mock-service/src/fsp-integration/nedbank/nedbank.mock.service.ts @@ -0,0 +1,79 @@ +import { 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', +} + +enum MockFailScenarios { + failDebitorAccountIncorrect = 'failDebitorAccountIncorrect', +} + +@Injectable() +export class NedbankMockService { + public async getOrder(orderCreateReference: string): Promise { + // 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.Name.includes( + MockFailScenarios.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: '', + }, + ], + }; + } + return { + Data: { + OrderStatus: NedbankVoucherStatus.PENDING, + }, + }; + } +}