fix it otp challenge

This commit is contained in:
Mamadou Khoussa [028918 DSI/DAC/DIF/DS] 2025-10-24 22:39:02 +00:00
parent df7ae8dbeb
commit f87650dcc2
16 changed files with 2393 additions and 21 deletions

View File

@ -28,7 +28,7 @@ async function bootstrap() {
const config = new DocumentBuilder() const config = new DocumentBuilder()
.setTitle('Payment Hub API') .setTitle('Payment Hub API')
.setDescription('Unified DCB Payment Aggregation Platform') .setDescription('Unified DCB Payment Aggregation Platform')
.setVersion('2.0.0') .setVersion('1.0.0')
.addBearerAuth() .addBearerAuth()
.addTag('auth') .addTag('auth')
.addTag('payments') .addTag('payments')
@ -38,9 +38,14 @@ async function bootstrap() {
const document = SwaggerModule.createDocument(app, config); const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api/docs', app, document); SwaggerModule.setup('api/docs', app, document);
app.getHttpAdapter().get('/api/swagger-json', (req, res) => {
res.json(document);
});
const port = process.env.PORT || 3000; const port = process.env.PORT || 3000;
await app.listen(port); await app.listen(port);
console.log(`Application is running on: http://localhost:${port}`); console.log(`Application is running on: http://localhost:${port}`);
console.log(`Swagger docs: http://localhost:${port}/api/docs`); console.log(`Swagger docs: http://localhost:${port}/api/docs`);
console.log(`Swagger docs: http://localhost:${port}/api/swagger-json`);
} }
bootstrap(); bootstrap();

View File

@ -0,0 +1,287 @@
import axios, { AxiosInstance, AxiosError } from 'axios';
import {
OrangeChallengeRequest,
OrangeChallengeResponse,
OrangeChallengeRequestBuilder
} from './dtos/orange-challenge.dto';
import {
OrangeConfig,
DEFAULT_ORANGE_CONFIG,
COUNTRY_CODE_MAPPING,
OTP_METHOD_MAPPING
} from './config/orange.config';
import { OtpChallengeRequestDto } from '../../dtos/otp-challenge-request.dto';
import { OtpChallengeResponseDto, OtpChallengeStatusEnum } from '../../dtos/otp-challenge-response.dto';
/**
* Adaptateur pour l'API Orange DCB v2
* Gère l'authentification OAuth2 et les appels à l'API Challenge
*/
export class OrangeAdapter {
private axiosInstance: AxiosInstance;
private config: OrangeConfig;
private accessToken: string | null = null;
private tokenExpiresAt: number = 0;
constructor(config: OrangeConfig) {
this.config = { ...DEFAULT_ORANGE_CONFIG, ...config } as OrangeConfig;
this.axiosInstance = axios.create({
baseURL: this.config.baseUrl,
timeout: this.config.timeout,
headers: {
'Content-Type': 'application/json',
'Accept': '*/*'
}
});
// Intercepteur pour gérer les erreurs
this.axiosInstance.interceptors.response.use(
response => response,
error => this.handleError(error)
);
}
/**
* Obtenir un token OAuth2 depuis Orange
*/
private async getAccessToken(): Promise<string> {
// Vérifier si le token est encore valide (avec une marge de 60 secondes)
if (this.accessToken && Date.now() < this.tokenExpiresAt - 60000) {
return this.accessToken;
}
try {
const auth = Buffer.from(
`${this.config.clientId}:${this.config.clientSecret}`
).toString('base64');
const response = await axios.post(
`${this.config.baseUrl}${this.config.tokenEndpoint}`,
'grant_type=client_credentials',
{
headers: {
'Authorization': `Basic ${auth}`,
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': '*/*'
}
}
);
this.accessToken = response.data.access_token;
const expiresIn = response.data.expires_in || 3600;
this.tokenExpiresAt = Date.now() + (expiresIn * 1000);
return this.accessToken;
} catch (error) {
throw new Error(`Failed to obtain Orange access token: ${error.message}`);
}
}
/**
* Convertir la requête générique en format Orange
*/
private mapToOrangeRequest(request: OtpChallengeRequestDto): OrangeChallengeRequest {
const builder = new OrangeChallengeRequestBuilder();
// Mapper le pays
const orangeCountry = COUNTRY_CODE_MAPPING[request.country] || request.country;
builder.withCountry(orangeCountry);
// Mapper la méthode
const orangeMethod = OTP_METHOD_MAPPING[request.method] || 'OTP-SMS-AUTH';
builder.withMethod(orangeMethod);
// Ajouter les informations de base
builder
.withService(request.service)
.withPartnerId(this.config.partnerId);
// Ajouter l'identifiant
builder.withIdentifier(request.identifier.type, request.identifier.value);
// Ajouter le code de confirmation si présent
if (request.confirmationCode) {
builder.withConfirmationCode(request.confirmationCode);
} else {
builder.withConfirmationCode(''); // Orange requiert ce champ même vide
}
// Configuration du message OTP
const message = request.config?.message || this.config.defaultOtpMessage;
builder.withMessage(message);
// Longueur de l'OTP
const otpLength = request.config?.length || this.config.defaultOtpLength;
builder.withOtpLength(otpLength);
// Nom de l'expéditeur
const senderName = request.config?.senderName || this.config.defaultSenderName;
builder.withSenderName(senderName);
return builder.build();
}
/**
* Convertir la réponse Orange en format générique
*/
private mapFromOrangeResponse(
orangeResponse: OrangeChallengeResponse,
request: OtpChallengeRequestDto
): OtpChallengeResponseDto {
const response: OtpChallengeResponseDto = {
challengeId: orangeResponse.challengeId || '',
merchantId: request.merchantId,
transactionId: request.transactionId,
status: this.mapOrangeStatus(orangeResponse),
message: orangeResponse.message,
expiresIn: orangeResponse.expiresIn,
sessionId: orangeResponse.sessionId,
requiresConfirmation: true,
metadata: {
provider: 'orange',
country: request.country,
method: request.method
}
};
// Ajouter l'erreur si présente
if (orangeResponse.error) {
response.error = {
code: orangeResponse.error.code.toString(),
message: orangeResponse.error.message,
description: orangeResponse.error.description
};
response.status = OtpChallengeStatusEnum.FAILED;
}
return response;
}
/**
* Mapper le statut Orange vers le statut générique
*/
private mapOrangeStatus(orangeResponse: OrangeChallengeResponse): OtpChallengeStatusEnum {
if (orangeResponse.error) {
return OtpChallengeStatusEnum.FAILED;
}
if (orangeResponse.challengeId) {
return OtpChallengeStatusEnum.SENT;
}
return OtpChallengeStatusEnum.PENDING;
}
/**
* Gérer les erreurs HTTP
*/
private handleError(error: AxiosError): never {
if (error.response) {
const data = error.response.data as any;
throw new Error(
`Orange API Error: ${data?.error?.message || error.message} (Code: ${data?.error?.code || error.response.status})`
);
} else if (error.request) {
throw new Error(`No response from Orange API: ${error.message}`);
} else {
throw new Error(`Request error: ${error.message}`);
}
}
/**
* Initier un challenge OTP via l'API Orange
*/
async initiateChallenge(request: OtpChallengeRequestDto): Promise<OtpChallengeResponseDto> {
try {
// Obtenir le token
const token = await this.getAccessToken();
// Mapper la requête
const orangeRequest = this.mapToOrangeRequest(request);
// Appeler l'API Orange
const response = await this.axiosInstance.post<OrangeChallengeResponse>(
this.config.challengeEndpoint,
orangeRequest,
{
headers: {
'Authorization': `Bearer ${token}`
}
}
);
// Mapper la réponse
return this.mapFromOrangeResponse(response.data, request);
} catch (error) {
// En cas d'erreur, retourner une réponse avec le statut FAILED
return {
challengeId: '',
merchantId: request.merchantId,
transactionId: request.transactionId,
status: OtpChallengeStatusEnum.FAILED,
error: {
code: 'ORANGE_API_ERROR',
message: error.message,
description: 'Failed to initiate OTP challenge with Orange'
}
};
}
}
/**
* Vérifier un code OTP (en appelant à nouveau l'API avec le code)
*/
async verifyOtp(
challengeId: string,
otpCode: string,
originalRequest: OtpChallengeRequestDto
): Promise<OtpChallengeResponseDto> {
try {
// Créer une nouvelle requête avec le code de confirmation
const verifyRequest: OtpChallengeRequestDto = {
...originalRequest,
confirmationCode: otpCode
};
// Obtenir le token
const token = await this.getAccessToken();
// Mapper la requête
const orangeRequest = this.mapToOrangeRequest(verifyRequest);
// Appeler l'API Orange pour vérification
const response = await this.axiosInstance.post<OrangeChallengeResponse>(
this.config.challengeEndpoint,
orangeRequest,
{
headers: {
'Authorization': `Bearer ${token}`
}
}
);
// Mapper la réponse
const mappedResponse = this.mapFromOrangeResponse(response.data, verifyRequest);
// Si pas d'erreur, c'est vérifié
if (!mappedResponse.error) {
mappedResponse.status = OtpChallengeStatusEnum.VERIFIED;
}
return mappedResponse;
} catch (error) {
return {
challengeId,
merchantId: originalRequest.merchantId,
transactionId: originalRequest.transactionId,
status: OtpChallengeStatusEnum.FAILED,
error: {
code: 'OTP_VERIFICATION_FAILED',
message: error.message,
description: 'Failed to verify OTP code'
}
};
}
}
}

View File

@ -0,0 +1,46 @@
export interface OrangeConfig {
baseUrl: string;
partnerId: string;
clientId: string;
clientSecret: string;
defaultService: string;
defaultOtpLength: number;
defaultSenderName: string;
defaultOtpMessage: string;
tokenEndpoint: string;
challengeEndpoint: string;
timeout: number;
}
export const DEFAULT_ORANGE_CONFIG: Partial<OrangeConfig> = {
defaultOtpLength: 4,
defaultOtpMessage: 'To confirm your purchase please enter the code %OTP%',
tokenEndpoint: '/oauth/v3/token',
challengeEndpoint: '/challenge/v1/challenges',
timeout: 30000, // 30 secondes
};
/**
* Mapping des codes pays ISO vers les codes Orange
*/
export const COUNTRY_CODE_MAPPING: Record<string, string> = {
'SN': 'SEN', // Sénégal
'CI': 'CIV', // Côte d'Ivoire
'CM': 'CMR', // Cameroun
'CD': 'COD', // RD Congo
'BF': 'BFA', // Burkina Faso
'TN': 'TUN', // Tunisie
'ML': 'MLI', // Mali
'GN': 'GIN', // Guinée
'NE': 'NER', // Niger
'MG': 'MDG', // Madagascar
};
/**
* Mapping des méthodes OTP génériques vers Orange
*/
export const OTP_METHOD_MAPPING: Record<string, string> = {
'SMS': 'OTP-SMS-AUTH',
'USSD': 'OTP-USSD-AUTH',
'IVR': 'OTP-IVR-AUTH',
};

View File

@ -0,0 +1,82 @@
import { IsString, IsNotEmpty, IsOptional, IsEnum, ValidateNested, IsArray, IsNumber, Min, Max } from 'class-validator';
import { Type } from 'class-transformer';
export enum OtpMethodEnum {
SMS = 'SMS',
USSD = 'USSD',
IVR = 'IVR'
}
export enum IdentifierTypeEnum {
MSISDN = 'MSISDN',
ISE2 = 'ISE2',
SUBSCRIBER_ID = 'SUBSCRIBER_ID'
}
export class IdentifierDto {
@IsEnum(IdentifierTypeEnum)
@IsNotEmpty()
type: IdentifierTypeEnum;
@IsString()
@IsNotEmpty()
value: string;
}
export class OtpConfigDto {
@IsOptional()
@IsString()
message?: string;
@IsOptional()
@IsNumber()
@Min(4)
@Max(8)
length?: number;
@IsOptional()
@IsString()
senderName?: string;
@IsOptional()
@IsString()
language?: string;
}
export class OtpChallengeRequestDto {
@IsString()
@IsNotEmpty()
merchantId: string;
@IsString()
@IsNotEmpty()
transactionId: string;
@ValidateNested()
@Type(() => IdentifierDto)
identifier: IdentifierDto;
@IsEnum(OtpMethodEnum)
@IsNotEmpty()
method: OtpMethodEnum;
@IsString()
@IsNotEmpty()
country: string;
@IsString()
@IsNotEmpty()
service: string;
@IsOptional()
@ValidateNested()
@Type(() => OtpConfigDto)
config?: OtpConfigDto;
@IsOptional()
@IsString()
confirmationCode?: string;
@IsOptional()
metadata?: Record<string, any>;
}

View File

@ -0,0 +1,53 @@
import { IsString, IsNotEmpty, IsOptional, IsEnum, IsNumber, IsBoolean } from 'class-validator';
export enum OtpChallengeStatusEnum {
PENDING = 'PENDING',
SENT = 'SENT',
VERIFIED = 'VERIFIED',
FAILED = 'FAILED',
EXPIRED = 'EXPIRED'
}
export class OtpChallengeResponseDto {
@IsString()
@IsNotEmpty()
challengeId: string;
@IsString()
@IsNotEmpty()
merchantId: string;
@IsString()
@IsNotEmpty()
transactionId: string;
@IsEnum(OtpChallengeStatusEnum)
@IsNotEmpty()
status: OtpChallengeStatusEnum;
@IsOptional()
@IsString()
message?: string;
@IsOptional()
@IsNumber()
expiresIn?: number;
@IsOptional()
@IsString()
sessionId?: string;
@IsOptional()
@IsBoolean()
requiresConfirmation?: boolean;
@IsOptional()
metadata?: Record<string, any>;
@IsOptional()
error?: {
code: string;
message: string;
description?: string;
};
}

View File

@ -0,0 +1,421 @@
import {
Controller,
Post,
Get,
Body,
Param,
Query,
HttpStatus,
HttpException,
UseGuards,
Headers,
Logger
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiParam,
ApiQuery,
ApiHeader,
ApiBearerAuth
} from '@nestjs/swagger';
import { OtpChallengeResponseDto, OtpChallengeStatusEnum } from './dto/challenge.response.dto';
import { OtpChallengeRequestDto } from './dto/challenge.request.dto';
/**
* Controller pour les endpoints de challenge OTP
* Architecture: Merchant -> Hub (ce controller) -> Orange (via adapter)
*/
@ApiTags('OTP Challenge')
@Controller('api/v1/otp-challenge')
export class OtpChallengeController {
private readonly logger = new Logger(OtpChallengeController.name);
constructor(private readonly otpChallengeService: OtpChallengeService) {}
/**
* Endpoint 1: Initier un challenge OTP
* POST /api/v1/otp-challenge/initiate
*/
@Post('initiate')
@ApiOperation({
summary: 'Initier un challenge OTP',
description: 'Envoie un code OTP au numéro de téléphone spécifié via SMS, USSD ou IVR'
})
@ApiResponse({
status: HttpStatus.CREATED,
description: 'Challenge OTP initié avec succès',
type: OtpChallengeResponseDto
})
@ApiResponse({
status: HttpStatus.BAD_REQUEST,
description: 'Requête invalide'
})
@ApiResponse({
status: HttpStatus.INTERNAL_SERVER_ERROR,
description: 'Erreur serveur'
})
@ApiHeader({
name: 'X-Merchant-ID',
description: 'Identifiant du merchant',
required: true
})
@ApiHeader({
name: 'X-API-Key',
description: 'Clé API du merchant',
required: true
})
async initiateChallenge(
@Body() request: OtpChallengeRequestDto,
@Headers('x-merchant-id') merchantId: string,
@Headers('x-api-key') apiKey: string
): Promise<OtpChallengeResponseDto> {
this.logger.log(`[INITIATE] Merchant: ${merchantId}, Transaction: ${request.transactionId}`);
try {
// Valider les headers
this.validateMerchantHeaders(merchantId, apiKey);
// S'assurer que le merchantId dans le body correspond au header
if (request.merchantId && request.merchantId !== merchantId) {
throw new HttpException(
'Merchant ID in body does not match header',
HttpStatus.BAD_REQUEST
);
}
// Définir le merchantId depuis le header si non présent
request.merchantId = merchantId;
// Appeler le service
const response = await this.otpChallengeService.initiateChallenge(request);
// Logger le résultat
this.logger.log(
`[INITIATE] Result - ChallengeId: ${response.challengeId}, Status: ${response.status}`
);
// Si échec, retourner une erreur HTTP appropriée
if (response.status === OtpChallengeStatusEnum.FAILED && response.error) {
throw new HttpException(
{
statusCode: HttpStatus.BAD_REQUEST,
message: response.error.message,
error: response.error
},
HttpStatus.BAD_REQUEST
);
}
return response;
} catch (error) {
this.logger.error(`[INITIATE] Error: ${error.message}`, error.stack);
if (error instanceof HttpException) {
throw error;
}
throw new HttpException(
{
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
message: 'Failed to initiate OTP challenge',
error: error.message
},
HttpStatus.INTERNAL_SERVER_ERROR
);
}
}
/**
* Endpoint 2: Vérifier un code OTP
* POST /api/v1/otp-challenge/:challengeId/verify
*/
@Post(':challengeId/verify')
@ApiOperation({
summary: 'Vérifier un code OTP',
description: 'Vérifie le code OTP entré par l\'utilisateur'
})
@ApiParam({
name: 'challengeId',
description: 'Identifiant du challenge',
type: String
})
@ApiResponse({
status: HttpStatus.OK,
description: 'Code OTP vérifié avec succès',
type: OtpChallengeResponseDto
})
@ApiResponse({
status: HttpStatus.BAD_REQUEST,
description: 'Code OTP invalide'
})
@ApiResponse({
status: HttpStatus.NOT_FOUND,
description: 'Challenge non trouvé'
})
@ApiHeader({
name: 'X-Merchant-ID',
description: 'Identifiant du merchant',
required: true
})
@ApiHeader({
name: 'X-API-Key',
description: 'Clé API du merchant',
required: true
})
async verifyOtp(
@Param('challengeId') challengeId: string,
@Body('otpCode') otpCode: string,
@Headers('x-merchant-id') merchantId: string,
@Headers('x-api-key') apiKey: string
): Promise<OtpChallengeResponseDto> {
this.logger.log(`[VERIFY] Merchant: ${merchantId}, Challenge: ${challengeId}`);
try {
// Valider les headers
this.validateMerchantHeaders(merchantId, apiKey);
// Valider le code OTP
if (!otpCode || otpCode.trim().length === 0) {
throw new HttpException('OTP code is required', HttpStatus.BAD_REQUEST);
}
// Appeler le service
const response = await this.otpChallengeService.verifyOtp(
challengeId,
otpCode,
merchantId
);
// Logger le résultat
this.logger.log(`[VERIFY] Result - Status: ${response.status}`);
// Si échec, retourner une erreur appropriée
if (response.status === OtpChallengeStatusEnum.FAILED && response.error) {
const statusCode =
response.error.code === 'CHALLENGE_NOT_FOUND'
? HttpStatus.NOT_FOUND
: HttpStatus.BAD_REQUEST;
throw new HttpException(
{
statusCode,
message: response.error.message,
error: response.error
},
statusCode
);
}
return response;
} catch (error) {
this.logger.error(`[VERIFY] Error: ${error.message}`, error.stack);
if (error instanceof HttpException) {
throw error;
}
throw new HttpException(
{
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
message: 'Failed to verify OTP code',
error: error.message
},
HttpStatus.INTERNAL_SERVER_ERROR
);
}
}
/**
* Endpoint 3: Obtenir le statut d'un challenge
* GET /api/v1/otp-challenge/:challengeId/status
*/
@Get(':challengeId/status')
@ApiOperation({
summary: 'Obtenir le statut d\'un challenge',
description: 'Récupère le statut actuel d\'un challenge OTP'
})
@ApiParam({
name: 'challengeId',
description: 'Identifiant du challenge',
type: String
})
@ApiResponse({
status: HttpStatus.OK,
description: 'Statut du challenge récupéré avec succès',
type: OtpChallengeResponseDto
})
@ApiResponse({
status: HttpStatus.NOT_FOUND,
description: 'Challenge non trouvé'
})
@ApiHeader({
name: 'X-Merchant-ID',
description: 'Identifiant du merchant',
required: true
})
@ApiHeader({
name: 'X-API-Key',
description: 'Clé API du merchant',
required: true
})
async getChallengeStatus(
@Param('challengeId') challengeId: string,
@Headers('x-merchant-id') merchantId: string,
@Headers('x-api-key') apiKey: string
): Promise<OtpChallengeResponseDto> {
this.logger.log(`[STATUS] Merchant: ${merchantId}, Challenge: ${challengeId}`);
try {
// Valider les headers
this.validateMerchantHeaders(merchantId, apiKey);
// Appeler le service
const response = await this.otpChallengeService.getChallengeStatus(
challengeId,
merchantId
);
// Si non trouvé
if (response.status === OtpChallengeStatusEnum.EXPIRED ||
(response.error && response.error.code === 'CHALLENGE_NOT_FOUND')) {
throw new HttpException(
{
statusCode: HttpStatus.NOT_FOUND,
message: 'Challenge not found or expired',
error: response.error
},
HttpStatus.NOT_FOUND
);
}
this.logger.log(`[STATUS] Result - Status: ${response.status}`);
return response;
} catch (error) {
this.logger.error(`[STATUS] Error: ${error.message}`, error.stack);
if (error instanceof HttpException) {
throw error;
}
throw new HttpException(
{
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
message: 'Failed to get challenge status',
error: error.message
},
HttpStatus.INTERNAL_SERVER_ERROR
);
}
}
/**
* Endpoint 4: Renvoyer un code OTP
* POST /api/v1/otp-challenge/:challengeId/resend
*/
@Post(':challengeId/resend')
@ApiOperation({
summary: 'Renvoyer un code OTP',
description: 'Renvoie un nouveau code OTP pour un challenge existant'
})
@ApiParam({
name: 'challengeId',
description: 'Identifiant du challenge',
type: String
})
@ApiResponse({
status: HttpStatus.OK,
description: 'Code OTP renvoyé avec succès',
type: OtpChallengeResponseDto
})
@ApiResponse({
status: HttpStatus.NOT_FOUND,
description: 'Challenge non trouvé'
})
@ApiHeader({
name: 'X-Merchant-ID',
description: 'Identifiant du merchant',
required: true
})
@ApiHeader({
name: 'X-API-Key',
description: 'Clé API du merchant',
required: true
})
async resendOtp(
@Param('challengeId') challengeId: string,
@Headers('x-merchant-id') merchantId: string,
@Headers('x-api-key') apiKey: string
): Promise<OtpChallengeResponseDto> {
this.logger.log(`[RESEND] Merchant: ${merchantId}, Challenge: ${challengeId}`);
try {
// Valider les headers
this.validateMerchantHeaders(merchantId, apiKey);
// Appeler le service
const response = await this.otpChallengeService.resendOtp(
challengeId,
merchantId
);
// Si échec
if (response.status === OtpChallengeStatusEnum.FAILED && response.error) {
const statusCode =
response.error.code === 'CHALLENGE_NOT_FOUND'
? HttpStatus.NOT_FOUND
: HttpStatus.BAD_REQUEST;
throw new HttpException(
{
statusCode,
message: response.error.message,
error: response.error
},
statusCode
);
}
this.logger.log(`[RESEND] Result - New ChallengeId: ${response.challengeId}`);
return response;
} catch (error) {
this.logger.error(`[RESEND] Error: ${error.message}`, error.stack);
if (error instanceof HttpException) {
throw error;
}
throw new HttpException(
{
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
message: 'Failed to resend OTP code',
error: error.message
},
HttpStatus.INTERNAL_SERVER_ERROR
);
}
}
/**
* Valider les headers du merchant
*/
private validateMerchantHeaders(merchantId: string, apiKey: string): void {
if (!merchantId || merchantId.trim().length === 0) {
throw new HttpException(
'X-Merchant-ID header is required',
HttpStatus.UNAUTHORIZED
);
}
if (!apiKey || apiKey.trim().length === 0) {
throw new HttpException(
'X-API-Key header is required',
HttpStatus.UNAUTHORIZED
);
}
// TODO: Implémenter la validation de l'API Key
// Cette logique devrait vérifier que l'API Key est valide pour le merchant
}
}

View File

@ -0,0 +1,50 @@
import { OtpChallengeRequestDto } from '../dtos/otp-challenge-request.dto';
import { OtpChallengeResponseDto } from '../dtos/otp-challenge-response.dto';
/**
* Interface générique pour le service de challenge OTP
* Permet de gérer différents fournisseurs (Orange, MTN, etc.)
*/
export interface IOtpChallengeService {
/**
* Initier un challenge OTP
* @param request - Les données du challenge
* @returns La réponse du challenge avec le challengeId et le status
*/
initiateChallenge(request: OtpChallengeRequestDto): Promise<OtpChallengeResponseDto>;
/**
* Vérifier un code OTP
* @param challengeId - L'identifiant du challenge
* @param otpCode - Le code OTP à vérifier
* @param merchantId - L'identifiant du merchant
* @returns La réponse de vérification
*/
verifyOtp(
challengeId: string,
otpCode: string,
merchantId: string
): Promise<OtpChallengeResponseDto>;
/**
* Obtenir le statut d'un challenge
* @param challengeId - L'identifiant du challenge
* @param merchantId - L'identifiant du merchant
* @returns Le statut actuel du challenge
*/
getChallengeStatus(
challengeId: string,
merchantId: string
): Promise<OtpChallengeResponseDto>;
/**
* Renvoyer un code OTP
* @param challengeId - L'identifiant du challenge
* @param merchantId - L'identifiant du merchant
* @returns La réponse du renvoi
*/
resendOtp(
challengeId: string,
merchantId: string
): Promise<OtpChallengeResponseDto>;
}

View File

@ -0,0 +1,39 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { OtpChallengeController } from './otp.challenge.controller';
import { OrangeConfig } from './adaptor/orange.config';
import { OtpChallengeService } from './otp.challenge.service';
/**
* Module pour le challenge OTP
* Gère l'injection de dépendances et la configuration
*/
@Module({
imports: [ConfigModule],
controllers: [OtpChallengeController],
providers: [
{
provide: 'ORANGE_CONFIG',
useFactory: (configService: ConfigService): OrangeConfig => ({
baseUrl: configService.get<string>('ORANGE_BASE_URL', 'https://api-gateway.app.cameleonapp.com/api-orange-dcb/1.0.0'),
partnerId: configService.get<string>('ORANGE_PARTNER_ID', 'PDKSUB'),
clientId: configService.get<string>('ORANGE_CLIENT_ID', 'admin'),
clientSecret: configService.get<string>('ORANGE_CLIENT_SECRET', 'admin'),
defaultService: configService.get<string>('ORANGE_DEFAULT_SERVICE', 'DCB_SERVICE'),
defaultOtpLength: configService.get<number>('ORANGE_DEFAULT_OTP_LENGTH', 4),
defaultSenderName: configService.get<string>('ORANGE_DEFAULT_SENDER_NAME', 'OTP'),
defaultOtpMessage: configService.get<string>(
'ORANGE_DEFAULT_OTP_MESSAGE',
'To confirm your purchase please enter the code %OTP%'
),
tokenEndpoint: '/oauth/v3/token',
challengeEndpoint: '/challenge/v1/challenges',
timeout: configService.get<number>('ORANGE_TIMEOUT', 30000),
}),
inject: [ConfigService],
},
OtpChallengeService,
],
exports: [OtpChallengeService],
})
export class OtpChallengeModule {}

View File

@ -0,0 +1,268 @@
import { Injectable, Inject } from '@nestjs/common';
import type { OrangeConfig } from './adaptor/orange.config';
import { OrangeAdapter } from './adaptor/orange.adaptor';
import { OtpChallengeRequestDto } from './dto/challenge.request.dto';
import { IOtpChallengeService } from './otp.challenge.interface';
import { OtpChallengeResponseDto, OtpChallengeStatusEnum } from './dto/challenge.response.dto';
/**
* Service Hub pour gérer les challenges OTP
* Utilise l'adaptateur Orange pour communiquer avec l'API Orange DCB
*/
@Injectable()
export class OtpChallengeService implements IOtpChallengeService {
private orangeAdapter: OrangeAdapter;
private challengeCache: Map<string, { request: OtpChallengeRequestDto; response: OtpChallengeResponseDto }>;
constructor(@Inject('ORANGE_CONFIG') private readonly orangeConfig: OrangeConfig) {
this.orangeAdapter = new OrangeAdapter(orangeConfig);
this.challengeCache = new Map();
}
/**
* Initier un challenge OTP
*/
async initiateChallenge(request: OtpChallengeRequestDto): Promise<OtpChallengeResponseDto> {
try {
// Valider la requête
this.validateRequest(request);
// Appeler l'adaptateur Orange
const response = await this.orangeAdapter.initiateChallenge(request);
// Mettre en cache pour la vérification future
if (response.challengeId) {
this.challengeCache.set(response.challengeId, { request, response });
// Nettoyer le cache après expiration (par défaut 5 minutes)
const expirationTime = (response.expiresIn || 300) * 1000;
setTimeout(() => {
this.challengeCache.delete(response.challengeId);
}, expirationTime);
}
return response;
} catch (error) {
return this.createErrorResponse(request, 'INITIATE_FAILED', error.message);
}
}
/**
* Vérifier un code OTP
*/
async verifyOtp(
challengeId: string,
otpCode: string,
merchantId: string
): Promise<OtpChallengeResponseDto> {
try {
// Récupérer le challenge depuis le cache
const cached = this.challengeCache.get(challengeId);
if (!cached) {
return {
challengeId,
merchantId,
transactionId: '',
status: OtpChallengeStatusEnum.FAILED,
error: {
code: 'CHALLENGE_NOT_FOUND',
message: 'Challenge not found or expired',
description: 'The challenge ID is invalid or has expired'
}
};
}
// Vérifier que le merchantId correspond
if (cached.request.merchantId !== merchantId) {
return {
challengeId,
merchantId,
transactionId: cached.request.transactionId,
status: OtpChallengeStatusEnum.FAILED,
error: {
code: 'MERCHANT_MISMATCH',
message: 'Merchant ID does not match',
description: 'The provided merchant ID does not match the challenge'
}
};
}
// Appeler l'adaptateur pour vérifier
const response = await this.orangeAdapter.verifyOtp(
challengeId,
otpCode,
cached.request
);
// Mettre à jour le cache
if (response.status === OtpChallengeStatusEnum.VERIFIED) {
this.challengeCache.set(challengeId, { request: cached.request, response });
}
return response;
} catch (error) {
return {
challengeId,
merchantId,
transactionId: '',
status: OtpChallengeStatusEnum.FAILED,
error: {
code: 'VERIFY_FAILED',
message: error.message,
description: 'Failed to verify OTP code'
}
};
}
}
/**
* Obtenir le statut d'un challenge
*/
async getChallengeStatus(
challengeId: string,
merchantId: string
): Promise<OtpChallengeResponseDto> {
const cached = this.challengeCache.get(challengeId);
if (!cached) {
return {
challengeId,
merchantId,
transactionId: '',
status: OtpChallengeStatusEnum.EXPIRED,
error: {
code: 'CHALLENGE_NOT_FOUND',
message: 'Challenge not found or expired'
}
};
}
// Vérifier que le merchantId correspond
if (cached.request.merchantId !== merchantId) {
return {
challengeId,
merchantId,
transactionId: cached.request.transactionId,
status: OtpChallengeStatusEnum.FAILED,
error: {
code: 'MERCHANT_MISMATCH',
message: 'Merchant ID does not match'
}
};
}
return cached.response;
}
/**
* Renvoyer un code OTP
*/
async resendOtp(
challengeId: string,
merchantId: string
): Promise<OtpChallengeResponseDto> {
try {
// Récupérer le challenge depuis le cache
const cached = this.challengeCache.get(challengeId);
if (!cached) {
return {
challengeId,
merchantId,
transactionId: '',
status: OtpChallengeStatusEnum.FAILED,
error: {
code: 'CHALLENGE_NOT_FOUND',
message: 'Challenge not found or expired'
}
};
}
// Vérifier que le merchantId correspond
if (cached.request.merchantId !== merchantId) {
return {
challengeId,
merchantId,
transactionId: cached.request.transactionId,
status: OtpChallengeStatusEnum.FAILED,
error: {
code: 'MERCHANT_MISMATCH',
message: 'Merchant ID does not match'
}
};
}
// Réinitier le challenge (nouvel envoi)
const response = await this.orangeAdapter.initiateChallenge(cached.request);
// Mettre à jour le cache avec le nouveau challengeId
if (response.challengeId) {
// Supprimer l'ancien
this.challengeCache.delete(challengeId);
// Ajouter le nouveau
this.challengeCache.set(response.challengeId, { request: cached.request, response });
}
return response;
} catch (error) {
return {
challengeId,
merchantId,
transactionId: '',
status: OtpChallengeStatusEnum.FAILED,
error: {
code: 'RESEND_FAILED',
message: error.message,
description: 'Failed to resend OTP code'
}
};
}
}
/**
* Valider la requête
*/
private validateRequest(request: OtpChallengeRequestDto): void {
if (!request.merchantId) {
throw new Error('Merchant ID is required');
}
if (!request.transactionId) {
throw new Error('Transaction ID is required');
}
if (!request.identifier || !request.identifier.type || !request.identifier.value) {
throw new Error('Valid identifier is required');
}
if (!request.country) {
throw new Error('Country is required');
}
if (!request.service) {
throw new Error('Service is required');
}
}
/**
* Créer une réponse d'erreur
*/
private createErrorResponse(
request: OtpChallengeRequestDto,
code: string,
message: string
): OtpChallengeResponseDto {
return {
challengeId: '',
merchantId: request.merchantId,
transactionId: request.transactionId,
status: OtpChallengeStatusEnum.FAILED,
error: {
code,
message,
description: 'Failed to process OTP challenge request'
}
};
}
}

View File

@ -0,0 +1,75 @@
import { IsString, IsOptional, IsBoolean } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class OperatorResponseDto {
@ApiProperty()
id: string;
@ApiProperty()
code: string;
@ApiProperty()
name: string;
@ApiProperty()
country: string;
@ApiProperty()
active: boolean;
@ApiProperty()
features: any;
}
export class OperatorConfigDto {
@ApiProperty()
code: string;
@ApiProperty()
name: string;
@ApiProperty()
baseUrl: string;
@ApiProperty()
endpoints: any;
@ApiProperty()
headers: any;
@ApiProperty()
features: any;
}
export class TestConnectionDto {
@ApiProperty()
@IsString()
testMsisdn: string;
@ApiProperty()
@IsString()
country: string;
}
export class OperatorStatsDto {
@ApiProperty()
operatorCode: string;
@ApiProperty()
totalPayments: number;
@ApiProperty()
successfulPayments: number;
@ApiProperty()
failedPayments: number;
@ApiProperty()
successRate: number;
@ApiProperty()
totalRevenue: number;
@ApiProperty()
uniqueUsers: number;
}

View File

@ -1,11 +1,182 @@
//todo import {
import { Controller, Get } from "@nestjs/common"; Controller,
Get,
Post,
Put,
Body,
Param,
Query,
UseGuards,
Request,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiBearerAuth,
ApiResponse,
ApiQuery,
} from '@nestjs/swagger';
import { OperatorsService } from './operators.service';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import {
OperatorConfigDto,
OperatorResponseDto,
TestConnectionDto,
OperatorStatsDto,
} from './dto/operator.dto';
@Controller() @ApiTags('operators')
@Controller('operators')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
export class OperatorsController { export class OperatorsController {
constructor(private readonly operatorsService: OperatorsService) {}
@Get() @Get()
getHello(): string { @ApiOperation({ summary: 'List all available operators' })
return 'Hello World!'; @ApiQuery({ name: 'country', required: false })
@ApiQuery({ name: 'active', required: false, type: Boolean })
@ApiResponse({
status: 200,
description: 'List of operators',
type: [OperatorResponseDto],
})
async listOperators(
@Query('country') country?: string,
@Query('active') active?: boolean,
) {
return this.operatorsService.listOperators({ country, active });
} }
@Get('supported-countries')
@ApiOperation({ summary: 'Get list of supported countries' })
@ApiResponse({
status: 200,
description: 'List of supported countries with operators',
})
async getSupportedCountries() {
return this.operatorsService.getSupportedCountries();
}
@Get(':operatorCode/config')
@ApiOperation({ summary: 'Get operator configuration' })
@ApiResponse({
status: 200,
description: 'Operator configuration',
type: OperatorConfigDto,
})
async getOperatorConfig(@Param('operatorCode') operatorCode: string) {
return this.operatorsService.getOperatorConfig(operatorCode);
}
@Get(':operatorCode/status')
@ApiOperation({ summary: 'Check operator service status' })
@ApiResponse({
status: 200,
description: 'Operator service status',
})
async checkOperatorStatus(@Param('operatorCode') operatorCode: string) {
return this.operatorsService.checkOperatorStatus(operatorCode);
}
@Post(':operatorCode/test-connection')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Test connection to operator' })
@ApiResponse({
status: 200,
description: 'Connection test result',
})
async testConnection(
@Param('operatorCode') operatorCode: string,
@Body() testDto: TestConnectionDto,
) {
return this.operatorsService.testConnection(operatorCode, testDto);
}
@Get(':operatorCode/statistics')
@ApiOperation({ summary: 'Get operator statistics' })
@ApiQuery({ name: 'period', required: false, enum: ['daily', 'weekly', 'monthly', 'yearly'] })
@ApiQuery({ name: 'startDate', required: false, type: Date })
@ApiQuery({ name: 'endDate', required: false, type: Date })
@ApiResponse({
status: 200,
description: 'Operator statistics',
type: OperatorStatsDto,
})
async getOperatorStatistics(
@Request() req,
@Param('operatorCode') operatorCode: string,
@Query('period') period: string = 'monthly',
@Query('startDate') startDate?: string,
@Query('endDate') endDate?: string,
) {
return this.operatorsService.getOperatorStatistics({
partnerId: req.user.partnerId,
operatorCode,
period,
startDate: startDate ? new Date(startDate) : undefined,
endDate: endDate ? new Date(endDate) : undefined,
});
}
@Get(':operatorCode/health')
@ApiOperation({ summary: 'Get operator health metrics' })
@ApiResponse({
status: 200,
description: 'Operator health metrics',
})
async getOperatorHealth(@Param('operatorCode') operatorCode: string) {
return this.operatorsService.getOperatorHealth(operatorCode);
}
@Get('detect/:msisdn')
@ApiOperation({ summary: 'Detect operator from MSISDN' })
@ApiResponse({
status: 200,
description: 'Detected operator information',
})
async detectOperator(@Param('msisdn') msisdn: string) {
return this.operatorsService.detectOperatorByMsisdn(msisdn);
}
@Get(':operatorCode/pricing')
@ApiOperation({ summary: 'Get operator pricing information' })
@ApiResponse({
status: 200,
description: 'Operator pricing details',
})
async getOperatorPricing(@Param('operatorCode') operatorCode: string) {
return this.operatorsService.getOperatorPricing(operatorCode);
}
@Get(':operatorCode/capabilities')
@ApiOperation({ summary: 'Get operator capabilities' })
@ApiResponse({
status: 200,
description: 'Operator capabilities and features',
})
async getOperatorCapabilities(@Param('operatorCode') operatorCode: string) {
return this.operatorsService.getOperatorCapabilities(operatorCode);
}
@Put(':operatorCode/toggle-status')
@ApiOperation({ summary: 'Enable/Disable operator (Admin only)' })
@ApiResponse({
status: 200,
description: 'Operator status updated',
})
async toggleOperatorStatus(
@Request() req,
@Param('operatorCode') operatorCode: string,
@Body() body: { active: boolean; reason?: string },
) {
// Add admin check here
return this.operatorsService.toggleOperatorStatus(
operatorCode,
body.active,
body.reason,
);
}
} }

View File

@ -1,7 +1,416 @@
import { BadRequestException, NotFoundException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { PrismaService } from "src/shared/services/prisma.service";
import { OperatorAdapterFactory } from "./adapters/operator-adapter.factory";
import { HttpService } from "@nestjs/axios";
import { firstValueFrom } from 'rxjs';
//todo tomaj //todo tomaj
export class OperatorsService{ export class OperatorsService{
getAdapter(code: any, country: any) :any{
throw new Error('Method not implemented.'); constructor(
private readonly prisma: PrismaService,
private readonly configService: ConfigService,
private readonly adapterFactory: OperatorAdapterFactory,
private readonly httpService: HttpService,
) {}
getAdapter(operator: string, country: string) {
return this.adapterFactory.getAdapter(operator, country);
}
async listOperators(filters?: { country?: string; active?: boolean }) {
const where: any = {};
if (filters?.country) {
where.country = filters.country;
}
if (filters?.active !== undefined) {
where.active = filters.active;
}
const operators = await this.prisma.operator.findMany({
where,
orderBy: { name: 'asc' },
});
return operators.map(op => ({
id: op.id,
code: op.code,
name: op.name,
country: op.country,
active: op.active,
features: this.extractFeatures(op.config),
}));
}
async getSupportedCountries() {
const operators = await this.prisma.operator.findMany({
where: { active: true },
distinct: ['country'],
select: {
country: true,
code: true,
name: true,
},
});
const countriesMap = new Map();
operators.forEach(op => {
if (!countriesMap.has(op.country)) {
countriesMap.set(op.country, {
code: op.country,
name: this.getCountryName(op.country),
operators: [],
});
}
countriesMap.get(op.country).operators.push({
code: op.code,
name: op.name,
});
});
return Array.from(countriesMap.values());
}
async getOperatorConfig(operatorCode: string) {
const operator = await this.prisma.operator.findFirst({
where: { code: operatorCode as any },
});
if (!operator) {
throw new NotFoundException('Operator not found');
}
const config = this.configService.get(`operators.${operatorCode}_${operator.country}`);
return {
...operator,
endpoints: config?.endpoints,
headers: config?.headers,
features: this.extractFeatures(operator.config),
};
}
async checkOperatorStatus(operatorCode: string) {
const operator = await this.prisma.operator.findFirst({
where: { code: operatorCode as any },
});
if (!operator) {
throw new NotFoundException('Operator not found');
}
const config = operator.config as any;
const healthEndpoint = config?.healthEndpoint || '/health';
const baseUrl = config?.baseUrl;
if (!baseUrl) {
return {
status: 'UNKNOWN',
message: 'No health endpoint configured',
};
}
try {
const response = await firstValueFrom(
this.httpService.get(`${baseUrl}${healthEndpoint}`, {
timeout: 5000,
}),
);
return {
status: 'OPERATIONAL',
responseTime: response.headers['x-response-time'] || 'N/A',
timestamp: new Date(),
};
} catch (error) {
return {
status: 'DOWN',
error: error.message,
timestamp: new Date(),
};
}
}
async testConnection(operatorCode: string, testDto: any) {
const adapter = this.adapterFactory.getAdapter(operatorCode, testDto.country);
try {
const result = await adapter.initializeAuth({
msisdn: testDto.testMsisdn,
country: testDto.country,
metadata: { test: true },
});
return {
success: true,
message: 'Connection successful',
details: {
sessionId: result.sessionId,
authMethod: result.authMethod,
},
};
} catch (error) {
return {
success: false,
message: 'Connection failed',
error: error.message,
};
}
}
async getOperatorStatistics(params: any) {
const { partnerId, operatorCode, startDate, endDate } = params;
const where: any = {
partner: { id: partnerId },
user: {
operator: { code: operatorCode as any },
},
};
if (startDate || endDate) {
where.createdAt = {};
if (startDate) where.createdAt.gte = startDate;
if (endDate) where.createdAt.lte = endDate;
}
const [
totalPayments,
successfulPayments,
totalRevenue,
uniqueUsers,
] = await Promise.all([
this.prisma.payment.count({ where }),
this.prisma.payment.count({ where: { ...where, status: 'SUCCESS' } }),
this.prisma.payment.aggregate({
where: { ...where, status: 'SUCCESS' },
_sum: { amount: true },
}),
this.prisma.payment.findMany({
where,
distinct: ['userId'],
select: { userId: true },
}),
]);
const successRate = totalPayments > 0
? (successfulPayments / totalPayments) * 100
: 0;
return {
operatorCode,
totalPayments,
successfulPayments,
failedPayments: totalPayments - successfulPayments,
successRate: Math.round(successRate * 100) / 100,
totalRevenue: totalRevenue._sum.amount || 0,
uniqueUsers: uniqueUsers.length,
period: params.period,
};
}
async getOperatorHealth(operatorCode: string) {
const now = new Date();
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
const [recentPayments, recentFailures] = await Promise.all([
this.prisma.payment.count({
where: {
createdAt: { gte: oneHourAgo },
user: {
operator: { code: operatorCode as any },
},
},
}),
this.prisma.payment.count({
where: {
createdAt: { gte: oneHourAgo },
status: 'FAILED',
user: {
operator: { code: operatorCode as any },
},
},
}),
]);
const errorRate = recentPayments > 0
? (recentFailures / recentPayments) * 100
: 0;
return {
operatorCode,
status: errorRate > 20 ? 'DEGRADED' : 'HEALTHY',
metrics: {
recentTransactions: recentPayments,
recentFailures,
errorRate: Math.round(errorRate * 100) / 100,
lastChecked: now,
},
};
}
async detectOperatorByMsisdn(msisdn: string) {
const cleanMsisdn = msisdn.replace(/[^0-9]/g, '');
// Extract country code
const countryCode = this.extractCountryCode(cleanMsisdn);
if (!countryCode) {
throw new BadRequestException('Unable to detect country from MSISDN');
}
// Get operator based on prefix
const operator = this.detectOperatorFromPrefix(cleanMsisdn, countryCode);
return {
msisdn: cleanMsisdn,
countryCode,
operator,
country: this.getCountryFromCode(countryCode),
};
}
async getOperatorPricing(operatorCode: string) {
const operator = await this.prisma.operator.findFirst({
where: { code: operatorCode as any },
});
if (!operator) {
throw new NotFoundException('Operator not found');
}
const config = operator.config as any;
return {
operatorCode,
pricing: config?.pricing || {
transactionFee: 0.02,
percentageFee: 0.03,
currency: 'USD',
},
};
}
async getOperatorCapabilities(operatorCode: string) {
const operator = await this.prisma.operator.findFirst({
where: { code: operatorCode as any },
});
if (!operator) {
throw new NotFoundException('Operator not found');
}
const config = operator.config as any;
return {
operatorCode,
capabilities: {
authMethods: config?.authMethods || ['OTP'],
paymentMethods: ['DCB'],
supportedCurrencies: config?.currencies || ['XOF'],
features: {
subscription: config?.features?.subscription || false,
refund: config?.features?.refund || false,
partialRefund: config?.features?.partialRefund || false,
sms: config?.features?.sms || true,
},
},
};
}
async toggleOperatorStatus(operatorCode: string, active: boolean, reason?: string) {
const operator = await this.prisma.operator.findFirst({
where: { code: operatorCode as any },
});
if (!operator) {
throw new NotFoundException('Operator not found');
}
const updated = await this.prisma.operator.update({
where: { id: operator.id },
data: {
active,
config: {
...(operator.config as any),
statusChangeReason: reason,
statusChangedAt: new Date(),
},
},
});
return {
operatorCode,
active: updated.active,
message: `Operator ${active ? 'enabled' : 'disabled'} successfully`,
};
}
private extractFeatures(config: any) {
return {
subscription: config?.features?.subscription || false,
refund: config?.features?.refund || false,
sms: config?.features?.sms || true,
ussd: config?.features?.ussd || false,
};
}
private getCountryName(code: string): string {
const countries = {
CI: 'Côte d\'Ivoire',
SN: 'Sénégal',
CM: 'Cameroun',
CD: 'RD Congo',
TN: 'Tunisie',
BF: 'Burkina Faso',
};
return countries[code] || code;
}
private extractCountryCode(msisdn: string): string {
const prefixes = {
'225': 'CI',
'221': 'SN',
'237': 'CM',
'243': 'CD',
'216': 'TN',
'226': 'BF',
};
for (const [prefix, country] of Object.entries(prefixes)) {
if (msisdn.startsWith(prefix)) {
return country;
}
}
return null;
}
private detectOperatorFromPrefix(msisdn: string, countryCode: string): string {
// Simplified logic - should be extended based on actual prefix mappings
const prefixMaps = {
CI: {
'07': 'ORANGE',
'08': 'ORANGE',
'05': 'MTN',
'04': 'MTN',
},
// Add other countries
};
const countryMap = prefixMaps[countryCode];
if (!countryMap) return 'UNKNOWN';
const localNumber = msisdn.replace(/^225|^221|^237/, '');
const prefix = localNumber.substring(0, 2);
return countryMap[prefix] || 'UNKNOWN';
}
private getCountryFromCode(code: string): string {
return this.getCountryName(code);
} }
} }

View File

@ -87,3 +87,92 @@ export class UpdateCallbacksDto {
@IsUrl() @IsUrl()
webhook?: string; webhook?: string;
} }
export class LoginPartnerDto {
@ApiProperty()
@IsEmail()
email: string;
@ApiProperty()
@IsString()
password: string;
}
export class ChangePasswordDto {
@ApiProperty()
@IsString()
currentPassword: string;
@ApiProperty()
@IsString()
@MinLength(8)
newPassword: string;
}
export class UpdatePartnerDto {
@ApiProperty({ required: false })
@IsOptional()
@IsString()
name?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsObject()
companyInfo?: {
legalName?: string;
taxId?: string;
address?: string;
phone?: string;
website?: string;
};
@ApiProperty({ required: false })
@IsOptional()
metadata?: Record<string, any>;
}
export class PartnerResponseDto {
@ApiProperty()
id: string;
@ApiProperty()
name: string;
@ApiProperty()
email: string;
@ApiProperty()
status: string;
@ApiProperty()
apiKey: string;
@ApiProperty()
country: string;
@ApiProperty({ required: false })
companyInfo?: any;
@ApiProperty()
createdAt: Date;
}
export class PartnerStatsResponseDto {
@ApiProperty()
totalUsers: number;
@ApiProperty()
activeSubscriptions: number;
@ApiProperty()
todayPayments: number;
@ApiProperty()
monthRevenue: number;
@ApiProperty()
successRate: number;
@ApiProperty()
avgPaymentAmount: number;
}

View File

@ -1,9 +1,342 @@
//todo import {
import { Controller, Get } from "@nestjs/common"; Controller,
@Controller() Post,
Get,
Put,
Delete,
Body,
Param,
Query,
UseGuards,
Request,
HttpCode,
HttpStatus,
Patch,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiBearerAuth,
ApiResponse,
ApiQuery,
} from '@nestjs/swagger';
import { PartnersService } from './partners.service';
import {
CreatePartnerDto,
UpdatePartnerDto,
UpdateCallbacksDto,
LoginPartnerDto,
ChangePasswordDto,
PartnerResponseDto,
PartnerStatsResponseDto,
} from './dto/partner.dto';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { Public } from '../../common/decorators/public.decorator';
import { AuthService } from '../auth/auth.service';
@ApiTags('partners')
@Controller('partners')
export class PartnersController { export class PartnersController {
@Get() constructor(
getHello(): string { private readonly partnersService: PartnersService,
return 'Hello World!'; private readonly authService: AuthService,
) {}
@Post('register')
@Public()
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Register a new partner' })
@ApiResponse({
status: 201,
description: 'Partner registered successfully',
type: PartnerResponseDto,
})
@ApiResponse({ status: 409, description: 'Email already exists' })
async register(@Body() createPartnerDto: CreatePartnerDto) {
return this.partnersService.register(createPartnerDto);
}
@Post('login')
@Public()
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Partner login' })
@ApiResponse({
status: 200,
description: 'Login successful',
})
@ApiResponse({ status: 401, description: 'Invalid credentials' })
async login(@Body() loginDto: LoginPartnerDto) {
return this.authService.loginPartner(loginDto);
}
@Get('profile')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Get partner profile' })
@ApiResponse({
status: 200,
description: 'Partner profile retrieved',
type: PartnerResponseDto,
})
async getProfile(@Request() req) {
return this.partnersService.getPartner(req.user.partnerId);
}
@Put('profile')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Update partner profile' })
@ApiResponse({
status: 200,
description: 'Profile updated successfully',
type: PartnerResponseDto,
})
async updateProfile(
@Request() req,
@Body() updatePartnerDto: UpdatePartnerDto,
) {
return this.partnersService.updatePartner(
req.user.partnerId,
updatePartnerDto,
);
}
@Put('callbacks')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Update callback URLs' })
@ApiResponse({
status: 200,
description: 'Callbacks updated successfully',
})
async updateCallbacks(
@Request() req,
@Body() updateCallbacksDto: UpdateCallbacksDto,
) {
return this.partnersService.updateCallbacks(
req.user.partnerId,
updateCallbacksDto,
);
}
@Get('callbacks')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Get callback configuration' })
@ApiResponse({
status: 200,
description: 'Callback configuration retrieved',
})
async getCallbacks(@Request() req) {
return this.partnersService.getCallbacks(req.user.partnerId);
}
@Post('keys/regenerate')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Regenerate API keys' })
@ApiResponse({
status: 200,
description: 'Keys regenerated successfully',
})
async regenerateKeys(@Request() req) {
return this.partnersService.regenerateKeys(req.user.partnerId);
}
@Get('statistics')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Get partner statistics' })
@ApiResponse({
status: 200,
description: 'Statistics retrieved',
type: PartnerStatsResponseDto,
})
async getStatistics(@Request() req) {
return this.partnersService.getPartnerStats(req.user.partnerId);
}
@Get('statistics/dashboard')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Get dashboard statistics' })
@ApiQuery({ name: 'period', required: false, enum: ['daily', 'weekly', 'monthly', 'yearly'] })
async getDashboardStats(
@Request() req,
@Query('period') period: string = 'monthly',
) {
return this.partnersService.getDashboardStats(req.user.partnerId, period);
}
@Post('password/change')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Change password' })
@ApiResponse({
status: 200,
description: 'Password changed successfully',
})
async changePassword(
@Request() req,
@Body() changePasswordDto: ChangePasswordDto,
) {
return this.partnersService.changePassword(
req.user.partnerId,
changePasswordDto,
);
}
@Get('users')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'List partner users' })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'limit', required: false, type: Number })
@ApiQuery({ name: 'status', required: false })
async listUsers(
@Request() req,
@Query('page') page: number = 1,
@Query('limit') limit: number = 20,
@Query('status') status?: string,
) {
return this.partnersService.listPartnerUsers(req.user.partnerId, {
page,
limit,
status,
});
}
@Get('users/:userId')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Get user details' })
async getUserDetails(@Request() req, @Param('userId') userId: string) {
return this.partnersService.getUserDetails(req.user.partnerId, userId);
}
@Get('transactions')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Get transaction history' })
@ApiQuery({ name: 'startDate', required: false, type: Date })
@ApiQuery({ name: 'endDate', required: false, type: Date })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'limit', required: false, type: Number })
async getTransactions(
@Request() req,
@Query('startDate') startDate?: string,
@Query('endDate') endDate?: string,
@Query('page') page: number = 1,
@Query('limit') limit: number = 20,
) {
return this.partnersService.getTransactionHistory(req.user.partnerId, {
startDate: startDate ? new Date(startDate) : undefined,
endDate: endDate ? new Date(endDate) : undefined,
page,
limit,
});
}
@Get('revenue')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Get revenue analytics' })
@ApiQuery({ name: 'period', required: false, enum: ['daily', 'weekly', 'monthly', 'yearly'] })
@ApiQuery({ name: 'groupBy', required: false, enum: ['day', 'week', 'month', 'operator', 'country'] })
async getRevenue(
@Request() req,
@Query('period') period: string = 'monthly',
@Query('groupBy') groupBy: string = 'day',
) {
return this.partnersService.getRevenueAnalytics(req.user.partnerId, {
period,
groupBy,
});
}
@Post('webhook/test')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Test webhook configuration' })
async testWebhook(
@Request() req,
@Body() body: { url: string; event: string },
) {
return this.partnersService.testWebhook(
req.user.partnerId,
body.url,
body.event,
);
}
@Get('settings')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Get partner settings' })
async getSettings(@Request() req) {
return this.partnersService.getSettings(req.user.partnerId);
}
@Put('settings')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Update partner settings' })
async updateSettings(@Request() req, @Body() settings: any) {
return this.partnersService.updateSettings(req.user.partnerId, settings);
}
@Get('api-usage')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Get API usage statistics' })
@ApiQuery({ name: 'period', required: false, enum: ['hourly', 'daily', 'weekly', 'monthly'] })
async getApiUsage(
@Request() req,
@Query('period') period: string = 'daily',
) {
return this.partnersService.getApiUsageStats(req.user.partnerId, period);
}
@Get('logs')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Get activity logs' })
@ApiQuery({ name: 'type', required: false })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'limit', required: false, type: Number })
async getActivityLogs(
@Request() req,
@Query('type') type?: string,
@Query('page') page: number = 1,
@Query('limit') limit: number = 50,
) {
return this.partnersService.getActivityLogs(req.user.partnerId, {
type,
page,
limit,
});
}
@Delete('account')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Delete partner account' })
@ApiResponse({
status: 200,
description: 'Account deletion initiated',
})
async deleteAccount(
@Request() req,
@Body() body: { password: string; reason?: string },
) {
return this.partnersService.deleteAccount(
req.user.partnerId,
body.password,
body.reason,
);
} }
} }

View File

@ -2,8 +2,10 @@ import { Module } from '@nestjs/common';
import { PartnersController } from './partners.controller'; import { PartnersController } from './partners.controller';
import { PartnersService } from './partners.service'; import { PartnersService } from './partners.service';
import { PrismaService } from '../../shared/services/prisma.service'; import { PrismaService } from '../../shared/services/prisma.service';
import { AuthModule } from '../auth/auth.module';
@Module({ @Module({
imports:[AuthModule],
controllers: [PartnersController], controllers: [PartnersController],
providers: [PartnersService, PrismaService], providers: [PartnersService, PrismaService],
exports: [PartnersService], exports: [PartnersService],

View File

@ -6,11 +6,53 @@ import {
import { PrismaService } from '../../shared/services/prisma.service'; import { PrismaService } from '../../shared/services/prisma.service';
import * as bcrypt from 'bcrypt'; import * as bcrypt from 'bcrypt';
import * as crypto from 'crypto'; import * as crypto from 'crypto';
import { CreatePartnerDto, UpdateCallbacksDto } from './dto/partner.dto'; import { ChangePasswordDto, CreatePartnerDto, UpdateCallbacksDto, UpdatePartnerDto } from './dto/partner.dto';
import { Prisma } from 'generated/prisma'; import { Prisma } from 'generated/prisma';
@Injectable() @Injectable()
export class PartnersService { export class PartnersService {
getActivityLogs(partnerId: any, arg1: { type: string | undefined; page: number; limit: number; }) {
throw new Error('Method not implemented.');
}
getApiUsageStats(partnerId: any, period: string) {
throw new Error('Method not implemented.');
}
getSettings(partnerId: any) {
throw new Error('Method not implemented.');
}
updateSettings(partnerId: any, settings: any) {
throw new Error('Method not implemented.');
}
getTransactionHistory(partnerId: any, arg1: { startDate: Date | undefined; endDate: Date | undefined; page: number; limit: number; }) {
throw new Error('Method not implemented.');
}
listPartnerUsers(partnerId: any, arg1: { page: number; limit: number; status: string | undefined; }) {
throw new Error('Method not implemented.');
}
testWebhook(partnerId: any, url: string, event: string) {
throw new Error('Method not implemented.');
}
getRevenueAnalytics(partnerId: any, arg1: { period: string; groupBy: string; }) {
throw new Error('Method not implemented.');
}
getCallbacks(partnerId: any) {
throw new Error('Method not implemented.');
}
getUserDetails(partnerId: any, userId: string) {
throw new Error('Method not implemented.');
}
changePassword(partnerId: any, changePasswordDto: ChangePasswordDto) {
throw new Error('Method not implemented.');
}
getDashboardStats(partnerId: any, period: string) {
throw new Error('Method not implemented.');
}
updatePartner(partnerId: any, updatePartnerDto: UpdatePartnerDto) {
throw new Error('Method not implemented.');
}
deleteAccount(partnerId: any, password: string, reason: string | undefined) {
throw new Error('Method not implemented.');
}
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
async register(dto: CreatePartnerDto) { async register(dto: CreatePartnerDto) {