fix it otp challenge
This commit is contained in:
parent
df7ae8dbeb
commit
f87650dcc2
@ -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();
|
||||||
|
|||||||
287
src/modules/challenge/adaptor/orange.adaptor.ts
Normal file
287
src/modules/challenge/adaptor/orange.adaptor.ts
Normal 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'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
46
src/modules/challenge/adaptor/orange.config.ts
Normal file
46
src/modules/challenge/adaptor/orange.config.ts
Normal 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',
|
||||||
|
};
|
||||||
82
src/modules/challenge/dto/challenge.request.dto.ts
Normal file
82
src/modules/challenge/dto/challenge.request.dto.ts
Normal 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>;
|
||||||
|
}
|
||||||
53
src/modules/challenge/dto/challenge.response.dto.ts
Normal file
53
src/modules/challenge/dto/challenge.response.dto.ts
Normal 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
421
src/modules/challenge/otp.challenge.controller.ts
Normal file
421
src/modules/challenge/otp.challenge.controller.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
50
src/modules/challenge/otp.challenge.interface.ts
Normal file
50
src/modules/challenge/otp.challenge.interface.ts
Normal 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>;
|
||||||
|
}
|
||||||
39
src/modules/challenge/otp.challenge.module.ts
Normal file
39
src/modules/challenge/otp.challenge.module.ts
Normal 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 {}
|
||||||
268
src/modules/challenge/otp.challenge.service.ts
Normal file
268
src/modules/challenge/otp.challenge.service.ts
Normal 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'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
75
src/modules/operators/dto/operator.dto.ts
Normal file
75
src/modules/operators/dto/operator.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -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')
|
||||||
export class OperatorsController{
|
@Controller('operators')
|
||||||
@Get()
|
@UseGuards(JwtAuthGuard)
|
||||||
getHello(): string {
|
@ApiBearerAuth()
|
||||||
return 'Hello World!';
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@ -1,9 +1,342 @@
|
|||||||
//todo
|
import {
|
||||||
import { Controller, Get } from "@nestjs/common";
|
Controller,
|
||||||
@Controller()
|
Post,
|
||||||
export class PartnersController{
|
Get,
|
||||||
@Get()
|
Put,
|
||||||
getHello(): string {
|
Delete,
|
||||||
return 'Hello World!';
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -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],
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user