From a417661b19a1c54829df98049b7313008d2731c9 Mon Sep 17 00:00:00 2001 From: Bennett Benedict Date: Tue, 11 Mar 2025 12:53:54 +0300 Subject: [PATCH 1/2] release(mno): Log mno checkouts on fail and success --- .../src/modules/azampay/services/azam.service.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/apps/mno/src/modules/azampay/services/azam.service.ts b/apps/mno/src/modules/azampay/services/azam.service.ts index 3cb1a75..d4a8311 100644 --- a/apps/mno/src/modules/azampay/services/azam.service.ts +++ b/apps/mno/src/modules/azampay/services/azam.service.ts @@ -36,7 +36,7 @@ export class AzamService { const token = await azampay.getToken(this.getTokenPayload); Logger.debug(`REQUESTING CHECKOUT VIA [${this.getTokenPayload.env}]`); if (token.success) { - return await this.getMnoCheckout(payload, token); + return (await this.getMnoCheckout(payload, token)) as CheckoutResponse; } return token as ErrorResponse; }; @@ -120,12 +120,20 @@ export class AzamService { payload.checkout as MnoCheckout, payload.options, ); - console.log(mnoCheckout); - if (mnoCheckout.success) { + if (mnoCheckout?.success) { + Logger.debug( + `MNO CHECKOUT SUCCESSFULL: ${mnoCheckout.message ?? mnoCheckout.msg}`, + 'MNO CHECKOUT', + ); return mnoCheckout; } + Logger.debug( + `MNO CHECKOUT FAILED: ${mnoCheckout?.message ?? mnoCheckout?.msg}`, + 'MNO CHECKOUT', + ); + console.log(mnoCheckout); return { - ...mnoCheckout, + ...(mnoCheckout ?? {}), statusCode: HttpStatus.BAD_REQUEST, status: HttpStatus.BAD_REQUEST, }; From c6077cd013bf89536f2074ee0e6d3af7a30174f0 Mon Sep 17 00:00:00 2001 From: Bennett Benedict Date: Fri, 20 Jun 2025 00:47:38 +0300 Subject: [PATCH 2/2] release(mno): Add selcom push --- apps/mno/src/mno.module.ts | 2 + .../selcom/controllers/selcom.controller.ts | 20 +++ apps/mno/src/modules/selcom/selcom.module.ts | 9 ++ .../modules/selcom/services/selcom.service.ts | 118 ++++++++++++++++++ libs/common/src/helpers/base.helper.ts | 33 +++++ libs/common/src/helpers/config.helper.ts | 20 +++ libs/common/src/index.ts | 1 + 7 files changed, 203 insertions(+) create mode 100644 apps/mno/src/modules/selcom/controllers/selcom.controller.ts create mode 100644 apps/mno/src/modules/selcom/selcom.module.ts create mode 100644 apps/mno/src/modules/selcom/services/selcom.service.ts create mode 100644 libs/common/src/helpers/base.helper.ts diff --git a/apps/mno/src/mno.module.ts b/apps/mno/src/mno.module.ts index a8ec451..3f5eced 100644 --- a/apps/mno/src/mno.module.ts +++ b/apps/mno/src/mno.module.ts @@ -6,6 +6,7 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { ConfigModule } from '@nestjs/config'; import { MPesaModule } from './modules/mpesa/mpesa.module'; import { AzamModule } from './modules/azampay/azampay.module'; +import { SelcomModule } from './modules/selcom/selcom.module'; @Module({ imports: [ @@ -20,6 +21,7 @@ import { AzamModule } from './modules/azampay/azampay.module'; ConfigModule.forRoot(), MPesaModule, AzamModule, + SelcomModule, ].filter((module) => module), controllers: [MnoController], providers: [{ provide: APP_FILTER, useClass: HttpErrorFilter }, RmqService], diff --git a/apps/mno/src/modules/selcom/controllers/selcom.controller.ts b/apps/mno/src/modules/selcom/controllers/selcom.controller.ts new file mode 100644 index 0000000..bcc73b9 --- /dev/null +++ b/apps/mno/src/modules/selcom/controllers/selcom.controller.ts @@ -0,0 +1,20 @@ +import { Body, Controller, Post, Res } from '@nestjs/common'; +import { + CheckoutResponse, + ErrorResponse, + MnoCheckout, +} from 'azampay/lib/shared/interfaces/base.interface'; +import { SelcomService } from '../services/selcom.service'; +import { Response } from 'express'; + +@Controller('api') +export class SelcomController { + constructor(private service: SelcomService) {} + + @Post('selcomPush') + async mnoCheckout(@Res() res: Response, @Body() payload: MnoCheckout) { + const response: CheckoutResponse | ErrorResponse = + await this.service.selcomPush(payload); + return res.status(response.statusCode).send(response); + } +} diff --git a/apps/mno/src/modules/selcom/selcom.module.ts b/apps/mno/src/modules/selcom/selcom.module.ts new file mode 100644 index 0000000..90db575 --- /dev/null +++ b/apps/mno/src/modules/selcom/selcom.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { SelcomController } from './controllers/selcom.controller'; +import { SelcomService } from './services/selcom.service'; + +@Module({ + controllers: [SelcomController], + providers: [SelcomService], +}) +export class SelcomModule {} diff --git a/apps/mno/src/modules/selcom/services/selcom.service.ts b/apps/mno/src/modules/selcom/services/selcom.service.ts new file mode 100644 index 0000000..b4d1bdd --- /dev/null +++ b/apps/mno/src/modules/selcom/services/selcom.service.ts @@ -0,0 +1,118 @@ +import { APPENV, phoneNumber } from '@flexpay/common'; +import { HttpStatus, Injectable, Logger } from '@nestjs/common'; +import { + CheckoutResponse, + MnoCheckout, + ErrorResponse, +} from 'azampay/lib/shared/interfaces/base.interface'; +import crypto from 'crypto'; + +// Types +type SelcomPayload = Record; +type Headers = Record; + +@Injectable() +export class SelcomService { + /** + * Compute HS256 Signature + * @param params - Payload parameters + * @param signedFields - Comma-separated keys to be signed + * @param timestamp - ISO timestamp + * @param apiSecret - Your API secret + * @returns Base64-encoded signature string + */ + computeSignature = ( + params: SelcomPayload, + signedFields: string, + timestamp: string, + apiSecret: string, + ): string => { + const fields = signedFields.split(','); + const signingString = [ + `timestamp=${timestamp}`, + ...fields.map((field) => `${field}=${params[field]}`), + ].join('&'); + + return crypto + .createHmac('sha256', apiSecret) + .update(signingString) + .digest('base64'); + }; + + /** + * Send POST request to SELCOM API + * @param url - API endpoint + * @param payload - Payload to send + * @param headers - Authorization headers + * @returns JSON response + */ + sendSelcomRequest = async ( + url: string, + payload: SelcomPayload, + headers: Headers, + ): Promise => { + const response = await fetch(url, { + method: 'POST', + headers, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + const text = await response.text(); + Logger.debug(`SELCOM Error: ${text}`, 'SELCOM'); + return { text }; + } + return response.json(); + }; + + selcomPush = async ( + request: MnoCheckout, + ): Promise => { + const { valid, value, withCode } = phoneNumber(request.accountNumber); + if (!valid) { + return { + statusCode: HttpStatus.BAD_REQUEST, + message: 'Invalid account number', + success: false, + code: 'FAIL', + } as unknown as CheckoutResponse | ErrorResponse; + } + // ======= Config & Execution ======= // + const apiKey = APPENV.SELCOM_APIKEY; + const apiSecret = APPENV.SELCOM_APISECRET; + + const url = `${APPENV.SELCOM_APIURL}/wallet-payment`; + + const payload: SelcomPayload = { + utilityref: value, + transid: request.externalId, + amount: request.amount, + vendor: APPENV.SELCOM_VENDOR, + msisdn: withCode, + }; + + const authorization = Buffer.from(apiKey).toString('hex'); + const timestamp = new Date().toISOString(); + const signedFields = Object.keys(payload).join(','); + const digest = this.computeSignature( + payload, + signedFields, + timestamp, + apiSecret, + ); + + const headers: Headers = { + 'Content-Type': 'application/json;charset=utf-8', + Accept: 'application/json', + 'Cache-Control': 'no-cache', + 'Digest-Method': 'HS256', + Authorization: `SELCOM ${authorization}`, + Digest: digest, + Timestamp: timestamp, + 'Signed-Fields': signedFields, + }; + const result = await this.sendSelcomRequest(url, payload, headers); + console.log(JSON.stringify(result, null, 2)); + return result; + }; +} diff --git a/libs/common/src/helpers/base.helper.ts b/libs/common/src/helpers/base.helper.ts new file mode 100644 index 0000000..4c7a752 --- /dev/null +++ b/libs/common/src/helpers/base.helper.ts @@ -0,0 +1,33 @@ +export const phoneNumber = (phone: string) => { + if (!phone || phone.length < 9) return { valid: false, value: phone }; + + phone = phone.replace(/[^\w\s]/gi, ''); + + let withCode = phone; + let withoutCode = phone; + let withOutPlus = phone; + + if (phone.startsWith('+255')) { + withoutCode = phone.replace('+255', '0'); + withOutPlus = phone.replace('+', ''); + withCode = phone; + } else if (phone.startsWith('255')) { + withoutCode = phone.replace(/^255/, '0'); + withCode = `+${phone}`; + } else if (!phone.startsWith('0') && phone.length === 9) { + withoutCode = `0${phone}`; + withCode = `+255${phone}`; + withOutPlus = `255${phone}`; + } else if (phone.startsWith('0')) { + withoutCode = phone; + withCode = `+255${phone.substring(1)}`; + withOutPlus = `255${phone.substring(1)}`; + } + + return { + valid: true, + value: withoutCode, + withCode, + withOutPlus, + }; +}; diff --git a/libs/common/src/helpers/config.helper.ts b/libs/common/src/helpers/config.helper.ts index fb898d5..5226e55 100644 --- a/libs/common/src/helpers/config.helper.ts +++ b/libs/common/src/helpers/config.helper.ts @@ -51,4 +51,24 @@ export const APPENV = { * Azam Pay App Name */ AZAMPAY_APIKEY: process.env.AZAMPAY_APIKEY, + + /** + * Selcom vendor + */ + SELCOM_VENDOR: process.env.SELCOM_VENDOR, + + /** + * Selcom API Key + */ + SELCOM_APIKEY: process.env.SELCOM_APIKEY, + + /** + * Selcom API URL + */ + SELCOM_APIURL: process.env.SELCOM_APIURL, + + /** + * Selcom API Secret + */ + SELCOM_APISECRET: process.env.SELCOM_APISECRET, }; diff --git a/libs/common/src/index.ts b/libs/common/src/index.ts index 25486ea..eeedcb0 100644 --- a/libs/common/src/index.ts +++ b/libs/common/src/index.ts @@ -29,3 +29,4 @@ export * from './auth/services/auth.service'; export * from './dto/shared.dto'; export * from './constants/entity.names.constants'; export * from './interfaces/mno.interface'; +export * from './helpers/base.helper';