infis on payment and subs

This commit is contained in:
Mamadou Khoussa [028918 DSI/DAC/DIF/DS] 2025-11-14 13:27:42 +00:00
parent d8ad43a56a
commit 6ea3ece796
11 changed files with 287 additions and 192 deletions

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "payments" ADD COLUMN "link" TEXT;

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "payments" ADD COLUMN "subscriptionId" INTEGER;

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "payments" ADD COLUMN "reference" TEXT;

View File

@ -93,6 +93,7 @@ model ReversementRequest {
model Payment {
id Int @id @default(autoincrement())
externalReference String?
reference String?
type PaymentType
status TransactionStatus
merchantPartnerId Int
@ -103,7 +104,9 @@ model Payment {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
customerId Int
subscriptionId Int?
metadata Json?
link String?
reversementRequests ReversementRequest[]

View File

@ -3,7 +3,7 @@ import { registerAs } from '@nestjs/config';
export default registerAs('operators', () => ({
ORANGE_CIV: {
name: 'Orange Côte d Ivoire',
baseUrl: process.env.ORANGE_CIV_BASE_URL || 'https://api.bizao.com',
baseUrl: process.env.ORANGE_CIV_BASE_URL || 'https://api.DCB-HUB.com',
authType: 'OTP',
endpoints: {
auth: {
@ -20,7 +20,7 @@ export default registerAs('operators', () => ({
},
},
headers: {
'X-OAPI-Application-Id': 'BIZAO',
'X-OAPI-Application-Id': 'DCB-HUB',
'X-Orange-MCO': 'OCI',
},
transformers: {
@ -30,7 +30,7 @@ export default registerAs('operators', () => ({
},
ORANGE_SEN: {
name: 'Orange Sénégal',
baseUrl: process.env.ORANGE_SEN_BASE_URL || 'https://api.bizao.com',
baseUrl: process.env.ORANGE_SEN_BASE_URL || 'https://api.DCB-HUB.com',
authType: 'OTP',
endpoints: {
auth: {
@ -47,7 +47,7 @@ export default registerAs('operators', () => ({
},
},
headers: {
'X-OAPI-Application-Id': 'BIZAO',
'X-OAPI-Application-Id': 'DCB-HUB',
'X-Orange-MCO': 'OSN',
},
transformers: {

View File

@ -70,5 +70,6 @@ export interface ChargeResponse {
status: 'SUCCESS' | 'FAILED' | 'PENDING';
operatorReference: string;
amount: number;
resourceURL: string;
currency: string;
}

View File

@ -57,7 +57,7 @@ export class OrangeAdapter implements IOperatorAdapter {
challenge: {
method: 'OTP-SMS-AUTH',
country: countryCode,
service: 'BIZAO',
service: 'DCB_HUB',
partnerId: 'PDKSUB',
inputs: [
{
@ -114,7 +114,7 @@ export class OrangeAdapter implements IOperatorAdapter {
challenge: {
method: 'OTP-SMS-AUTH',
country: params.country,
service: 'BIZAO',
service: 'DCB_HUB',
partnerId: 'PDKSUB',
inputs: [
{
@ -178,7 +178,7 @@ export class OrangeAdapter implements IOperatorAdapter {
chargingMetaData: {
onBehalfOf: 'PaymentHub', //from config todo
purchaseCategoryCode: 'Service', //todo from config
serviceId: 'BIZAO',
serviceId: 'DCB_HUB',
},
},
transactionOperationStatus: 'Charged',
@ -188,12 +188,17 @@ export class OrangeAdapter implements IOperatorAdapter {
};
const token = await this.getAccessToken();
this.logger.debug(
`[requesting to ]: ${this.config.baseUrl}/payment/v1/acr%3AOrangeAPIToken/transactions/amount`,
`[requesting to ]: ${this.config.baseUrl}/payment/mea/v1/acr%3AX-Orange-ISE2/transactions/amount`,
);
this.logger.debug(
`[requesting token ]: ${token} `,
);
const response = await firstValueFrom(
this.httpService.post(
`${this.config.baseUrl}}/payment/v1/acr%3AOrangeAPIToken/transactions/amount`,
`${this.config.baseUrl}/payment/mea/v1/acr%3AX-Orange-ISE2/transactions/amount`,
hubRequest,
{
headers: {
@ -238,11 +243,11 @@ export class OrangeAdapter implements IOperatorAdapter {
{
headers: {
Authorization: `Bearer ${this.accessToken}`,
'X-OAPI-Application-Id': 'BIZAO',
'X-OAPI-Contact-Id': 'b2b-bizao-97b5878',
'X-OAPI-Application-Id': 'DCB_HUB',
'X-OAPI-Contact-Id': 'b2b-DCB_HUB-97b5878',
'X-OAPI-Resource-Type': 'SMS_OSM',
'bizao-alias': params.userAlias,
'bizao-token': params.userToken,
'DCB_HUB-alias': params.userAlias,
'DCB_HUB-token': params.userToken,
'X-Orange-MCO': this.getMCO(params.country),
'Content-Type': 'application/json',
},

View File

@ -2,29 +2,30 @@ import { Injectable } from '@nestjs/common';
@Injectable()
export class OrangeTransformer {
transformChargeResponse(bizaoResponse: any): any {
transformChargeResponse(orangeResponse: any): any {
return {
paymentId: bizaoResponse.amountTransaction?.serverReferenceCode,
paymentId: orangeResponse.amountTransaction?.serverReferenceCode,
status: this.mapStatus(
bizaoResponse.amountTransaction?.transactionOperationStatus,
orangeResponse.amountTransaction?.transactionOperationStatus,
),
operatorReference: bizaoResponse.amountTransaction?.serverReferenceCode,
operatorReference: orangeResponse.amountTransaction?.serverReferenceCode,
amount: parseFloat(
bizaoResponse.amountTransaction?.paymentAmount?.totalAmountCharged,
orangeResponse.amountTransaction?.paymentAmount?.totalAmountCharged,
),
resourceURL: orangeResponse.amountTransaction?.resourceURL,
currency:
bizaoResponse.amountTransaction?.paymentAmount?.chargingInformation
orangeResponse.amountTransaction?.paymentAmount?.chargingInformation
?.currency,
createdAt: new Date(),
};
}
private mapStatus(bizaoStatus: string): string {
private mapStatus(orangeStatus: string): string {
const statusMap = {
Charged: 'SUCCESS',
Failed: 'FAILED',
Pending: 'PENDING',
};
return statusMap[bizaoStatus] || 'PENDING';
return statusMap[orangeStatus] || 'PENDING';
}
}

View File

@ -6,6 +6,7 @@ import {
Min,
IsEnum,
IsDateString,
isNumber,
} from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
@ -35,7 +36,7 @@ export class ChargeDto {
@ApiProperty({ required: false, description: 'Subscription ID if recurring' })
@IsOptional()
@IsString()
@IsNumber()
subscriptionId?: number;
@ApiProperty({ required: false, description: 'Callback URL for notifications' })
@ -64,7 +65,7 @@ export class RefundDto {
@ApiProperty({ required: false, description: 'Amount to refund (partial refund)' })
@IsOptional()
@IsNumber()
@Min(0)
@Min(1)
amount?: number;
@ApiProperty({ description: 'Reason for refund' })
@ -89,8 +90,8 @@ export class PaymentQueryDto {
@ApiProperty({ required: false })
@IsOptional()
@IsString()
subscriptionId?: string;
@IsNumber()
subscriptionId?: number;
@ApiProperty({ required: false })
@IsOptional()

View File

@ -89,7 +89,7 @@ export class PaymentsController {
}
@Get(':paymentId')
@UseGuards(JwtAuthGuard)
//@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Get payment details' })
@ApiResponse({
@ -98,8 +98,9 @@ export class PaymentsController {
type: PaymentResponseDto,
})
@ApiResponse({ status: 404, description: 'Payment not found' })
async getPayment(@Request() req, @Param('paymentId') paymentId: string) {
return this.paymentsService.getPayment(paymentId, req.user.partnerId);
async getPayment(@Request() req, @Param('paymentId') paymentId: number) {
console.log('Fetching payment with ID:', paymentId);
return this.paymentsService.getPayment(paymentId);
}
@ -118,11 +119,32 @@ export class PaymentsController {
@Param('reference') reference: string,
) {
return this.paymentsService.getPaymentByReference(
reference,
req.user.partnerId,
reference
);
}
@Get('/')
@ApiOperation({ summary: 'Get payments list' })
async getAll(@Request() req) {
return this.paymentsService.findAll();
}
@Get('merchant/:merchantId')
@ApiOperation({ summary: 'Get payments list by merchant' })
async getAllByPaymentByMerchant(@Request() req, @Param('merchantId', ParseIntPipe) merchantId: number) {
return this.paymentsService.findAllByMerchant(merchantId);
}
@Get('merchant/:merchantId/subscription/:subscriptionId')
@ApiOperation({ summary: 'Get payments list by merchant' })
async getAllBySubscription(@Request() req,
@Param('merchantId', ParseIntPipe) merchantId: number,
@Param('subscriptionId', ParseIntPipe) subscriptionId: number) {
return this.paymentsService.findAllByMerchantSubscription(merchantId,subscriptionId);
}
@Post(':paymentId/retry')
// @UseGuards(JwtAuthGuard)
@ApiBearerAuth()

View File

@ -9,22 +9,71 @@ 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.');
handleWebhook(arg0: {
partnerId: any;
event: any;
payload: any;
signature: any;
}) {
throw new Error('Method not implemented.');
}
async getPaymentByReference(reference: string) {
const plan = await this.prisma.payment.findFirst({
where: { reference: reference },
});
return plan
}
async getPayment(id: number) {
const data = await this.prisma.payment.findUnique({
where: { id },
});
return data;
}
async findAllByMerchant(merchantId: number): Promise<any[]> {
// Check if merchant exists
return this.prisma.payment.findMany({
where: { merchantPartnerId: merchantId },
orderBy: {
createdAt: 'desc',
},
});
}
getPaymentByReference(reference: string, partnerId: any) {
throw new Error('Method not implemented.');
async findAllByMerchantSubscription(merchantId: number, subscriptionId: number): Promise<any[]> {
// Check if merchant exists
return this.prisma.payment.findMany({
where: { merchantPartnerId: merchantId, subscriptionId: subscriptionId },
orderBy: {
createdAt: 'desc',
},
});
}
getPayment(paymentId: string, partnerId: any) {
throw new Error('Method not implemented.');
}
refundPayment(paymentId: string, partnerId: any, refundDto: RefundDto) {
throw new Error('Method not implemented.');
async findAll(): Promise<any[]> {
// Check if merchant exists
return this.prisma.payment.findMany({
// where: { merchantPartnerId: merchantId },
orderBy: {
createdAt: 'desc',
},
});
}
refundPayment(paymentId: string, partnerId: any, refundDto: RefundDto) {
throw new Error('Method not implemented.');
}
retryPayment(paymentId: any, attempt: any) {
throw new Error('Method not implemented.');
}
processPayment(paymentId: any) :any{
processPayment(paymentId: any): any {
throw new Error('Method not implemented.');
}
constructor(
@ -47,13 +96,14 @@ export class PaymentsService {
// Créer la transaction dans la base
const payment = await this.prisma.payment.create({
data: {
merchantPartnerId:chargeDto.partnerId , // À remplacer par le bon partnerId
subscriptionId: chargeDto.subscriptionId,
merchantPartnerId: chargeDto.partnerId, // À remplacer par le bon partnerId
customerId: 1, // todo À remplacer par user.id
amount: chargeDto.amount,
currency: chargeDto.currency,
type: PaymentType.MM,
reference: chargeDto.reference || this.generateReference(),
//description: chargeDto.description,
//reference: chargeDto.reference || this.generateReference(),
status: TransactionStatus.PENDING,
@ -64,28 +114,31 @@ export class PaymentsService {
try {
// Router vers le bon opérateur
this.logger.debug(
`[getting adaptator for ]: ${chargeDto.operator}_${chargeDto.country} `)
`[getting adaptator for ]: ${chargeDto.operator}_${chargeDto.country} `,
);
const adapter = this.operatorsService.getAdapter(
chargeDto.operator,
chargeDto.country,
);
this.logger.debug(`Processing payment ${payment.id} through operator adapter ${adapter.constructor.name}`);
this.logger.debug(
`Processing payment ${payment.id} through operator adapter ${adapter.constructor.name}`,
);
const chargeParams = {
userToken: chargeDto.userToken,
userAlias: chargeDto.userToken,//todo make alias in contrat
userAlias: chargeDto.userToken, //todo make alias in contrat
amount: chargeDto.amount,
currency: chargeDto.currency,
description: chargeDto.description,
subscriptionId: chargeDto.subscriptionId,
reference: chargeDto.reference +'',//todo make reference in contrat
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}`);
this.logger.debug(
`result frm adaptaor ${result} for payment ${payment.id}`,
);
// Mettre à jour le paiement
const updatedPayment = await this.prisma.payment.update({
@ -95,7 +148,8 @@ export class PaymentsService {
result.status === 'SUCCESS'
? TransactionStatus.SUCCESS
: TransactionStatus.FAILED,
//operatorReference: result.operatorReference,
externalReference: result.operatorReference,
link: result.resourceURL,
completedAt: new Date(),
},
});
@ -113,8 +167,12 @@ export class PaymentsService {
return updatedPayment;
} catch (error) {
this.logger.debug(
`error ${error.message} processing payment ${payment.id}`,
);
// En cas d'erreur, marquer comme échoué
const resultFinal= await this.prisma.payment.update({
const resultFinal = await this.prisma.payment.update({
where: { id: payment.id },
data: {
status: TransactionStatus.FAILED,
@ -137,150 +195,148 @@ export class PaymentsService {
// Ajouter ces méthodes dans PaymentsService
async listPayments(filters: any) {
const where: any = {
partnerId: filters.partnerId,
};
if (filters.status) {
where.status = filters.status;
}
if (filters.userId) {
where.userId = filters.userId;
}
if (filters.subscriptionId) {
where.subscriptionId = filters.subscriptionId;
}
if (filters.startDate || filters.endDate) {
where.createdAt = {};
if (filters.startDate) {
where.createdAt.gte = new Date(filters.startDate);
}
if (filters.endDate) {
where.createdAt.lte = new Date(filters.endDate);
}
}
const page = filters.page || 1;
const limit = filters.limit || 20;
const skip = (page - 1) * limit;
const [payments, total] = await Promise.all([
this.prisma.payment.findMany({
where,
skip,
take: limit,
orderBy: { createdAt: 'desc' },
}),
this.prisma.payment.count({ where }),
]);
return {
data: payments,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}
async getStatistics(params: {
partnerId: string;
period: string;
startDate?: Date;
endDate?: Date;
}) {
const { partnerId, period, startDate, endDate } = params;
const where: any = { partnerId };
if (startDate || endDate) {
where.createdAt = {};
if (startDate) where.createdAt.gte = startDate;
if (endDate) where.createdAt.lte = endDate;
}
const [
totalPayments,
successfulPayments,
failedPayments,
totalRevenue,
avgPaymentAmount,
] = await Promise.all([
this.prisma.payment.count({ where }),
this.prisma.payment.count({ where: { ...where, status: 'SUCCESS' } }),
this.prisma.payment.count({ where: { ...where, status: 'FAILED' } }),
this.prisma.payment.aggregate({
where: { ...where, status: 'SUCCESS' },
_sum: { amount: true },
}),
this.prisma.payment.aggregate({
where: { ...where, status: 'SUCCESS' },
_avg: { amount: true },
}),
]);
const successRate = totalPayments > 0
? (successfulPayments / totalPayments) * 100
: 0;
return {
totalPayments,
successfulPayments,
failedPayments,
successRate: Math.round(successRate * 100) / 100,
totalRevenue: totalRevenue._sum.amount || 0,
avgPaymentAmount: avgPaymentAmount._avg.amount || 0,
period,
startDate,
endDate,
};
}
async validatePayment(params: any) {
// Valider le user token
const user = await this.prisma.user.findUnique({
where: { userToken: params.userToken },
});
if (!user) {
return {
valid: false,
error: 'Invalid user token',
async listPayments(filters: any) {
const where: any = {
partnerId: filters.partnerId,
};
}
// Vérifier les limites
const todayPayments = await this.prisma.payment.count({
where: {
customerId: 1, // todo À remplacer par user.id
status: 'SUCCESS',
createdAt: {
gte: new Date(new Date().setHours(0, 0, 0, 0)),
if (filters.status) {
where.status = filters.status;
}
if (filters.userId) {
where.userId = filters.userId;
}
if (filters.subscriptionId) {
where.subscriptionId = filters.subscriptionId;
}
if (filters.startDate || filters.endDate) {
where.createdAt = {};
if (filters.startDate) {
where.createdAt.gte = new Date(filters.startDate);
}
if (filters.endDate) {
where.createdAt.lte = new Date(filters.endDate);
}
}
const page = filters.page || 1;
const limit = filters.limit || 20;
const skip = (page - 1) * limit;
const [payments, total] = await Promise.all([
this.prisma.payment.findMany({
where,
skip,
take: limit,
orderBy: { createdAt: 'desc' },
}),
this.prisma.payment.count({ where }),
]);
return {
data: payments,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
},
});
if (todayPayments >= 10) {
return {
valid: false,
error: 'Daily payment limit reached',
};
}
return {
valid: true,
user: {
id: user.id,
msisdn: user.msisdn,
country: user.country,
},
};
}
async getStatistics(params: {
partnerId: string;
period: string;
startDate?: Date;
endDate?: Date;
}) {
const { partnerId, period, startDate, endDate } = params;
const where: any = { partnerId };
if (startDate || endDate) {
where.createdAt = {};
if (startDate) where.createdAt.gte = startDate;
if (endDate) where.createdAt.lte = endDate;
}
const [
totalPayments,
successfulPayments,
failedPayments,
totalRevenue,
avgPaymentAmount,
] = await Promise.all([
this.prisma.payment.count({ where }),
this.prisma.payment.count({ where: { ...where, status: 'SUCCESS' } }),
this.prisma.payment.count({ where: { ...where, status: 'FAILED' } }),
this.prisma.payment.aggregate({
where: { ...where, status: 'SUCCESS' },
_sum: { amount: true },
}),
this.prisma.payment.aggregate({
where: { ...where, status: 'SUCCESS' },
_avg: { amount: true },
}),
]);
const successRate =
totalPayments > 0 ? (successfulPayments / totalPayments) * 100 : 0;
return {
totalPayments,
successfulPayments,
failedPayments,
successRate: Math.round(successRate * 100) / 100,
totalRevenue: totalRevenue._sum.amount || 0,
avgPaymentAmount: avgPaymentAmount._avg.amount || 0,
period,
startDate,
endDate,
};
}
async validatePayment(params: any) {
// Valider le user token
const user = await this.prisma.user.findUnique({
where: { userToken: params.userToken },
});
if (!user) {
return {
valid: false,
error: 'Invalid user token',
};
}
// Vérifier les limites
const todayPayments = await this.prisma.payment.count({
where: {
customerId: 1, // todo À remplacer par user.id
status: 'SUCCESS',
createdAt: {
gte: new Date(new Date().setHours(0, 0, 0, 0)),
},
},
});
if (todayPayments >= 10) {
return {
valid: false,
error: 'Daily payment limit reached',
};
}
return {
valid: true,
user: {
id: user.id,
msisdn: user.msisdn,
country: user.country,
},
};
}
}