diff --git a/src/main.ts b/src/main.ts index cd22145..c33fe8e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -28,7 +28,7 @@ async function bootstrap() { const config = new DocumentBuilder() .setTitle('Payment Hub API') .setDescription('Unified DCB Payment Aggregation Platform') - .setVersion('2.0.0') + .setVersion('1.0.0') .addBearerAuth() .addTag('auth') .addTag('payments') @@ -38,9 +38,14 @@ async function bootstrap() { const document = SwaggerModule.createDocument(app, config); SwaggerModule.setup('api/docs', app, document); + app.getHttpAdapter().get('/api/swagger-json', (req, res) => { + res.json(document); + }); + const port = process.env.PORT || 3000; await app.listen(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/swagger-json`); } bootstrap(); diff --git a/src/modules/challenge/adaptor/orange.adaptor.ts b/src/modules/challenge/adaptor/orange.adaptor.ts new file mode 100644 index 0000000..837747c --- /dev/null +++ b/src/modules/challenge/adaptor/orange.adaptor.ts @@ -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 { + // 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 { + 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( + 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 { + 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( + 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' + } + }; + } + } +} \ No newline at end of file diff --git a/src/modules/challenge/adaptor/orange.config.ts b/src/modules/challenge/adaptor/orange.config.ts new file mode 100644 index 0000000..1289864 --- /dev/null +++ b/src/modules/challenge/adaptor/orange.config.ts @@ -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 = { + 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 = { + '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 = { + 'SMS': 'OTP-SMS-AUTH', + 'USSD': 'OTP-USSD-AUTH', + 'IVR': 'OTP-IVR-AUTH', +}; \ No newline at end of file diff --git a/src/modules/challenge/dto/challenge.request.dto.ts b/src/modules/challenge/dto/challenge.request.dto.ts new file mode 100644 index 0000000..6220386 --- /dev/null +++ b/src/modules/challenge/dto/challenge.request.dto.ts @@ -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; +} \ No newline at end of file diff --git a/src/modules/challenge/dto/challenge.response.dto.ts b/src/modules/challenge/dto/challenge.response.dto.ts new file mode 100644 index 0000000..2574a52 --- /dev/null +++ b/src/modules/challenge/dto/challenge.response.dto.ts @@ -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; + + @IsOptional() + error?: { + code: string; + message: string; + description?: string; + }; +} \ No newline at end of file diff --git a/src/modules/challenge/otp.challenge.controller.ts b/src/modules/challenge/otp.challenge.controller.ts new file mode 100644 index 0000000..3d2376d --- /dev/null +++ b/src/modules/challenge/otp.challenge.controller.ts @@ -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 { + 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 { + 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 { + 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 { + 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 + } +} \ No newline at end of file diff --git a/src/modules/challenge/otp.challenge.interface.ts b/src/modules/challenge/otp.challenge.interface.ts new file mode 100644 index 0000000..44f48bb --- /dev/null +++ b/src/modules/challenge/otp.challenge.interface.ts @@ -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; + + /** + * 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; + + /** + * 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; + + /** + * 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; +} \ No newline at end of file diff --git a/src/modules/challenge/otp.challenge.module.ts b/src/modules/challenge/otp.challenge.module.ts new file mode 100644 index 0000000..b15ea6f --- /dev/null +++ b/src/modules/challenge/otp.challenge.module.ts @@ -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('ORANGE_BASE_URL', 'https://api-gateway.app.cameleonapp.com/api-orange-dcb/1.0.0'), + partnerId: configService.get('ORANGE_PARTNER_ID', 'PDKSUB'), + clientId: configService.get('ORANGE_CLIENT_ID', 'admin'), + clientSecret: configService.get('ORANGE_CLIENT_SECRET', 'admin'), + defaultService: configService.get('ORANGE_DEFAULT_SERVICE', 'DCB_SERVICE'), + defaultOtpLength: configService.get('ORANGE_DEFAULT_OTP_LENGTH', 4), + defaultSenderName: configService.get('ORANGE_DEFAULT_SENDER_NAME', 'OTP'), + defaultOtpMessage: configService.get( + 'ORANGE_DEFAULT_OTP_MESSAGE', + 'To confirm your purchase please enter the code %OTP%' + ), + tokenEndpoint: '/oauth/v3/token', + challengeEndpoint: '/challenge/v1/challenges', + timeout: configService.get('ORANGE_TIMEOUT', 30000), + }), + inject: [ConfigService], + }, + OtpChallengeService, + ], + exports: [OtpChallengeService], +}) +export class OtpChallengeModule {} \ No newline at end of file diff --git a/src/modules/challenge/otp.challenge.service.ts b/src/modules/challenge/otp.challenge.service.ts new file mode 100644 index 0000000..e70af31 --- /dev/null +++ b/src/modules/challenge/otp.challenge.service.ts @@ -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; + + 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 { + 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 { + 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 { + 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 { + 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' + } + }; + } +} \ No newline at end of file diff --git a/src/modules/operators/dto/operator.dto.ts b/src/modules/operators/dto/operator.dto.ts new file mode 100644 index 0000000..20483af --- /dev/null +++ b/src/modules/operators/dto/operator.dto.ts @@ -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; +} \ No newline at end of file diff --git a/src/modules/operators/operators.controller.ts b/src/modules/operators/operators.controller.ts index e940876..a7a5bf7 100644 --- a/src/modules/operators/operators.controller.ts +++ b/src/modules/operators/operators.controller.ts @@ -1,11 +1,182 @@ -//todo -import { Controller, Get } from "@nestjs/common"; +import { + 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() -export class OperatorsController{ - @Get() - getHello(): string { - return 'Hello World!'; - } - +@ApiTags('operators') +@Controller('operators') +@UseGuards(JwtAuthGuard) +@ApiBearerAuth() +export class OperatorsController { + constructor(private readonly operatorsService: OperatorsService) {} + + @Get() + @ApiOperation({ summary: 'List all available operators' }) + @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, + ); + } } \ No newline at end of file diff --git a/src/modules/operators/operators.service.ts b/src/modules/operators/operators.service.ts index fae4b36..8710deb 100644 --- a/src/modules/operators/operators.service.ts +++ b/src/modules/operators/operators.service.ts @@ -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 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); } } \ No newline at end of file diff --git a/src/modules/partners/dto/partner.dto.ts b/src/modules/partners/dto/partner.dto.ts index 73dc1ce..8bd74a7 100644 --- a/src/modules/partners/dto/partner.dto.ts +++ b/src/modules/partners/dto/partner.dto.ts @@ -87,3 +87,92 @@ export class UpdateCallbacksDto { @IsUrl() 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; +} + +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; +} diff --git a/src/modules/partners/partners.controller.ts b/src/modules/partners/partners.controller.ts index 0c7f554..fd17797 100644 --- a/src/modules/partners/partners.controller.ts +++ b/src/modules/partners/partners.controller.ts @@ -1,9 +1,342 @@ -//todo -import { Controller, Get } from "@nestjs/common"; -@Controller() -export class PartnersController{ - @Get() - getHello(): string { - return 'Hello World!'; - } +import { + 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 { + constructor( + private readonly partnersService: PartnersService, + 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, + ); + } } \ No newline at end of file diff --git a/src/modules/partners/partners.module.ts b/src/modules/partners/partners.module.ts index e2e1340..bd1f5f6 100644 --- a/src/modules/partners/partners.module.ts +++ b/src/modules/partners/partners.module.ts @@ -2,8 +2,10 @@ import { Module } from '@nestjs/common'; import { PartnersController } from './partners.controller'; import { PartnersService } from './partners.service'; import { PrismaService } from '../../shared/services/prisma.service'; +import { AuthModule } from '../auth/auth.module'; @Module({ + imports:[AuthModule], controllers: [PartnersController], providers: [PartnersService, PrismaService], exports: [PartnersService], diff --git a/src/modules/partners/partners.service.ts b/src/modules/partners/partners.service.ts index 8cec1e1..e6f1bf1 100644 --- a/src/modules/partners/partners.service.ts +++ b/src/modules/partners/partners.service.ts @@ -6,11 +6,53 @@ import { import { PrismaService } from '../../shared/services/prisma.service'; import * as bcrypt from 'bcrypt'; 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'; @Injectable() 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) {} async register(dto: CreatePartnerDto) {