payment object

This commit is contained in:
Mamadou Khoussa [028918 DSI/DAC/DIF/DS] 2025-11-14 12:09:56 +00:00
parent 767201ec06
commit d8ad43a56a
11 changed files with 283 additions and 59 deletions

View File

@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { HttpService } from '@nestjs/axios';
import axios, { AxiosInstance, AxiosError } from 'axios';
import { ConfigService } from '@nestjs/config';
import { firstValueFrom } from 'rxjs';
import {
@ -10,27 +11,49 @@ import {
ChargeResponse,
} from './operator.adapter.interface';
import { OrangeTransformer } from '../transformers/orange.transformer';
import {
DEFAULT_ORANGE_CONFIG,
COUNTRY_CODE_MAPPING,
} from './orange.config';
import type { OrangeConfig } from './orange.config';
@Injectable()
export class OrangeAdapter implements IOperatorAdapter {
private readonly logger = new Logger(OrangeAdapter.name);
private config: OrangeConfig;
private baseUrl: string;
private accessToken: string;
private transformer: OrangeTransformer;
private tokenExpiresAt: number = 0;
private axiosInstance: AxiosInstance;
constructor(
private readonly httpService: HttpService,
private readonly configService: ConfigService,
private readonly httpService: HttpService,
@Inject('ORANGE_CONFIG')config: OrangeConfig,
) {
this.baseUrl = this.configService.get('ORANGE_API_URL') as string;
this.accessToken = this.configService.get('ORANGE_ACCESS_TOKEN') as string;
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: '*/*',
},
});
this.axiosInstance.interceptors.response.use(
response => response,
error => this.handleError(error)
);
this.transformer = new OrangeTransformer();
}
async initializeAuth(params: AuthInitParams): Promise<AuthInitResponse> {
const countryCode = this.getCountryCode(params.country);
const bizaoRequest = {
const hubRequest = {
challenge: {
method: 'OTP-SMS-AUTH',
country: countryCode,
@ -64,7 +87,7 @@ export class OrangeAdapter implements IOperatorAdapter {
const response = await firstValueFrom(
this.httpService.post(
`${this.baseUrl}/challenge/v1/challenges`,
bizaoRequest,
hubRequest,
{
headers: {
Authorization: `Bearer ${this.accessToken}`,
@ -87,7 +110,7 @@ export class OrangeAdapter implements IOperatorAdapter {
}
async validateAuth(params: any): Promise<any> {
const bizaoRequest = {
const hubRequest = {
challenge: {
method: 'OTP-SMS-AUTH',
country: params.country,
@ -113,7 +136,7 @@ export class OrangeAdapter implements IOperatorAdapter {
const response = await firstValueFrom(
this.httpService.post(
`${this.baseUrl}/challenge/v1/challenges/${params.challengeId}`,
bizaoRequest,
hubRequest,
{
headers: {
Authorization: `Bearer ${this.accessToken}`,
@ -139,7 +162,11 @@ export class OrangeAdapter implements IOperatorAdapter {
}
async charge(params: ChargeParams): Promise<ChargeResponse> {
const bizaoRequest = {
this.logger.debug(
`[orange adapter charge ]: ${JSON.stringify(params, null, 2)}`,
);
const hubRequest = {
amountTransaction: {
endUserId: 'acr:OrangeAPIToken',
paymentAmount: {
@ -149,31 +176,38 @@ export class OrangeAdapter implements IOperatorAdapter {
description: params.description,
},
chargingMetaData: {
onBehalfOf: 'PaymentHub',
onBehalfOf: 'PaymentHub', //from config todo
purchaseCategoryCode: 'Service', //todo from config
serviceId: 'BIZAO',
},
},
transactionOperationStatus: 'Charged',
referenceCode: params.reference,
clientCorrelator: `${params.reference}-${Date.now()}`,
clientCorrelator: `${params.reference}-${Date.now()}`, //uniquely identifies this create charge request.
},
};
const token = await this.getAccessToken();
this.logger.debug(
`[requesting to ]: ${this.config.baseUrl}/payment/v1/acr%3AOrangeAPIToken/transactions/amount`,
);
const response = await firstValueFrom(
this.httpService.post(
`${this.baseUrl}/payment/v1/acr%3AOrangeAPIToken/transactions/amount`,
bizaoRequest,
`${this.config.baseUrl}}/payment/v1/acr%3AOrangeAPIToken/transactions/amount`,
hubRequest,
{
headers: {
Authorization: `Bearer ${this.accessToken}`,
'bizao-token': params.userToken,
'bizao-alias': params.userAlias,
Authorization: `Bearer ${token}`,
'X-Orange-ISE2': params.userToken,
'X-Orange-MCO': 'orange', //from country todo
'Content-Type': 'application/json',
},
},
),
);
this.logger.debug(`[response fromm orange ]: ${JSON.stringify(response.data, null, 2)}`,)
return this.transformer.transformChargeResponse(response.data);
}
@ -257,4 +291,53 @@ export class OrangeAdapter implements IOperatorAdapter {
};
return senderMap[country];
}
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');
//this.logger.debug( `request to get acces token , ${this.config.baseUrl}${this.config.tokenEndpoint}`)
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 as string;
} catch (error) {
throw new Error(`Failed to obtain Orange access token: ${error.message}`);
}
}
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}`);
}
}
}

View File

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

View File

@ -8,6 +8,8 @@ import { MTNAdapter } from './adapters/mtn.adapter';
import { OrangeTransformer } from './transformers/orange.transformer';
import { MTNTransformer } from './transformers/mtn.transformer';
import { PrismaService } from '../../shared/services/prisma.service';
import { OrangeConfig } from './adapters/orange.config';
import { ConfigModule, ConfigService } from '@nestjs/config';
@Module({
imports: [
@ -15,9 +17,32 @@ import { PrismaService } from '../../shared/services/prisma.service';
timeout: 30000,
maxRedirects: 3,
}),
ConfigModule
],
controllers: [OperatorsController],
providers: [
{
provide: 'ORANGE_CONFIG',
useFactory: (configService: ConfigService): OrangeConfig => ({
baseUrl: configService.get<string>('ORANGE_BASE_URL', 'https://webhook.site/69ce9344-f87b-421a-a494-c59eca7c54ce'),
//tokenUrl: configService.get<string>('ORANGE_BASE_URL', 'https://webhook.site/69ce9344-f87b-421a-a494-c59eca7c54ce'),
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],
},
OperatorsService,
OperatorAdapterFactory,
OrangeAdapter,

View File

@ -1,10 +1,11 @@
import { BadRequestException, NotFoundException } from "@nestjs/common";
import { BadRequestException, Injectable, 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
@Injectable()
export class OperatorsService{
constructor(
@ -126,12 +127,6 @@ export class OperatorsService{
};
}
private getCountryName(code: string): string {
const countries = {

View File

@ -36,7 +36,7 @@ export class ChargeDto {
@ApiProperty({ required: false, description: 'Subscription ID if recurring' })
@IsOptional()
@IsString()
subscriptionId?: string;
subscriptionId?: number;
@ApiProperty({ required: false, description: 'Callback URL for notifications' })
@IsOptional()
@ -49,7 +49,15 @@ export class ChargeDto {
@ApiProperty({ required: false, description: 'partnerId ' })
@IsOptional()
partnerId: string;
partnerId: number;
@ApiProperty({ required: false, description: 'country ' })
@IsOptional()
country: string;
@ApiProperty({ required: false, description: 'operator ' })
@IsOptional()
operator: string;
}
export class RefundDto {

View File

@ -10,6 +10,9 @@ import {
HttpCode,
HttpStatus,
BadRequestException,
Headers,
Logger,
ParseIntPipe,
} from '@nestjs/common';
import {
ApiTags,
@ -32,10 +35,12 @@ import { ApiKeyGuard } from '../../common/guards/api-key.guard';
@ApiTags('payments')
@Controller('payments')
export class PaymentsController {
private readonly logger = new Logger(PaymentsController.name);
constructor(private readonly paymentsService: PaymentsService) {}
@Post('charge')
@UseGuards(JwtAuthGuard)
//@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Create a new charge' })
@ -46,10 +51,18 @@ export class PaymentsController {
})
@ApiResponse({ status: 400, description: 'Bad request' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
async createCharge(@Request() req, @Body() chargeDto: ChargeDto) {
async createCharge(
@Headers('X-Merchant-ID') merchantId: string,
@Headers('X-COUNTRY') coutnry: string,
@Headers('X-OPERATOR') operator: string,
@Request() req, @Body() chargeDto: ChargeDto) {
this.logger.debug(
`[request charge to hub ]: ${JSON.stringify(chargeDto, null, 2)}`,
)
return this.paymentsService.createCharge({
...chargeDto,
partnerId: req.user.partnerId,
...chargeDto,
country: coutnry,
operator: operator,
});
}
@ -92,7 +105,7 @@ export class PaymentsController {
@Get('reference/:reference')
@UseGuards(JwtAuthGuard)
//@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Get payment by reference' })
@ApiResponse({
@ -111,7 +124,7 @@ export class PaymentsController {
}
@Post(':paymentId/retry')
@UseGuards(JwtAuthGuard)
// @UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Retry a failed payment' })
@ -130,7 +143,7 @@ export class PaymentsController {
@Post('validate')
@UseGuards(JwtAuthGuard)
//@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Validate payment before processing' })
@ -143,7 +156,7 @@ export class PaymentsController {
// Webhook endpoints
@Post('webhook/callback')
@UseGuards(ApiKeyGuard)
//@UseGuards(ApiKeyGuard)
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Webhook callback for payment updates' })
async handleWebhook(@Request() req, @Body() payload: any) {

View File

@ -6,6 +6,8 @@ import { PaymentProcessor } from './processors/payment.processor';
import { WebhookService } from './services/webhook.service';
import { PrismaService } from '../../shared/services/prisma.service';
import { OperatorsModule } from '../operators/operators.module';
import { Subscription } from 'rxjs';
import { SubscriptionsModule } from '../subscriptions/subscriptions.module';
@Module({
imports: [
@ -16,6 +18,8 @@ import { OperatorsModule } from '../operators/operators.module';
name: 'webhooks',
}),
OperatorsModule,
SubscriptionsModule,
],
controllers: [PaymentsController],
providers: [PaymentsService, PaymentProcessor, WebhookService, PrismaService],

View File

@ -1,4 +1,4 @@
import { Injectable, BadRequestException } from '@nestjs/common';
import { Injectable, BadRequestException, Logger } from '@nestjs/common';
import { OperatorsService } from '../operators/operators.service';
import { PrismaService } from '../../shared/services/prisma.service';
import { EventEmitter2 } from '@nestjs/event-emitter';
@ -8,6 +8,7 @@ import { PaymentType, TransactionStatus } from 'generated/prisma';
@Injectable()
export class PaymentsService {
private readonly logger = new Logger(PaymentsService.name);
handleWebhook(arg0: { partnerId: any; event: any; payload: any; signature: any; }) {
throw new Error('Method not implemented.');
}
@ -33,7 +34,7 @@ export class PaymentsService {
) {}
async createCharge(chargeDto: ChargeDto) {
// Récupérer les informations de l'utilisateur
/* Récupérer les informations de l'utilisateur
const user = await this.prisma.user.findUnique({
where: { userToken: chargeDto.userToken },
// include: { operator: true },
@ -42,12 +43,13 @@ export class PaymentsService {
if (!user) {
throw new BadRequestException('Invalid user token');
}
*/
// Créer la transaction dans la base
const payment = await this.prisma.payment.create({
data: {
merchantPartnerId:1, // À remplacer par le bon partnerId
merchantPartnerId:chargeDto.partnerId , // À remplacer par le bon partnerId
customerId: 1, // todo À remplacer par user.id
amount: chargeDto.amount,
currency: chargeDto.currency,
@ -61,21 +63,29 @@ export class PaymentsService {
try {
// Router vers le bon opérateur
this.logger.debug(
`[getting adaptator for ]: ${chargeDto.operator}_${chargeDto.country} `)
const adapter = this.operatorsService.getAdapter(
'user.operator.code',
user.country,
chargeDto.operator,
chargeDto.country,
);
this.logger.debug(`Processing payment ${payment.id} through operator adapter ${adapter.constructor.name}`);
const chargeParams = {
userToken: user.userToken,
userAlias: user.userAlias,
userToken: chargeDto.userToken,
userAlias: chargeDto.userToken,//todo make alias in contrat
amount: chargeDto.amount,
currency: chargeDto.currency,
description: chargeDto.description,
reference: 'payment.reference,',//todo À remplacer par payment.reference
subscriptionId: chargeDto.subscriptionId,
reference: chargeDto.reference +'',//todo make reference in contrat
};
const result = await adapter.charge(chargeParams);
this.logger.debug(`result frm adaptaor ${result} for payment ${payment.id}`);
// Mettre à jour le paiement
const updatedPayment = await this.prisma.payment.update({
@ -104,7 +114,7 @@ export class PaymentsService {
return updatedPayment;
} catch (error) {
// En cas d'erreur, marquer comme échoué
await this.prisma.payment.update({
const resultFinal= await this.prisma.payment.update({
where: { id: payment.id },
data: {
status: TransactionStatus.FAILED,
@ -112,7 +122,7 @@ export class PaymentsService {
},
});
throw error;
return { ...resultFinal };
}
}

View File

@ -11,6 +11,7 @@ import {
UseGuards,
Request,
Logger,
ParseIntPipe,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { SubscriptionsService } from './subscriptions.service';
@ -37,12 +38,24 @@ export class SubscriptionsController {
return this.subscriptionsService.create(merchantId, dto);
}
@Get('/')
@ApiOperation({ summary: 'Get subscription list' })
async getAll(@Request() req) {
return this.subscriptionsService.findAll();
}
@Get('merchant/:merchantId')
@ApiOperation({ summary: 'Get subscription list by merchant' })
async getAllByMErchant(@Request() req, @Param('merchantId', ParseIntPipe) merchantId: number) {
return this.subscriptionsService.findAllByMerchant(merchantId);
}
@Get(':id')
@ApiOperation({ summary: 'Get subscription details' })
async get(@Request() req, @Param('id') id: string) {
return this.subscriptionsService.get(id, req.user.partnerId);
async get(@Request() req, @Param('id') id: number) {
return this.subscriptionsService.get(id);
}
@ -56,9 +69,5 @@ export class SubscriptionsController {
return this.subscriptionsService.cancel(id, req.user.partnerId, reason);
}
@Get(':id/invoices')
@ApiOperation({ summary: 'Get subscription invoices' })
async getInvoices(@Request() req, @Param('id') id: string) {
return this.subscriptionsService.getInvoices(id, req.user.partnerId);
}
}

View File

@ -18,7 +18,7 @@ import { PaymentsModule } from '../payments/payments.module';
BullModule.registerQueue({
name: 'billing',
}),
PaymentsModule,
// PaymentsModule,
],
controllers: [SubscriptionsController],
providers: [

View File

@ -4,23 +4,54 @@ import bull from 'bull';
import { PrismaService } from '../../shared/services/prisma.service';
import { PaymentsService } from '../payments/payments.service';
import { CreateSubscriptionDto, UpdateSubscriptionDto } from './dto/subscription.dto';
import { Subscription } from 'generated/prisma';
//import { SubscriptionStatus } from '@prisma/client';
//import { SubscriptionStatus, Prisma } from '@prisma/client';
@Injectable()
export class SubscriptionsService {
get(id: string, partnerId: any) {
throw new Error('Method not implemented.');
async get(id: number):Promise<any> {
const service = await this.prisma.subscription.findUnique({
where: { id },
});
if (!service) {
throw new NotFoundException(`Service with ID ${id} not found`);
}
return service;
}
list(arg0: { partnerId: any; status: string | undefined; userId: string | undefined; page: number; limit: number; }) {
throw new Error('Method not implemented.');
async findAllByMerchant(merchantId: number): Promise<Subscription[]> {
// Check if merchant exists
return this.prisma.subscription.findMany({
where: { merchantPartnerId: merchantId },
orderBy: {
createdAt: 'desc',
},
});
}
async findAll(): Promise<Subscription[]> {
// Check if merchant exists
return this.prisma.subscription.findMany({
// where: { merchantPartnerId: merchantId },
orderBy: {
createdAt: 'desc',
},
});
}
getInvoices(id: string, partnerId: any) {
throw new Error('Method not implemented.');
}
constructor(
private readonly prisma: PrismaService,
private readonly paymentsService: PaymentsService,
private readonly prisma: PrismaService,
@InjectQueue('subscriptions') private subscriptionQueue: bull.Queue,
@InjectQueue('billing') private billingQueue: bull.Queue,
) {}