Compare commits

...

10 Commits

Author SHA1 Message Date
Mamadou Khoussa [028918 DSI/DAC/DIF/DS]
e4c4383ceb fix pagination et filter 2025-12-01 10:13:12 +00:00
Mamadou Khoussa [028918 DSI/DAC/DIF/DS]
0af15e26fc fix subscription from orange 2025-11-14 16:50:42 +00:00
Mamadou Khoussa [028918 DSI/DAC/DIF/DS]
6ea3ece796 infis on payment and subs 2025-11-14 13:27:42 +00:00
Mamadou Khoussa [028918 DSI/DAC/DIF/DS]
d8ad43a56a payment object 2025-11-14 12:09:56 +00:00
Mamadou Khoussa [028918 DSI/DAC/DIF/DS]
767201ec06 subscription management 2025-11-14 01:43:27 +00:00
Mamadou Khoussa [028918 DSI/DAC/DIF/DS]
f7820ddccf fix convert message 2025-10-30 01:43:10 +00:00
Mamadou Khoussa [028918 DSI/DAC/DIF/DS]
8406d79800 otp challenge 2025-10-29 18:13:48 +00:00
Mamadou Khoussa [028918 DSI/DAC/DIF/DS]
039e9f067d fix debploiemen 2025-10-28 17:10:41 +00:00
Mamadou Khoussa [028918 DSI/DAC/DIF/DS]
08127aa36e fix debploiemen 2025-10-28 17:09:01 +00:00
Mamadou Khoussa [028918 DSI/DAC/DIF/DS]
fdfdd1a83e fix it " 2025-10-28 16:58:28 +00:00
57 changed files with 2803 additions and 1516 deletions

76
Dockerfile Normal file
View File

@ -0,0 +1,76 @@
# Stage 1: Build
FROM node:20-alpine AS builder
# Définir le répertoire de travail
WORKDIR /app
# Copier les fichiers de dépendances
COPY package*.json ./
# Copier le schema Prisma AVANT npm ci
COPY prisma ./prisma/
# Installer les dépendances avec --legacy-peer-deps pour résoudre les conflits
RUN npm ci --legacy-peer-deps
# Copier le code source
COPY . .
# Générer Prisma Client
RUN npx prisma generate
# Builder l'application
RUN npm run build
# Stage 2: Production
FROM node:20-alpine AS production
# Installer dumb-init pour une meilleure gestion des signaux
RUN apk add --no-cache dumb-init
# Créer un utilisateur non-root
RUN addgroup -g 1001 -S nodejs && \
adduser -S nestjs -u 1001
# Définir le répertoire de travail
WORKDIR /app
# Copier package.json et package-lock.json
COPY package*.json ./
# Copier le schema Prisma
COPY prisma ./prisma/
# Installer UNIQUEMENT les dépendances de production avec --legacy-peer-deps
RUN npm ci --omit=dev --legacy-peer-deps && \
npm cache clean --force
# 🔥 IMPORTANT: Générer Prisma Client en production
RUN npx prisma generate
# Copier le code buildé depuis le builder
COPY --from=builder --chown=nestjs:nodejs /app/dist ./dist
# 🔥 IMPORTANT: Copier les fichiers générés de Prisma depuis le builder
COPY --from=builder --chown=nestjs:nodejs /app/node_modules/.prisma ./node_modules/.prisma
COPY --from=builder --chown=nestjs:nodejs /app/node_modules/@prisma ./node_modules/@prisma
# Si vous utilisez un output personnalisé dans schema.prisma, copiez aussi:
# COPY --from=builder --chown=nestjs:nodejs /app/generated ./generated
# Changer le propriétaire des fichiers
RUN chown -R nestjs:nodejs /app
# Utiliser l'utilisateur non-root
USER nestjs
# Exposer le port
EXPOSE 3000
# Healthcheck
#HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
# CMD node -e "require('http').get('http://localhost:3000/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"
# Démarrer l'application avec dumb-init
ENTRYPOINT ["dumb-init", "--"]
CMD ["node", "dist/main"]

View File

@ -96,3 +96,8 @@ Nest is an MIT-licensed open source project. It can grow thanks to the sponsors
## License
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).
## docker
docker build -t service-core:latest .
docker run -p 3000:3000 service-core:latest

View File

@ -0,0 +1,182 @@
/*
Warnings:
- You are about to drop the `AuthSession` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `Invoice` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `Notification` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `Operator` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `Partner` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `Payment` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `Plan` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `Refund` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `Subscription` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `Webhook` table. If the table is not empty, all the data it contains will be lost.
*/
-- CreateEnum
CREATE TYPE "TransactionStatus" AS ENUM ('SUCCES', 'FAILED', 'PENDING');
-- CreateEnum
CREATE TYPE "Periodicity" AS ENUM ('Daily', 'Weekly', 'Monthly', 'OneTime');
-- CreateEnum
CREATE TYPE "PaymentType" AS ENUM ('MM', 'BANK', 'CHEQUE');
-- DropForeignKey
ALTER TABLE "public"."AuthSession" DROP CONSTRAINT "AuthSession_partnerId_fkey";
-- DropForeignKey
ALTER TABLE "public"."Invoice" DROP CONSTRAINT "Invoice_partnerId_fkey";
-- DropForeignKey
ALTER TABLE "public"."Invoice" DROP CONSTRAINT "Invoice_paymentId_fkey";
-- DropForeignKey
ALTER TABLE "public"."Invoice" DROP CONSTRAINT "Invoice_subscriptionId_fkey";
-- DropForeignKey
ALTER TABLE "public"."Invoice" DROP CONSTRAINT "Invoice_userId_fkey";
-- DropForeignKey
ALTER TABLE "public"."Notification" DROP CONSTRAINT "Notification_partnerId_fkey";
-- DropForeignKey
ALTER TABLE "public"."Notification" DROP CONSTRAINT "Notification_userId_fkey";
-- DropForeignKey
ALTER TABLE "public"."Payment" DROP CONSTRAINT "Payment_partnerId_fkey";
-- DropForeignKey
ALTER TABLE "public"."Payment" DROP CONSTRAINT "Payment_subscriptionId_fkey";
-- DropForeignKey
ALTER TABLE "public"."Payment" DROP CONSTRAINT "Payment_userId_fkey";
-- DropForeignKey
ALTER TABLE "public"."Plan" DROP CONSTRAINT "Plan_partnerId_fkey";
-- DropForeignKey
ALTER TABLE "public"."Refund" DROP CONSTRAINT "Refund_paymentId_fkey";
-- DropForeignKey
ALTER TABLE "public"."Subscription" DROP CONSTRAINT "Subscription_partnerId_fkey";
-- DropForeignKey
ALTER TABLE "public"."Subscription" DROP CONSTRAINT "Subscription_planId_fkey";
-- DropForeignKey
ALTER TABLE "public"."Subscription" DROP CONSTRAINT "Subscription_userId_fkey";
-- DropForeignKey
ALTER TABLE "public"."User" DROP CONSTRAINT "User_operatorId_fkey";
-- DropForeignKey
ALTER TABLE "public"."User" DROP CONSTRAINT "User_partnerId_fkey";
-- DropForeignKey
ALTER TABLE "public"."Webhook" DROP CONSTRAINT "Webhook_partnerId_fkey";
-- DropTable
DROP TABLE "public"."AuthSession";
-- DropTable
DROP TABLE "public"."Invoice";
-- DropTable
DROP TABLE "public"."Notification";
-- DropTable
DROP TABLE "public"."Operator";
-- DropTable
DROP TABLE "public"."Partner";
-- DropTable
DROP TABLE "public"."Payment";
-- DropTable
DROP TABLE "public"."Plan";
-- DropTable
DROP TABLE "public"."Refund";
-- DropTable
DROP TABLE "public"."Subscription";
-- DropTable
DROP TABLE "public"."Webhook";
-- DropEnum
DROP TYPE "public"."OperatorCode";
-- DropEnum
DROP TYPE "public"."PaymentStatus";
-- DropEnum
DROP TYPE "public"."SubscriptionStatus";
-- CreateTable
CREATE TABLE "transactions" (
"id" SERIAL NOT NULL,
"date" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"amount" DOUBLE PRECISION NOT NULL,
"tax" DOUBLE PRECISION NOT NULL,
"status" "TransactionStatus" NOT NULL,
"merchantPartnerId" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "transactions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "subscriptions" (
"id" SERIAL NOT NULL,
"periodicity" "Periodicity" NOT NULL,
"startDate" TIMESTAMP(3) NOT NULL,
"endDate" TIMESTAMP(3),
"amount" DOUBLE PRECISION NOT NULL,
"currency" TEXT NOT NULL,
"token" TEXT NOT NULL,
"nextPaymentDate" TIMESTAMP(3) NOT NULL,
"merchantPartnerId" INTEGER NOT NULL,
"customerId" INTEGER NOT NULL,
"serviceId" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "subscriptions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "reversement_requests" (
"id" SERIAL NOT NULL,
"startDate" TIMESTAMP(3) NOT NULL,
"endDate" TIMESTAMP(3) NOT NULL,
"amount" DOUBLE PRECISION NOT NULL,
"tax" DOUBLE PRECISION NOT NULL,
"status" "TransactionStatus" NOT NULL,
"transactionId" INTEGER NOT NULL,
"paymentId" INTEGER,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "reversement_requests_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "payments" (
"id" SERIAL NOT NULL,
"type" "PaymentType" NOT NULL,
"status" "TransactionStatus" NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "payments_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "reversement_requests" ADD CONSTRAINT "reversement_requests_transactionId_fkey" FOREIGN KEY ("transactionId") REFERENCES "transactions"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "reversement_requests" ADD CONSTRAINT "reversement_requests_paymentId_fkey" FOREIGN KEY ("paymentId") REFERENCES "payments"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -0,0 +1,8 @@
/*
Warnings:
- Added the required column `planId` to the `subscriptions` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "subscriptions" ADD COLUMN "planId" INTEGER NOT NULL;

View File

@ -0,0 +1,11 @@
/*
Warnings:
- Added the required column `status` to the `subscriptions` table without a default value. This is not possible if the table is not empty.
*/
-- CreateEnum
CREATE TYPE "SubscriptionStatus" AS ENUM ('ACTIVE', 'TRIAL', 'SUSPENDED');
-- AlterTable
ALTER TABLE "subscriptions" ADD COLUMN "status" "SubscriptionStatus" NOT NULL;

View File

@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "SubscriptionStatus" ADD VALUE 'PENDING';

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "subscriptions" ADD COLUMN "metadata" JSONB;

View File

@ -0,0 +1,8 @@
/*
Warnings:
- Added the required column `failureCount` to the `subscriptions` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "subscriptions" ADD COLUMN "failureCount" INTEGER NOT NULL;

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "subscriptions" ALTER COLUMN "failureCount" DROP NOT NULL;

View File

@ -0,0 +1,10 @@
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "SubscriptionStatus" ADD VALUE 'EXPIRED';
ALTER TYPE "SubscriptionStatus" ADD VALUE 'CANCELLED';

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "subscriptions" ADD COLUMN "suspendedAt" TIMESTAMP(3);

View File

@ -0,0 +1,16 @@
/*
Warnings:
- The values [SUCCES] on the enum `TransactionStatus` will be removed. If these variants are still used in the database, this will fail.
*/
-- AlterEnum
BEGIN;
CREATE TYPE "TransactionStatus_new" AS ENUM ('SUCCESS', 'FAILED', 'PENDING');
ALTER TABLE "transactions" ALTER COLUMN "status" TYPE "TransactionStatus_new" USING ("status"::text::"TransactionStatus_new");
ALTER TABLE "reversement_requests" ALTER COLUMN "status" TYPE "TransactionStatus_new" USING ("status"::text::"TransactionStatus_new");
ALTER TABLE "payments" ALTER COLUMN "status" TYPE "TransactionStatus_new" USING ("status"::text::"TransactionStatus_new");
ALTER TYPE "TransactionStatus" RENAME TO "TransactionStatus_old";
ALTER TYPE "TransactionStatus_new" RENAME TO "TransactionStatus";
DROP TYPE "public"."TransactionStatus_old";
COMMIT;

View File

@ -0,0 +1,8 @@
/*
Warnings:
- Added the required column `customerId` to the `payments` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "payments" ADD COLUMN "customerId" INTEGER NOT NULL;

View File

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

View File

@ -0,0 +1,10 @@
/*
Warnings:
- You are about to drop the column `partnerId` on the `User` table. All the data in the column will be lost.
- Added the required column `merchantPartnerId` to the `User` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "User" DROP COLUMN "partnerId",
ADD COLUMN "merchantPartnerId" TEXT NOT NULL;

View File

@ -0,0 +1,13 @@
/*
Warnings:
- Changed the type of `merchantPartnerId` on the `User` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required.
- Added the required column `merchantPartnerId` to the `payments` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "User" DROP COLUMN "merchantPartnerId",
ADD COLUMN "merchantPartnerId" INTEGER NOT NULL;
-- AlterTable
ALTER TABLE "payments" ADD COLUMN "merchantPartnerId" INTEGER NOT NULL;

View File

@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "payments" ADD COLUMN "amount" DOUBLE PRECISION,
ADD COLUMN "completedAt" TIMESTAMP(3);

View File

@ -0,0 +1,8 @@
/*
Warnings:
- Added the required column `currency` to the `payments` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "payments" ADD COLUMN "currency" TEXT NOT NULL;

View File

@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "payments" ADD COLUMN "metadata" JSONB;
-- AlterTable
ALTER TABLE "reversement_requests" ADD COLUMN "metadata" JSONB;

View File

@ -0,0 +1,8 @@
-- AlterTable
ALTER TABLE "payments" ADD COLUMN "externalReference" TEXT;
-- AlterTable
ALTER TABLE "reversement_requests" ADD COLUMN "externalReference" TEXT;
-- AlterTable
ALTER TABLE "subscriptions" ADD COLUMN "externalReference" TEXT;

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;

272
prisma/schema copy.prisma Normal file
View File

@ -0,0 +1,272 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
output = "../generated/prisma"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum OperatorCode {
ORANGE
MTN
AIRTEL
VODACOM
MOOV
}
enum PaymentStatus {
PENDING
SUCCESS
FAILED
REFUNDED
}
enum SubscriptionStatus {
PENDING
TRIAL
ACTIVE
SUSPENDED
CANCELLED
EXPIRED
FAILED
}
model Operator {
id String @id @default(cuid())
code OperatorCode
name String
country String
config Json
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
users User[]
}
model User {
id String @id @default(cuid())
msisdn String @unique
userToken String @unique
userAlias String
operatorId String
partnerId String
country String
metadata Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
operator Operator @relation(fields: [operatorId], references: [id])
partner Partner @relation(fields: [partnerId], references: [id])
subscriptions Subscription[]
payments Payment[]
invoices Invoice[]
notifications Notification[] // Added relation
}
model Plan {
id String @id @default(cuid())
partnerId String
code String
name String
description String?
amount Float
currency String
interval String // DAILY, WEEKLY, MONTHLY, YEARLY
intervalCount Int @default(1)
trialDays Int @default(0)
features Json? // Array of features
limits Json? // Object with usage limits
metadata Json?
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
partner Partner @relation(fields: [partnerId], references: [id])
subscriptions Subscription[]
@@unique([partnerId, code])
@@index([partnerId, active])
}
model Invoice {
id String @id @default(cuid())
number String @unique
subscriptionId String
userId String
partnerId String
paymentId String? @unique
amount Float
currency String
status String // PENDING, PAID, FAILED, CANCELLED
billingPeriodStart DateTime
billingPeriodEnd DateTime
dueDate DateTime
paidAt DateTime?
items Json // Array of line items
attempts Int @default(0)
failureReason String?
metadata Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
subscription Subscription @relation(fields: [subscriptionId], references: [id])
user User @relation(fields: [userId], references: [id])
partner Partner @relation(fields: [partnerId], references: [id])
payment Payment? @relation(fields: [paymentId], references: [id])
@@index([subscriptionId])
@@index([partnerId, status])
}
model Subscription {
id String @id @default(cuid())
userId String
planId String
partnerId String
status SubscriptionStatus
currentPeriodStart DateTime
currentPeriodEnd DateTime
nextBillingDate DateTime?
trialEndsAt DateTime?
cancelledAt DateTime?
suspendedAt DateTime?
failureCount Int @default(0)
renewalCount Int @default(0)
lastPaymentId String?
metadata Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id])
plan Plan @relation(fields: [planId], references: [id])
partner Partner @relation(fields: [partnerId], references: [id])
payments Payment[]
invoices Invoice[]
}
model Payment {
id String @id @default(cuid())
userId String
partnerId String
subscriptionId String?
amount Float
currency String
description String
reference String @unique
operatorReference String?
status PaymentStatus
failureReason String?
metadata Json?
completedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id])
partner Partner @relation(fields: [partnerId], references: [id])
subscription Subscription? @relation(fields: [subscriptionId], references: [id])
refunds Refund[]
invoice Invoice?
}
model Refund {
id String @id @default(cuid())
paymentId String
amount Float
reason String?
status String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
payment Payment @relation(fields: [paymentId], references: [id])
}
model Webhook {
id String @id @default(cuid())
partnerId String? // Made optional for system webhooks
url String
event String
payload Json
response Json?
status String
attempts Int @default(0)
lastAttempt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
partner Partner? @relation(fields: [partnerId], references: [id])
}
model Partner {
id String @id @default(cuid())
name String
email String @unique
passwordHash String
apiKey String @unique
secretKey String
status String @default("PENDING")
companyInfo Json?
callbacks Json?
country String
metadata Json?
keysRotatedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
users User[]
subscriptions Subscription[]
payments Payment[]
authSessions AuthSession[]
plans Plan[]
invoices Invoice[]
notifications Notification[] // Added relation
webhooks Webhook[] // Added relation
}
model AuthSession {
id String @id @default(cuid())
sessionId String @unique
partnerId String
userId String?
msisdn String
operator String
country String
authMethod String
challengeId String?
status String
expiresAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
partner Partner @relation(fields: [partnerId], references: [id])
}
model Notification {
id String @id @default(cuid())
partnerId String
userId String?
type String // PAYMENT, SUBSCRIPTION, ALERT, MARKETING
channel String // SMS, EMAIL, WEBHOOK
recipient String
subject String?
content String
templateId String?
status String // PENDING, SENT, FAILED
batchId String?
scheduledFor DateTime?
sentAt DateTime?
failedAt DateTime?
failureReason String?
response Json?
metadata Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
partner Partner @relation(fields: [partnerId], references: [id])
user User? @relation(fields: [userId], references: [id])
}

View File

@ -11,42 +11,112 @@ datasource db {
url = env("DATABASE_URL")
}
enum OperatorCode {
ORANGE
MTN
AIRTEL
VODACOM
MOOV
model Transaction {
id Int @id @default(autoincrement())
date DateTime @default(now())
amount Float
tax Float
status TransactionStatus
merchantPartnerId Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
reversementRequests ReversementRequest[]
@@map("transactions")
}
enum PaymentStatus {
PENDING
enum TransactionStatus {
SUCCESS
FAILED
REFUNDED
PENDING
}
enum SubscriptionStatus {
PENDING
TRIAL
ACTIVE
TRIAL
PENDING
SUSPENDED
CANCELLED
EXPIRED
FAILED
CANCELLED
}
enum Periodicity {
Daily
Weekly
Monthly
OneTime
}
model Operator {
id String @id @default(cuid())
code OperatorCode
name String
country String
config Json
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
model Subscription {
id Int @id @default(autoincrement())
externalReference String?
periodicity Periodicity
startDate DateTime
endDate DateTime?
amount Float
currency String
token String
status SubscriptionStatus
nextPaymentDate DateTime
suspendedAt DateTime?
merchantPartnerId Int
customerId Int
planId Int
serviceId Int
failureCount Int?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
metadata Json?
users User[]
@@map("subscriptions")
}
model ReversementRequest {
id Int @id @default(autoincrement())
externalReference String?
startDate DateTime
endDate DateTime
amount Float
tax Float
status TransactionStatus
transactionId Int
paymentId Int?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
metadata Json?
transaction Transaction @relation(fields: [transactionId], references: [id], onDelete: Cascade)
payment Payment? @relation(fields: [paymentId], references: [id])
@@map("reversement_requests")
}
model Payment {
id Int @id @default(autoincrement())
externalReference String?
reference String?
type PaymentType
status TransactionStatus
merchantPartnerId Int
failureReason String?
amount Float?
currency String
completedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
customerId Int
subscriptionId Int?
metadata Json?
link String?
reversementRequests ReversementRequest[]
@@map("payments")
}
enum PaymentType {
MM
BANK
CHEQUE
}
model User {
@ -55,218 +125,10 @@ model User {
userToken String @unique
userAlias String
operatorId String
partnerId String
merchantPartnerId Int
country String
metadata Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
operator Operator @relation(fields: [operatorId], references: [id])
partner Partner @relation(fields: [partnerId], references: [id])
subscriptions Subscription[]
payments Payment[]
invoices Invoice[]
notifications Notification[] // Added relation
}
model Plan {
id String @id @default(cuid())
partnerId String
code String
name String
description String?
amount Float
currency String
interval String // DAILY, WEEKLY, MONTHLY, YEARLY
intervalCount Int @default(1)
trialDays Int @default(0)
features Json? // Array of features
limits Json? // Object with usage limits
metadata Json?
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
partner Partner @relation(fields: [partnerId], references: [id])
subscriptions Subscription[]
@@unique([partnerId, code])
@@index([partnerId, active])
}
model Invoice {
id String @id @default(cuid())
number String @unique
subscriptionId String
userId String
partnerId String
paymentId String? @unique
amount Float
currency String
status String // PENDING, PAID, FAILED, CANCELLED
billingPeriodStart DateTime
billingPeriodEnd DateTime
dueDate DateTime
paidAt DateTime?
items Json // Array of line items
attempts Int @default(0)
failureReason String?
metadata Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
subscription Subscription @relation(fields: [subscriptionId], references: [id])
user User @relation(fields: [userId], references: [id])
partner Partner @relation(fields: [partnerId], references: [id])
payment Payment? @relation(fields: [paymentId], references: [id])
@@index([subscriptionId])
@@index([partnerId, status])
}
model Subscription {
id String @id @default(cuid())
userId String
planId String
partnerId String
status SubscriptionStatus
currentPeriodStart DateTime
currentPeriodEnd DateTime
nextBillingDate DateTime?
trialEndsAt DateTime?
cancelledAt DateTime?
suspendedAt DateTime?
failureCount Int @default(0)
renewalCount Int @default(0)
lastPaymentId String?
metadata Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id])
plan Plan @relation(fields: [planId], references: [id])
partner Partner @relation(fields: [partnerId], references: [id])
payments Payment[]
invoices Invoice[]
}
model Payment {
id String @id @default(cuid())
userId String
partnerId String
subscriptionId String?
amount Float
currency String
description String
reference String @unique
operatorReference String?
status PaymentStatus
failureReason String?
metadata Json?
completedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id])
partner Partner @relation(fields: [partnerId], references: [id])
subscription Subscription? @relation(fields: [subscriptionId], references: [id])
refunds Refund[]
invoice Invoice?
}
model Refund {
id String @id @default(cuid())
paymentId String
amount Float
reason String?
status String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
payment Payment @relation(fields: [paymentId], references: [id])
}
model Webhook {
id String @id @default(cuid())
partnerId String? // Made optional for system webhooks
url String
event String
payload Json
response Json?
status String
attempts Int @default(0)
lastAttempt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
partner Partner? @relation(fields: [partnerId], references: [id])
}
model Partner {
id String @id @default(cuid())
name String
email String @unique
passwordHash String
apiKey String @unique
secretKey String
status String @default("PENDING")
companyInfo Json?
callbacks Json?
country String
metadata Json?
keysRotatedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
users User[]
subscriptions Subscription[]
payments Payment[]
authSessions AuthSession[]
plans Plan[]
invoices Invoice[]
notifications Notification[] // Added relation
webhooks Webhook[] // Added relation
}
model AuthSession {
id String @id @default(cuid())
sessionId String @unique
partnerId String
userId String?
msisdn String
operator String
country String
authMethod String
challengeId String?
status String
expiresAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
partner Partner @relation(fields: [partnerId], references: [id])
}
model Notification {
id String @id @default(cuid())
partnerId String
userId String?
type String // PAYMENT, SUBSCRIPTION, ALERT, MARKETING
channel String // SMS, EMAIL, WEBHOOK
recipient String
subject String?
content String
templateId String?
status String // PENDING, SENT, FAILED
batchId String?
scheduledFor DateTime?
sentAt DateTime?
failedAt DateTime?
failureReason String?
response Json?
metadata Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
partner Partner @relation(fields: [partnerId], references: [id])
user User? @relation(fields: [userId], references: [id])
}

View File

@ -31,6 +31,8 @@ import { SubscriptionsModule } from './modules/subscriptions/subscriptions.modul
redis: {
host: configService.get('app.redis.host'),
port: configService.get('app.redis.port'),
password: configService.get('app.redis.password'),
},
}),
inject: [ConfigService],
@ -41,7 +43,9 @@ import { SubscriptionsModule } from './modules/subscriptions/subscriptions.modul
store: redisStore,
host: configService.get('app.redis.host'),
port: configService.get('app.redis.port'),
password: configService.get('app.redis.password'),
ttl: 600, // 10 minutes default
}),
inject: [ConfigService],
isGlobal: true,

View File

@ -0,0 +1,59 @@
import { Module } from '@nestjs/common';
import Redis from 'ioredis';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { RedisCacheService } from './services/cache.redis';
/**
* Module pour le challenge OTP
* Gère l'injection de dépendances et la configuration
*/
@Module({
imports: [
],
controllers: [],
providers: [
{
provide: 'REDIS_CLIENT',
useFactory: (configService: ConfigService) => {
const redisConfig = {
host: configService.get('REDIS_HOST', 'localhost'),
port: configService.get('REDIS_PORT', 6379),
password: configService.get('REDIS_PASSWORD'), // ⚠️ Important
db: configService.get('REDIS_DB', 0),
keyPrefix: configService.get('REDIS_KEY_PREFIX', 'app:'),
retryStrategy: (times) => {
const delay = Math.min(times * 50, 2000);
return delay;
},
maxRetriesPerRequest: 3,
enableOfflineQueue: false,
lazyConnect: false, // Connexion immédiate
};
const redis = new Redis(redisConfig);
// Gestion des événements de connexion
redis.on('connect', () => {
console.log('✅ Redis connected successfully');
});
redis.on('error', (err) => {
console.error('❌ Redis connection error:', err.message);
});
redis.on('ready', () => {
console.log('✅ Redis is ready');
});
return redis;
},
inject: [ConfigService],
},
RedisCacheService,
],
exports: [RedisCacheService],
})
export class CommonModule {}

View File

@ -0,0 +1,31 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsInt, IsOptional, Min, Max } from 'class-validator';
export class PaginationDto {
@ApiProperty({
description: 'Page number',
minimum: 1,
default: 1,
required: false,
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@ApiProperty({
description: 'Number of items per page',
minimum: 1,
maximum: 100,
default: 10,
required: false,
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit?: number = 10;
}

View File

@ -13,15 +13,8 @@ export class ApiKeyGuard implements CanActivate {
throw new UnauthorizedException('API key is required');
}
const partner = await this.prisma.partner.findUnique({
where: { apiKey },
});
if (!partner || partner.status !== 'ACTIVE') {
throw new UnauthorizedException('Invalid or inactive API key');
}
request.partner = partner;
return true;
}
}

View File

@ -0,0 +1,11 @@
export interface PaginatedResponse<T> {
data: T[];
meta: {
total: number;
page: number;
limit: number;
totalPages: number;
hasNextPage: boolean;
hasPreviousPage: boolean;
};
}

View File

@ -0,0 +1,253 @@
import { Injectable, Inject, Logger } from '@nestjs/common';
import { Redis } from 'ioredis';
export interface CacheOptions {
ttl?: number; // Time to live en secondes
prefix?: string; // Préfixe pour les clés
}
@Injectable()
export class RedisCacheService {
private readonly logger = new Logger(RedisCacheService.name);
private readonly DEFAULT_TTL = 300; // 5 minutes
constructor(@Inject('REDIS_CLIENT') private readonly redis: Redis) {}
/**
* Sauvegarder une valeur dans le cache
*/
async set<T>(
key: string,
value: T,
options?: CacheOptions
): Promise<void> {
try {
const fullKey = this.buildKey(key, options?.prefix);
const serializedValue = JSON.stringify(value);
const ttl = options?.ttl || this.DEFAULT_TTL;
await this.redis.setex(fullKey, ttl, serializedValue);
this.logger.debug(`Cache set: ${fullKey} (TTL: ${ttl}s)`);
} catch (error) {
this.logger.error(`Failed to set cache for key ${key}:`, error);
throw error;
}
}
/**
* Récupérer une valeur depuis le cache
*/
async get<T>(key: string, prefix?: string): Promise<T | null> {
try {
const fullKey = this.buildKey(key, prefix);
this.logger.debug(`Cache fullkey: ${fullKey}`);
const data = await this.redis.get(fullKey);
if (!data) {
this.logger.debug(`Error Cache miss: ${fullKey}`);
return null;
}
this.logger.debug(`Cache hit: ${fullKey}`);
return JSON.parse(data) as T;
} catch (error) {
this.logger.error(`Failed to get cache for key ${key}:`, error);
return null;
}
}
/**
* Supprimer une valeur du cache
*/
async delete(key: string, prefix?: string): Promise<boolean> {
try {
const fullKey = this.buildKey(key, prefix);
const result = await this.redis.del(fullKey);
this.logger.debug(`Cache delete: ${fullKey}`);
return result > 0;
} catch (error) {
this.logger.error(`Failed to delete cache for key ${key}:`, error);
return false;
}
}
/**
* Vérifier si une clé existe
*/
async exists(key: string, prefix?: string): Promise<boolean> {
try {
const fullKey = this.buildKey(key, prefix);
const result = await this.redis.exists(fullKey);
return result === 1;
} catch (error) {
this.logger.error(`Failed to check existence for key ${key}:`, error);
return false;
}
}
/**
* Mettre à jour le TTL d'une clé
*/
async updateTTL(key: string, ttl: number, prefix?: string): Promise<boolean> {
try {
const fullKey = this.buildKey(key, prefix);
const result = await this.redis.expire(fullKey, ttl);
return result === 1;
} catch (error) {
this.logger.error(`Failed to update TTL for key ${key}:`, error);
return false;
}
}
/**
* Récupérer le TTL restant d'une clé
*/
async getTTL(key: string, prefix?: string): Promise<number> {
try {
const fullKey = this.buildKey(key, prefix);
return await this.redis.ttl(fullKey);
} catch (error) {
this.logger.error(`Failed to get TTL for key ${key}:`, error);
return -1;
}
}
/**
* Supprimer toutes les clés avec un préfixe donné
*/
async deleteByPrefix(prefix: string): Promise<number> {
try {
const pattern = `${prefix}*`;
const keys = await this.redis.keys(pattern);
if (keys.length === 0) {
return 0;
}
const result = await this.redis.del(...keys);
this.logger.debug(`Deleted ${result} keys with prefix: ${prefix}`);
return result;
} catch (error) {
this.logger.error(`Failed to delete by prefix ${prefix}:`, error);
return 0;
}
}
/**
* Récupérer plusieurs valeurs en une fois
*/
async mget<T>(keys: string[], prefix?: string): Promise<(T | null)[]> {
try {
const fullKeys = keys.map(key => this.buildKey(key, prefix));
const values = await this.redis.mget(...fullKeys);
return values.map(value => {
if (!value) return null;
try {
return JSON.parse(value) as T;
} catch {
return null;
}
});
} catch (error) {
this.logger.error('Failed to get multiple cache values:', error);
return keys.map(() => null);
}
}
/**
* Sauvegarder plusieurs valeurs en une fois
*/
async mset<T>(
entries: Array<{ key: string; value: T }>,
options?: CacheOptions
): Promise<void> {
try {
const pipeline = this.redis.pipeline();
const ttl = options?.ttl || this.DEFAULT_TTL;
for (const entry of entries) {
const fullKey = this.buildKey(entry.key, options?.prefix);
const serializedValue = JSON.stringify(entry.value);
pipeline.setex(fullKey, ttl, serializedValue);
}
await pipeline.exec();
this.logger.debug(`Batch set ${entries.length} cache entries`);
} catch (error) {
this.logger.error('Failed to set multiple cache values:', error);
throw error;
}
}
/**
* Incrémenter une valeur numérique
*/
async increment(key: string, prefix?: string, amount: number = 1): Promise<number> {
try {
const fullKey = this.buildKey(key, prefix);
return await this.redis.incrby(fullKey, amount);
} catch (error) {
this.logger.error(`Failed to increment key ${key}:`, error);
throw error;
}
}
/**
* Obtenir ou définir (get-or-set pattern)
*/
async getOrSet<T>(
key: string,
factory: () => Promise<T>,
options?: CacheOptions
): Promise<T> {
try {
// Essayer de récupérer depuis le cache
const cached = await this.get<T>(key, options?.prefix);
if (cached !== null) {
return cached;
}
// Si pas en cache, exécuter la factory
const value = await factory();
// Sauvegarder dans le cache
await this.set(key, value, options);
return value;
} catch (error) {
this.logger.error(`Failed getOrSet for key ${key}:`, error);
throw error;
}
}
/**
* Construire la clé complète avec préfixe
*/
private buildKey(key: string, prefix?: string): string {
return prefix ? `${prefix}:${key}` : key;
}
/**
* Vider tout le cache (ATTENTION: à utiliser avec précaution)
*/
async flushAll(): Promise<void> {
try {
await this.redis.flushdb();
this.logger.warn('Cache flushed completely');
} catch (error) {
this.logger.error('Failed to flush cache:', error);
throw error;
}
}
/**
* Obtenir des informations sur Redis
*/
async info(): Promise<string> {
return await this.redis.info();
}
}

View File

@ -12,5 +12,6 @@ export default registerAs('app', () => ({
redis: {
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT?? "6379", 10) || 6379,
password: process.env.REDIS_PASSWORD || undefined,
},
}));

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

@ -1,29 +1,32 @@
/**
* Structure de la requête pour l'API Orange DCB Challenge v2
*/
export interface OrangeChallengeRequest {
country: string;
method: string;
service: string;
partnerId: string;
identifier: {
type: string;
value: string;
};
confirmationCode: string;
message: string;
otpLength: number;
senderName: string;
challenge:{
country: string;
method: string;
service: string;
partnerId: string;
inputs: any[];
}
}
/**
* Structure de la réponse de l'API Orange DCB Challenge
*/
export interface OrangeChallengeResponse {
challengeId?: string;
message?: string;
expiresIn?: number;
sessionId?: string;
challenge: {
method: string,
result: any[],
country: string,
service: string,
partnerId:string,
inputs: [ ]
}
location:string; // "/challenge/v1/challenges/c87d3360-c7bc-488f-86aa-02a537eaf1cc"
error?: {
code: number | string;
message: string;
@ -31,17 +34,46 @@ export interface OrangeChallengeResponse {
};
}
export interface OrangeVerifyResponse {
challenge: {
method: string,
country: string,
service: string,
partnerId:string,
inputs: [ ]
result:any[ ],/*[
{
type: 'ise2',
value: 'PDKSUB-200-KzIyMTc3MTcxNzE3MS1TRU4tMTc2MTc4MzI2NjAy'
}
]*/
}
error?: {
code: number | string;
message: string;
description?: string;
};
}
/**
* Builder pour construire des requêtes Orange Challenge
*/
export class OrangeChallengeRequestBuilder {
private request: Partial<OrangeChallengeRequest> = {};
private request: OrangeChallengeRequest = {
challenge:{
country:'',
method: '',
service: '',
partnerId: '',
inputs:[]}
};
/**
* Définir le pays
*/
withCountry(country: string): this {
this.request.country = country;
this.request.challenge.country = country;
return this;
}
@ -49,7 +81,7 @@ export class OrangeChallengeRequestBuilder {
* Définir la méthode d'authentification
*/
withMethod(method: string): this {
this.request.method = method;
this.request.challenge.method = method;
return this;
}
@ -57,7 +89,7 @@ export class OrangeChallengeRequestBuilder {
* Définir le service
*/
withService(service: string): this {
this.request.service = service;
this.request.challenge.service = service;
return this;
}
@ -65,7 +97,7 @@ export class OrangeChallengeRequestBuilder {
* Définir l'ID du partenaire
*/
withPartnerId(partnerId: string): this {
this.request.partnerId = partnerId;
this.request.challenge.partnerId = partnerId;
return this;
}
@ -73,10 +105,12 @@ export class OrangeChallengeRequestBuilder {
* Définir l'identifiant (numéro de téléphone, etc.)
*/
withIdentifier(type: string, value: string): this {
this.request.identifier = {
type,
value
};
this.request.challenge.inputs?.push({
"type": type,//, or “ISE2”
"value": value// or “PDKSUB-XXXXXX”
},
)
return this;
}
@ -84,23 +118,41 @@ export class OrangeChallengeRequestBuilder {
* Définir le code de confirmation (OTP)
*/
withConfirmationCode(code: string): this {
this.request.confirmationCode = code;
this.request.challenge.inputs?.push(
{
"type": "confirmationCode",
"value": code
},
);
return this;
}
/**
* Définir le message OTP
*/
//todo voir value par defaut
withMessage(message: string): this {
this.request.message = message;
this.request.challenge.inputs?.push(
{
"type": "message",
"value": message
},
)
return this;
}
/**
* Définir la longueur de l'OTP
*/
//todo mettre la valeur par defaut
withOtpLength(length: number): this {
this.request.otpLength = length;
this.request.challenge.inputs?.push(
{
"type": "otpLength",
"value": length
},
)
return this;
}
@ -108,7 +160,22 @@ export class OrangeChallengeRequestBuilder {
* Définir le nom de l'expéditeur
*/
withSenderName(senderName: string): this {
this.request.senderName = senderName;
this.request.challenge.inputs?.push(
{
"type": "senderName",
"value": senderName
}
)
return this;
}
withInfo(infoValue: string): this {
this.request.challenge.inputs?.push(
{
"type": "info",
"value": infoValue
}
)
return this;
}
@ -117,20 +184,20 @@ export class OrangeChallengeRequestBuilder {
*/
build(): OrangeChallengeRequest {
// Validation des champs obligatoires
if (!this.request.country) {
if (!this.request.challenge.country) {
throw new Error('Country is required');
}
if (!this.request.method) {
if (!this.request.challenge.method) {
throw new Error('Method is required');
}
if (!this.request.service) {
if (!this.request.challenge.service) {
throw new Error('Service is required');
}
if (!this.request.partnerId) {
if (!this.request.challenge.partnerId) {
throw new Error('Partner ID is required');
}
if (!this.request.identifier) {
throw new Error('Identifier is required');
if (!this.request.challenge.inputs) {
throw new Error('inputs is required');
}
return this.request as OrangeChallengeRequest;
@ -140,7 +207,14 @@ export class OrangeChallengeRequestBuilder {
* Réinitialiser le builder
*/
reset(): this {
this.request = {};
this.request ={
challenge:{
country:'',
method: '',
service: '',
partnerId: '',
inputs:[]}
};
return this;
}
}

View File

@ -2,7 +2,8 @@ import axios, { AxiosInstance, AxiosError } from 'axios';
import {
OrangeChallengeRequest,
OrangeChallengeResponse,
OrangeChallengeRequestBuilder
OrangeChallengeRequestBuilder,
OrangeVerifyResponse
} from './dtos/orange.challenge.dto'
import {
OrangeConfig,
@ -13,13 +14,17 @@ import {
//import { OtpChallengeResponseDto, OtpChallengeStatusEnum } from '../../dtos/otp-challenge-response.dto';
import { OtpChallengeRequestDto } from '../dto/challenge.request.dto';
import { OtpChallengeResponseDto, OtpChallengeStatusEnum } from '../dto/challenge.response.dto';
import { OtpChallengeResponseDto, OtpChallengeStatusEnum, OtpVerifResponseDto } from '../dto/challenge.response.dto';
import { Logger } from '@nestjs/common';
import { log } from 'console';
/**
* Adaptateur pour l'API Orange DCB v2
* Gère l'authentification OAuth2 et les appels à l'API Challenge
*/
export class OrangeAdapter {
private readonly logger = new Logger(OrangeAdapter.name);
private axiosInstance: AxiosInstance;
private config: OrangeConfig;
private accessToken: string | null = null;
@ -58,6 +63,9 @@ export class OrangeAdapter {
`${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',
@ -83,7 +91,49 @@ export class OrangeAdapter {
/**
* Convertir la requête générique en format Orange
*/
private mapToOrangeRequest(request: OtpChallengeRequestDto): OrangeChallengeRequest {
private mapToOrangeRequestChallenge(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
/* todo voir si mandatory
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();
}
private mapToOrangeRequestVerify(request: OtpChallengeRequestDto): OrangeChallengeRequest {
const builder = new OrangeChallengeRequestBuilder();
// Mapper le pays
@ -109,17 +159,11 @@ export class OrangeAdapter {
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);
builder.withInfo("ise2");
return builder.build();
}
@ -127,17 +171,18 @@ export class OrangeAdapter {
/**
* Convertir la réponse Orange en format générique
*/
private mapFromOrangeResponse(
private mapFromOrangeChallengeResponse(
orangeResponse: OrangeChallengeResponse,
request: OtpChallengeRequestDto
): OtpChallengeResponseDto {
const partsChallengeLocation= orangeResponse.location.split('/');
const response: OtpChallengeResponseDto = {
challengeId: orangeResponse.challengeId || '',
challengeId: partsChallengeLocation[partsChallengeLocation.length - 1],
merchantId: request.merchantId,
status: this.mapOrangeStatus(orangeResponse),
message: orangeResponse.message,
expiresIn: orangeResponse.expiresIn,
sessionId: orangeResponse.sessionId,
message: orangeResponse.challenge.result+"",
expiresIn: new Date().getTime(),
//sessionId: orangeResponse.sessionId,
requiresConfirmation: true,
metadata: {
provider: 'orange',
@ -159,6 +204,35 @@ export class OrangeAdapter {
return response;
}
private mapFromOrangeVerifyResponse(
orangeResponse: OrangeVerifyResponse,
request: OtpChallengeRequestDto
): OtpVerifResponseDto {
const response: OtpVerifResponseDto = {
merchantId: request.merchantId,
status: this.mapOrangeResponseStatus(orangeResponse),
userAlias: orangeResponse.challenge.result?.[0]['value'] || 'not presenter',
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
*/
@ -167,13 +241,23 @@ export class OrangeAdapter {
return OtpChallengeStatusEnum.FAILED;
}
if (orangeResponse.challengeId) {
if (orangeResponse.location) {
return OtpChallengeStatusEnum.SENT;
}
return OtpChallengeStatusEnum.PENDING;
}
private mapOrangeResponseStatus(orangeResponse: OrangeVerifyResponse): OtpChallengeStatusEnum {
if (orangeResponse.error) {
return OtpChallengeStatusEnum.FAILED;
}else{
return OtpChallengeStatusEnum.VERIFIED;
}
}
/**
* Gérer les erreurs HTTP
*/
@ -197,9 +281,14 @@ export class OrangeAdapter {
try {
// Obtenir le token
const token = await this.getAccessToken();
//this.logger.debug(`initiateChallenge --> acces token ${token}`);
// Mapper la requête
const orangeRequest = this.mapToOrangeRequest(request);
const orangeRequest = this.mapToOrangeRequestChallenge(request);
this.logger.debug(
`[request to orange ]: ${JSON.stringify(orangeRequest, null, 2)}`,
)
// Appeler l'API Orange
const response = await this.axiosInstance.post<OrangeChallengeResponse>(
@ -213,7 +302,7 @@ export class OrangeAdapter {
);
// Mapper la réponse
return this.mapFromOrangeResponse(response.data, request);
return this.mapFromOrangeChallengeResponse(response.data, request);
} catch (error) {
// En cas d'erreur, retourner une réponse avec le statut FAILED
return {
@ -236,7 +325,7 @@ export class OrangeAdapter {
challengeId: string,
otpCode: string,
originalRequest: OtpChallengeRequestDto
): Promise<OtpChallengeResponseDto> {
): Promise<OtpVerifResponseDto> {
try {
// Créer une nouvelle requête avec le code de confirmation
const verifyRequest: OtpChallengeRequestDto = {
@ -248,11 +337,15 @@ export class OrangeAdapter {
const token = await this.getAccessToken();
// Mapper la requête
const orangeRequest = this.mapToOrangeRequest(verifyRequest);
const orangeRequest = this.mapToOrangeRequestVerify(verifyRequest);
this.logger.debug(
`[request to orange (verify) ]: ${JSON.stringify(orangeRequest, null, 2)}`,
)
// Appeler l'API Orange pour vérification
const response = await this.axiosInstance.post<OrangeChallengeResponse>(
this.config.challengeEndpoint,
// Appeler l'API Orange pour vérification todo use request otp challenge
//
const response = await this.axiosInstance.post<OrangeVerifyResponse>(
`${this.config.challengeEndpoint}/${challengeId}`,
orangeRequest,
{
headers: {
@ -260,9 +353,17 @@ export class OrangeAdapter {
}
}
);
//${JSON.stringify(response, null, 2)}
this.logger.debug(
`[response from orange (verify) ${JSON.stringify(response.data, null, 2)} ]: `,
)
// Mapper la réponse
const mappedResponse = this.mapFromOrangeResponse(response.data, verifyRequest);
const mappedResponse = this.mapFromOrangeVerifyResponse(response.data, verifyRequest);
this.logger.debug(
`[response parsed from orange (verify) ${JSON.stringify(mappedResponse, null, 2)} ]: `,
)
// Si pas d'erreur, c'est vérifié
if (!mappedResponse.error) {
@ -272,7 +373,7 @@ export class OrangeAdapter {
return mappedResponse;
} catch (error) {
return {
challengeId,
userAlias:'undefined',
merchantId: originalRequest.merchantId,
status: OtpChallengeStatusEnum.FAILED,
error: {

View File

@ -40,6 +40,36 @@ export class OtpChallengeResponseDto {
@IsBoolean()
requiresConfirmation?: boolean;
@IsOptional()
metadata?: Record<string, any>;
@IsOptional()
error?: {
code: string;
message: string;
description?: string;
};
}
export class OtpVerifResponseDto {
@IsString()
@IsNotEmpty()
merchantId: string;
@IsString()
@IsNotEmpty()
userAlias: string;
@IsEnum(OtpChallengeStatusEnum)
@IsNotEmpty()
status: OtpChallengeStatusEnum;
@IsOptional()
@IsString()
message?: string;
@IsOptional()
metadata?: Record<string, any>;

View File

@ -23,6 +23,7 @@ import {
import {
OtpChallengeResponseDto,
OtpChallengeStatusEnum,
OtpVerifResponseDto,
} from './dto/challenge.response.dto';
import { OtpChallengeRequestDto } from './dto/challenge.request.dto';
import { OtpChallengeService } from './otp.challenge.service';
@ -177,7 +178,7 @@ export class OtpChallengeController {
@Body('otpCode') otpCode: string,
@Headers('X-Merchant-ID') merchantId: string,
@Headers('x-API-KEY') apiKey: string,
): Promise<OtpChallengeResponseDto> {
): Promise<OtpVerifResponseDto> {
this.logger.log(
`[VERIFY] Merchant: ${merchantId}, Challenge: ${challengeId}`,
);
@ -193,6 +194,7 @@ export class OtpChallengeController {
otpCode,
merchantId,
);
this.logger.log(`[VERIFY] Result - object: ${response}`);
// Logger le résultat
this.logger.log(`[VERIFY] Result - Status: ${response.status}`);

View File

@ -3,19 +3,21 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
import { OtpChallengeController } from './otp.challenge.controller';
import { OrangeConfig } from './adaptor/orange.config';
import { OtpChallengeService } from './otp.challenge.service';
import { CommonModule } from 'src/common/commonde.module';
/**
* Module pour le challenge OTP
* Gère l'injection de dépendances et la configuration
*/
@Module({
imports: [ConfigModule],
imports: [ConfigModule, CommonModule],
controllers: [OtpChallengeController],
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'),

View File

@ -1,9 +1,11 @@
import { Injectable, Inject } from '@nestjs/common';
import { Injectable, Inject, Logger } 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';
import { OtpChallengeResponseDto, OtpChallengeStatusEnum, OtpVerifResponseDto } from './dto/challenge.response.dto';
import { RedisCacheService } from 'src/common/services/cache.redis';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
/**
* Service Hub pour gérer les challenges OTP
@ -11,12 +13,17 @@ import { OtpChallengeResponseDto, OtpChallengeStatusEnum } from './dto/challenge
*/
@Injectable()
export class OtpChallengeService implements IOtpChallengeService {
private readonly logger = new Logger(OtpChallengeService.name);
private readonly CACHE_PREFIX = 'otp:challenge';
private readonly DEFAULT_TTL = 300; // 5 minutes
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();
constructor(
//@Inject(CACHE_MANAGER) private cacheManager: Cache,
@Inject('ORANGE_CONFIG') private readonly orangeConfig: OrangeConfig,
private readonly cacheService: RedisCacheService) {
this.orangeAdapter = new OrangeAdapter(this.orangeConfig);
}
/**
@ -26,16 +33,16 @@ export class OtpChallengeService implements IOtpChallengeService {
try {
// Appeler l'adaptateur Orange
const response = await this.orangeAdapter.initiateChallenge(request);
if (response.challengeId || true) {
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);
if (response.challengeId || true) {
// this.cacheManager
await this.cacheService.set(response.challengeId , {request:request,response:response}, {
prefix: this.CACHE_PREFIX,
ttl: this.DEFAULT_TTL,
});
}
return response;
@ -51,14 +58,15 @@ export class OtpChallengeService implements IOtpChallengeService {
challengeId: string,
otpCode: string,
merchantId: string
): Promise<OtpChallengeResponseDto> {
): Promise<any> {
try {
// Récupérer le challenge depuis le cache
const cached = this.challengeCache.get(challengeId);
const cached:any =await this.cacheService.get(challengeId,this.CACHE_PREFIX,);
this.logger.debug(`cache retrieve , ${cached}`)
if (!cached) {
return {
challengeId,
userAlias:"",
merchantId,
status: OtpChallengeStatusEnum.FAILED,
error: {
@ -72,7 +80,7 @@ export class OtpChallengeService implements IOtpChallengeService {
// Vérifier que le merchantId correspond
if (cached.request.merchantId !== merchantId) {
return {
challengeId,
userAlias:"",
merchantId,
status: OtpChallengeStatusEnum.FAILED,
error: {
@ -92,13 +100,13 @@ export class OtpChallengeService implements IOtpChallengeService {
// Mettre à jour le cache
if (response.status === OtpChallengeStatusEnum.VERIFIED) {
this.challengeCache.set(challengeId, { request: cached.request, response });
this.cacheService.set(challengeId, { request: cached.request, response });
}
return response;
} catch (error) {
return {
challengeId,
userAlias:"",
merchantId,
status: OtpChallengeStatusEnum.FAILED,
error: {

View File

@ -24,19 +24,13 @@ export interface RefundResponse{
}
export interface SubscriptionParams{
}
export interface SubscriptionResponse{
}
export interface IOperatorAdapter {
initializeAuth(params: AuthInitParams): Promise<AuthInitResponse>;
validateAuth(params: AuthValidateParams): Promise<AuthValidateResponse>;
charge(params: ChargeParams): Promise<ChargeResponse>;
refund(params: RefundParams): Promise<RefundResponse>;
sendSms(params: SmsParams): Promise<SmsResponse>;
createSubscription?(
createSubscription(
params: SubscriptionParams,
): Promise<SubscriptionResponse>;
cancelSubscription?(subscriptionId: string): Promise<void>;
@ -70,5 +64,27 @@ export interface ChargeResponse {
status: 'SUCCESS' | 'FAILED' | 'PENDING';
operatorReference: string;
amount: number;
resourceURL: string;
currency: string;
}
export interface SubscriptionParams {
merchantId: any;
periodicity: any;
userToken: string;
userAlias: string;
amount: number;
currency: string;
description: string;
productId: string;
}
export interface SubscriptionResponse {
subscriptionId: string;
status: 'SUCCESS' | 'FAILED' | 'PENDING';
operatorReference: string;
amount: number;
resourceURL: string;
currency: string;
}

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 {
@ -8,33 +9,57 @@ import {
AuthInitResponse,
ChargeParams,
ChargeResponse,
SubscriptionParams,
SubscriptionResponse,
} 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,
@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,
service: 'BIZAO',
service: 'DCB_HUB',
partnerId: 'PDKSUB',
inputs: [
{
@ -64,7 +89,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,11 +112,11 @@ export class OrangeAdapter implements IOperatorAdapter {
}
async validateAuth(params: any): Promise<any> {
const bizaoRequest = {
const hubRequest = {
challenge: {
method: 'OTP-SMS-AUTH',
country: params.country,
service: 'BIZAO',
service: 'DCB_HUB',
partnerId: 'PDKSUB',
inputs: [
{
@ -113,7 +138,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}`,
@ -138,8 +163,123 @@ export class OrangeAdapter implements IOperatorAdapter {
};
}
async createSubscription(
params: SubscriptionParams,
): Promise<SubscriptionResponse> {
this.logger.debug(
`[orange adapter createSubscription]: ${JSON.stringify(params, null, 2)}`,
);
const hubRequest = {
note: {
"text": "partner data"
},
relatedPublicKey: {
"id": "PDKSUB-200-KzIxNnh4eHh4eC1TRU4tMTc1ODY1MjI5MjMwMg==",
"name": "ISE2"
},
relatedParty: [
{
"id": "{{serviceId)}}",
"name": " DIGITALAFRIQUETELECOM ",
"role": "partner"
},
{
"id": `${params.merchantId}`,
"name": "{{onBehalfOf)}}",
"role": "retailer"
}
],
orderItem: {
"action": "add",
"state": "Completed",
"product": {
"id": `${params.productId}}`,
"href": "antifraudId",
"productCharacteristic": [
{
"name": "taxAmount",
"value": "0"
},
{
"name": "amount",
"value": `${params.amount}`
},
{
"name": "currency",//ISO 4217 see Annexes
"value": `${params.currency}`
},
{
"name": "periodicity",//86400 (daily), 604800 (weekly), 0 (monthly) only those values will be accepted
"value": `${params.periodicity}`
},
{
"name": "startDate",//YYYY-MM-DD
"value": "2021-08-16"
},
{
"name": "country",
"value": "COD"
},
{
"name": "language",//ISO 639-1 see Annexes
"value": "fr"
},
{
"name": "mode",
"value": "hybrid"
}
]
}
},
};
const token = await this.getAccessToken();
this.logger.debug(
`[requesting subscription to]: ${this.config.baseUrl}/payment/mea/v1/digipay_sub/productOrder`,
);
this.logger.debug(`[requesting token]: ${token}`);
const response = await firstValueFrom(
this.httpService.post(
`${this.config.baseUrl}/payment/mea/v1/digipay_sub/productOrder`,
hubRequest,
{
headers: {
Authorization: `Bearer ${token}`,
'X-Orange-ISE2': params.userToken,
'X-Orange-MCO': 'orange',
'Content-Type': 'application/json',
},
},
),
);
this.logger.debug(
`[response from orange subscription]: ${JSON.stringify(response.data, null, 2)}`,
);
return this.transformer.transformSubscriptionResponse(response.data);
}
async cancelSubscription(subscriptionId: string): Promise<void> {
this.logger.debug(
`[orange adapter cancelSubscription]: ${subscriptionId}`,
);
// Implémentation de l'annulation d'abonnement
// Cela dépend de l'API Orange - à adapter selon la documentation
throw new Error('Cancel subscription not implemented for Orange');
}
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 +289,43 @@ export class OrangeAdapter implements IOperatorAdapter {
description: params.description,
},
chargingMetaData: {
onBehalfOf: 'PaymentHub',
serviceId: 'BIZAO',
onBehalfOf: 'PaymentHub', //from config todo
purchaseCategoryCode: 'Service', //todo from config
serviceId: 'DCB_HUB',
},
},
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/mea/v1/acr%3AX-Orange-ISE2/transactions/amount`,
);
this.logger.debug(
`[requesting token ]: ${token} `,
);
const response = await firstValueFrom(
this.httpService.post(
`${this.baseUrl}/payment/v1/acr%3AOrangeAPIToken/transactions/amount`,
bizaoRequest,
`${this.config.baseUrl}/payment/mea/v1/acr%3AX-Orange-ISE2/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);
}
@ -182,6 +334,8 @@ export class OrangeAdapter implements IOperatorAdapter {
throw new Error('Refund not implemented for Orange');
}
async sendSms(params: any): Promise<any> {
const smsRequest = {
outboundSMSMessageRequest: {
@ -204,11 +358,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',
},
@ -257,4 +411,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

@ -43,57 +43,7 @@ export class OperatorsController {
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' })
@ -141,42 +91,9 @@ export class OperatorsController {
return this.operatorsService.detectOperatorByMsisdn(msisdn);
}
@Get(':operatorCode/pricing')
@ApiOperation({ summary: 'Get operator pricing information' })
@ApiResponse({
status: 200,
description: 'Operator pricing details',
})
async getOperatorPricing(@Param('operatorCode') operatorCode: string) {
return this.operatorsService.getOperatorPricing(operatorCode);
}
@Get(':operatorCode/capabilities')
@ApiOperation({ summary: 'Get operator capabilities' })
@ApiResponse({
status: 200,
description: 'Operator capabilities and features',
})
async getOperatorCapabilities(@Param('operatorCode') operatorCode: string) {
return this.operatorsService.getOperatorCapabilities(operatorCode);
}
@Put(':operatorCode/toggle-status')
@ApiOperation({ summary: 'Enable/Disable operator (Admin only)' })
@ApiResponse({
status: 200,
description: 'Operator status updated',
})
async toggleOperatorStatus(
@Request() req,
@Param('operatorCode') operatorCode: string,
@Body() body: { active: boolean; reason?: string },
) {
// Add admin check here
return this.operatorsService.toggleOperatorStatus(
operatorCode,
body.active,
body.reason,
);
}
}

View File

@ -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(
@ -18,149 +19,6 @@ export class OperatorsService{
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;
@ -191,8 +49,8 @@ export class OperatorsService{
}),
this.prisma.payment.findMany({
where,
distinct: ['userId'],
select: { userId: true },
//distinct: ['userId'],
//select: { userId: true },
}),
]);
@ -220,18 +78,14 @@ export class OperatorsService{
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 },
},
},
}),
]);
@ -273,90 +127,6 @@ export class OperatorsService{
};
}
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 = {

View File

@ -2,29 +2,46 @@ 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 {
transformSubscriptionResponse(orangeResponse: any): any {
return {
subscriptionId: orangeResponse.id,
status: this.mapStatus(
orangeResponse.state,
),
operatorReference: orangeResponse.amountTransaction?.serverReferenceCode,
amount: parseFloat(
orangeResponse.amountTransaction?.paymentAmount?.totalAmountCharged,
),
createdAt: new Date(),
};
}
private mapStatus(orangeStatus: string): string {//todo make exaustifs
const statusMap = {
Completed: 'SUCCESS',
Charged: 'SUCCESS',
Failed: 'FAILED',
Pending: 'PENDING',
};
return statusMap[bizaoStatus] || 'PENDING';
return statusMap[orangeStatus] || 'PENDING';
}
}

View File

@ -6,9 +6,13 @@ import {
Min,
IsEnum,
IsDateString,
isNumber,
IsInt,
} from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { PaymentType, TransactionStatus } from 'generated/prisma';
import { PaginationDto } from 'src/common/dto/pagination.dto';
export class ChargeDto {
@ApiProperty({ description: 'User token from authentication' })
@ -35,8 +39,8 @@ export class ChargeDto {
@ApiProperty({ required: false, description: 'Subscription ID if recurring' })
@IsOptional()
@IsString()
subscriptionId?: string;
@IsNumber()
subscriptionId?: number;
@ApiProperty({ required: false, description: 'Callback URL for notifications' })
@IsOptional()
@ -49,14 +53,22 @@ 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 {
@ApiProperty({ required: false, description: 'Amount to refund (partial refund)' })
@IsOptional()
@IsNumber()
@Min(0)
@Min(1)
amount?: number;
@ApiProperty({ description: 'Reason for refund' })
@ -68,46 +80,6 @@ export class RefundDto {
metadata?: Record<string, any>;
}
export class PaymentQueryDto {
@ApiProperty({ required: false, enum: ['PENDING', 'SUCCESS', 'FAILED', 'REFUNDED'] })
@IsOptional()
@IsEnum(['PENDING', 'SUCCESS', 'FAILED', 'REFUNDED'])
status?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
userId?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
subscriptionId?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsDateString()
startDate?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsDateString()
endDate?: string;
@ApiProperty({ required: false, default: 1 })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
page?: number = 1;
@ApiProperty({ required: false, default: 20 })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
limit?: number = 20;
}
export class PaymentResponseDto {
@ApiProperty()
@ -159,3 +131,156 @@ export class PaymentListResponseDto {
totalPages: number;
};
}
export class PaymentQueryDto extends PaginationDto {
@ApiProperty({
description: 'Filter by payment type',
enum: PaymentType,
required: false,
})
@IsOptional()
@IsEnum(PaymentType)
type?: PaymentType;
@ApiProperty({
description: 'Filter by transaction status',
enum: TransactionStatus,
required: false,
})
@IsOptional()
@IsEnum(TransactionStatus)
status?: TransactionStatus;
@ApiProperty({
description: 'Filter by merchant partner ID',
required: false,
})
@IsOptional()
@Type(() => Number)
@IsInt()
merchantPartnerId?: number;
@ApiProperty({
description: 'Filter by customer ID',
required: false,
})
@IsOptional()
@Type(() => Number)
@IsInt()
customerId?: number;
@ApiProperty({
description: 'Filter by subscription ID',
required: false,
})
@IsOptional()
@Type(() => Number)
@IsInt()
subscriptionId?: number;
@ApiProperty({
description: 'Search by external reference',
required: false,
})
@IsOptional()
@IsString()
externalReference?: string;
@ApiProperty({
description: 'Search by payment reference',
required: false,
})
@IsOptional()
@IsString()
reference?: string;
@ApiProperty({
description: 'Filter by currency code (e.g., XOF, XAF, EUR)',
example: 'XOF',
required: false,
})
@IsOptional()
@IsString()
currency?: string;
@ApiProperty({
description: 'Filter payments with amount greater than or equal to this value',
required: false,
})
@IsOptional()
@Type(() => Number)
amountMin?: number;
@ApiProperty({
description: 'Filter payments with amount less than or equal to this value',
required: false,
})
@IsOptional()
@Type(() => Number)
amountMax?: number;
@ApiProperty({
description: 'Filter payments created from this date (ISO format)',
example: '2024-01-01T00:00:00Z',
required: false,
})
@IsOptional()
@IsDateString()
createdFrom?: string;
@ApiProperty({
description: 'Filter payments created until this date (ISO format)',
example: '2024-12-31T23:59:59Z',
required: false,
})
@IsOptional()
@IsDateString()
createdTo?: string;
@ApiProperty({
description: 'Filter payments completed from this date (ISO format)',
example: '2024-01-01T00:00:00Z',
required: false,
})
@IsOptional()
@IsDateString()
completedFrom?: string;
@ApiProperty({
description: 'Filter payments completed until this date (ISO format)',
example: '2024-12-31T23:59:59Z',
required: false,
})
@IsOptional()
@IsDateString()
completedTo?: string;
@ApiProperty({
description: 'Filter only failed payments (with failure reason)',
required: false,
})
@IsOptional()
@Type(() => Boolean)
hasFailureReason?: boolean;
@ApiProperty({
description: 'Sort field',
enum: ['createdAt', 'completedAt', 'amount'],
default: 'createdAt',
required: false,
})
@IsOptional()
@IsString()
sortBy?: 'createdAt' | 'completedAt' | 'amount' = 'createdAt';
@ApiProperty({
description: 'Sort order',
enum: ['asc', 'desc'],
default: 'desc',
required: false,
})
@IsOptional()
@IsEnum(['asc', 'desc'])
sortOrder?: 'asc' | 'desc' = 'desc';
}

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,
country: coutnry,
operator: operator,
});
}
@ -76,7 +89,7 @@ export class PaymentsController {
}
@Get(':paymentId')
@UseGuards(JwtAuthGuard)
//@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Get payment details' })
@ApiResponse({
@ -85,14 +98,15 @@ 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);
}
@Get('reference/:reference')
@UseGuards(JwtAuthGuard)
//@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Get payment by reference' })
@ApiResponse({
@ -105,13 +119,43 @@ export class PaymentsController {
@Param('reference') reference: string,
) {
return this.paymentsService.getPaymentByReference(
reference,
req.user.partnerId,
reference
);
}
@Get('/')
@ApiOperation({
summary: 'Get payment list with pagination and filters',
description: 'Retrieve payments with optional filters on status, type, dates, amounts, etc.'
})
@ApiResponse({
status: 200,
description: 'Paginated list of payments',
})
@ApiOperation({ summary: 'Get payments list' })
async getAll(@Request() req, @Query() paymentQueryDto: PaymentQueryDto) {
return this.paymentsService.findAll(paymentQueryDto);
}
@Get('merchant/:merchantId')
@ApiOperation({ summary: 'Get payments list by merchant' })
async getAllByPaymentByMerchant(@Request() req, @Param('merchantId', ParseIntPipe) merchantId: number, @Query() queryDto: Omit<PaymentQueryDto, 'merchantPartnerId'>,) {
return this.paymentsService.findAll({ ...queryDto, merchantPartnerId: 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,
@Query() queryDto: Omit<PaymentQueryDto, 'merchantPartnerId' | 'subscriptionId'>,) {
return this.paymentsService.findAll({ ...queryDto, merchantPartnerId: merchantId, subscriptionId: subscriptionId });
}
@Post(':paymentId/retry')
@UseGuards(JwtAuthGuard)
// @UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Retry a failed payment' })
@ -130,7 +174,7 @@ export class PaymentsController {
@Post('validate')
@UseGuards(JwtAuthGuard)
//@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Validate payment before processing' })
@ -143,7 +187,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,80 +1,281 @@
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';
import { ChargeDto } from './dto/payment.dto';
import { ChargeDto, PaymentQueryDto } from './dto/payment.dto';
import { RefundDto } from './dto/payment.dto';
import { PaymentStatus } from 'generated/prisma';
import { Payment, PaymentType, Prisma, TransactionStatus } from 'generated/prisma';
import { PaginatedResponse } from 'src/common/interfaces/paginated-response.interface';
@Injectable()
export class PaymentsService {
handleWebhook(arg0: { partnerId: any; event: any; payload: any; signature: any; }) {
throw new Error('Method not implemented.');
}
getPaymentByReference(reference: string, partnerId: any) {
throw new Error('Method not implemented.');
}
getPayment(paymentId: string, partnerId: any) {
throw new Error('Method not implemented.');
}
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{
throw new Error('Method not implemented.');
}
constructor(
private readonly logger = new Logger(PaymentsService.name);
constructor(
private readonly operatorsService: OperatorsService,
private readonly prisma: PrismaService,
private readonly eventEmitter: EventEmitter2,
) {}
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',
},
});
}
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',
},
});
}
async findAll(
queryDto: PaymentQueryDto,
): Promise<PaginatedResponse<Payment>> {
const {
page = 1,
limit = 10,
sortBy = 'createdAt',
sortOrder = 'desc',
} = queryDto;
const skip = (page - 1) * limit;
const where = this.buildWhereClause(queryDto);
// Construction du orderBy dynamique
const orderBy: Prisma.PaymentOrderByWithRelationInput = {
[sortBy]: sortOrder,
};
// Exécuter les requêtes en parallèle
const [payments, total] = await Promise.all([
this.prisma.payment.findMany({
where,
skip,
take: limit,
orderBy,
// Optionnel: inclure les relations
// include: {
// merchantPartner: true,
// subscription: true,
// reversementRequests: true,
// },
}),
this.prisma.payment.count({ where }),
]);
const totalPages = Math.ceil(total / limit);
return {
data: payments,
meta: {
total,
page,
limit,
totalPages,
hasNextPage: page < totalPages,
hasPreviousPage: page > 1,
},
};
}
private buildWhereClause(
filters: Partial<PaymentQueryDto>
): Prisma.PaymentWhereInput {
const {
type,
status,
merchantPartnerId,
customerId,
subscriptionId,
externalReference,
reference,
currency,
amountMin,
amountMax,
createdFrom,
createdTo,
completedFrom,
completedTo,
hasFailureReason,
} = filters;
const where: Prisma.PaymentWhereInput = {};
// Filtres simples
if (type) where.type = type;
if (status) where.status = status;
if (merchantPartnerId) where.merchantPartnerId = merchantPartnerId;
if (customerId) where.customerId = customerId;
if (subscriptionId) where.subscriptionId = subscriptionId;
if (currency) where.currency = currency;
// Filtres de recherche par référence
if (externalReference) {
where.externalReference = {
contains: externalReference,
mode: 'insensitive',
};
}
if (reference) {
where.reference = {
contains: reference,
mode: 'insensitive',
};
}
// Filtre sur les montants
if (amountMin !== undefined || amountMax !== undefined) {
where.amount = {};
if (amountMin !== undefined) {
where.amount.gte = amountMin;
}
if (amountMax !== undefined) {
where.amount.lte = amountMax;
}
}
// Filtres sur createdAt
if (createdFrom || createdTo) {
where.createdAt = {};
if (createdFrom) {
where.createdAt.gte = new Date(createdFrom);
}
if (createdTo) {
where.createdAt.lte = new Date(createdTo);
}
}
// Filtres sur completedAt
if (completedFrom || completedTo) {
where.completedAt = {};
if (completedFrom) {
where.completedAt.gte = new Date(completedFrom);
}
if (completedTo) {
where.completedAt.lte = new Date(completedTo);
}
}
// Filtre sur les paiements échoués
if (hasFailureReason !== undefined) {
if (hasFailureReason) {
where.failureReason = { not: null };
} else {
where.failureReason = null;
}
}
return where;
}
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 {
throw new Error('Method not implemented.');
}
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 },
// include: { operator: true },
});
if (!user) {
throw new BadRequestException('Invalid user token');
}
*/
// Créer la transaction dans la base
const payment = await this.prisma.payment.create({
data: {
partnerId:"",
userId: user.id,
amount: chargeDto.amount,
subscriptionId: chargeDto.subscriptionId,
merchantPartnerId: chargeDto.partnerId, // À remplacer par le bon partnerId
customerId: 1, // todo À remplacer par user.id
amount: chargeDto.amount,
currency: chargeDto.currency,
description: chargeDto.description,
type: PaymentType.MM,
reference: chargeDto.reference || this.generateReference(),
status: PaymentStatus.PENDING,
//description: chargeDto.description,
//reference: chargeDto.reference || this.generateReference(),
status: TransactionStatus.PENDING,
metadata: chargeDto.metadata,
},
});
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,
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({
@ -82,9 +283,10 @@ export class PaymentsService {
data: {
status:
result.status === 'SUCCESS'
? PaymentStatus.SUCCESS
: PaymentStatus.FAILED,
operatorReference: result.operatorReference,
? TransactionStatus.SUCCESS
: TransactionStatus.FAILED,
externalReference: result.operatorReference,
link: result.resourceURL,
completedAt: new Date(),
},
});
@ -92,7 +294,7 @@ export class PaymentsService {
// Émettre un événement
this.eventEmitter.emit('payment.completed', {
payment: updatedPayment,
operator: user.operator.code,
operator: 'user.operator.code',
});
// Appeler le callback du partenaire si fourni
@ -102,16 +304,20 @@ export class PaymentsService {
return updatedPayment;
} catch (error) {
this.logger.debug(
`error ${error.message} processing payment ${payment.id}`,
);
// 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: PaymentStatus.FAILED,
status: TransactionStatus.FAILED,
failureReason: error.message,
},
});
throw error;
return { ...resultFinal };
}
}
@ -126,163 +332,148 @@ export class PaymentsService {
// Ajouter ces méthodes dans PaymentsService
async listPayments(filters: any) {
const where: any = {
partnerId: filters.partnerId,
};
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.status) {
where.status = filters.status;
}
if (filters.endDate) {
where.createdAt.lte = new Date(filters.endDate);
if (filters.userId) {
where.userId = filters.userId;
}
}
const page = filters.page || 1;
const limit = filters.limit || 20;
const skip = (page - 1) * limit;
if (filters.subscriptionId) {
where.subscriptionId = filters.subscriptionId;
}
const [payments, total] = await Promise.all([
this.prisma.payment.findMany({
where,
skip,
take: limit,
orderBy: { createdAt: 'desc' },
include: {
user: {
select: {
id: true,
msisdn: true,
},
},
subscription: {
select: {
id: true,
planId: true,
},
},
},
}),
this.prisma.payment.count({ where }),
]);
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);
}
}
return {
data: payments,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}
const page = filters.page || 1;
const limit = filters.limit || 20;
const skip = (page - 1) * limit;
async getStatistics(params: {
partnerId: string;
period: string;
startDate?: Date;
endDate?: Date;
}) {
const { partnerId, period, startDate, endDate } = params;
const [payments, total] = await Promise.all([
this.prisma.payment.findMany({
where,
skip,
take: limit,
orderBy: { createdAt: 'desc' },
}),
this.prisma.payment.count({ where }),
]);
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',
data: payments,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}
// Vérifier les limites
const todayPayments = await this.prisma.payment.count({
where: {
userId: user.id,
status: 'SUCCESS',
createdAt: {
gte: new Date(new Date().setHours(0, 0, 0, 0)),
},
},
});
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;
if (todayPayments >= 10) {
return {
valid: false,
error: 'Daily payment limit reached',
totalPayments,
successfulPayments,
failedPayments,
successRate: Math.round(successRate * 100) / 100,
totalRevenue: totalRevenue._sum.amount || 0,
avgPaymentAmount: avgPaymentAmount._avg.amount || 0,
period,
startDate,
endDate,
};
}
return {
valid: true,
user: {
id: user.id,
msisdn: user.msisdn,
country: user.country,
},
};
}
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,
},
};
}
}

View File

@ -1,5 +1,8 @@
import { IsString, IsOptional, IsNumber, IsEnum, IsBoolean, Min } from 'class-validator';
import { IsString, IsOptional, IsNumber, IsEnum,IsDateString, IsBoolean, Min, IsInt } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { PaginationDto } from 'src/common/dto/pagination.dto';
import { Type } from 'class-transformer';
import { Periodicity, SubscriptionStatus } from 'generated/prisma';
export class CreateSubscriptionDto {
@ApiProperty()
@ -8,13 +11,12 @@ export class CreateSubscriptionDto {
@ApiProperty()
@IsString()
planId: string;
userAlias: string;
@ApiProperty({ required: false })
@IsOptional()
@ApiProperty()
@IsNumber()
@Min(0)
trialDays?: number;
planId: number;
@ApiProperty({ required: false })
@IsOptional()
@ -27,15 +29,15 @@ export class CreateSubscriptionDto {
}
export class UpdateSubscriptionDto {
@ApiProperty({ required: false, enum: ['ACTIVE', 'PAUSED'] })
@ApiProperty({ required: false, enum: ['ACTIVE', 'SUSPENDED'] })
@IsOptional()
@IsEnum(['ACTIVE', 'PAUSED'])
@IsEnum(['ACTIVE', 'SUSPENDED'])
status?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
planId?: string;
planId?: number;
@ApiProperty({ required: false })
@IsOptional()
@ -46,3 +48,122 @@ export class UpdateSubscriptionDto {
@IsOptional()
metadata?: Record<string, any>;
}
export class SubscriptionQueryDto extends PaginationDto {
@ApiProperty({
description: 'Filter by merchant partner ID',
required: false,
})
@IsOptional()
@Type(() => Number)
@IsInt()
merchantPartnerId?: number;
@ApiProperty({
description: 'Filter by subscription status',
enum: SubscriptionStatus,
required: false,
})
@IsOptional()
@IsEnum(SubscriptionStatus)
status?: SubscriptionStatus;
@ApiProperty({
description: 'Filter by periodicity',
enum: Periodicity,
required: false,
})
@IsOptional()
@IsEnum(Periodicity)
periodicity?: Periodicity;
@ApiProperty({
description: 'Filter by service ID',
required: false,
})
@IsOptional()
@Type(() => Number)
@IsInt()
serviceId?: number;
@ApiProperty({
description: 'Filter subscriptions starting from this date (ISO format)',
example: '2024-01-01',
required: false,
})
@IsOptional()
@IsDateString()
startDateFrom?: string;
@ApiProperty({
description: 'Filter subscriptions starting until this date (ISO format)',
example: '2024-12-31',
required: false,
})
@IsOptional()
@IsDateString()
startDateTo?: string;
@ApiProperty({
description: 'Filter subscriptions ending from this date (ISO format)',
example: '2024-01-01',
required: false,
})
@IsOptional()
@IsDateString()
endDateFrom?: string;
@ApiProperty({
description: 'Filter subscriptions ending until this date (ISO format)',
example: '2024-12-31',
required: false,
})
@IsOptional()
@IsDateString()
endDateTo?: string;
@ApiProperty({
description: 'Filter subscriptions created from this date (ISO format)',
example: '2024-01-01T00:00:00Z',
required: false,
})
@IsOptional()
@IsDateString()
createdFrom?: string;
@ApiProperty({
description: 'Filter subscriptions created until this date (ISO format)',
example: '2024-12-31T23:59:59Z',
required: false,
})
@IsOptional()
@IsDateString()
createdTo?: string;
@ApiProperty({
description: 'Filter subscriptions with next payment from this date',
required: false,
})
@IsOptional()
@IsDateString()
nextPaymentFrom?: string;
@ApiProperty({
description: 'Filter subscriptions with next payment until this date',
required: false,
})
@IsOptional()
@IsDateString()
nextPaymentTo?: string;
@ApiProperty({
description: 'Filter by customer ID',
required: false,
})
@IsOptional()
@Type(() => Number)
@IsInt()
customerId?: number;
}

View File

@ -19,7 +19,7 @@ export class SubscriptionScheduler {
const subscriptions = await this.prisma.subscription.findMany({
where: {
status: 'ACTIVE',
nextBillingDate: {
nextPaymentDate: {
lte: now,
},
},
@ -43,7 +43,7 @@ export class SubscriptionScheduler {
const expiringTrials = await this.prisma.subscription.findMany({
where: {
status: 'TRIAL',
trialEndsAt: {
nextPaymentDate: {
lte: now,
},
},

View File

@ -1,337 +0,0 @@
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../../shared/services/prisma.service';
import { CreatePlanDto, UpdatePlanDto } from '../dto/plan.dto';
import { Prisma } from 'generated/prisma';
@Injectable()
export class PlanService {
constructor(private readonly prisma: PrismaService) {}
async create(partnerId: string, dto: CreatePlanDto) {
// Vérifier si un plan avec le même code existe déjà
const existingPlan = await this.prisma.plan.findFirst({
where: {
code: dto.code,
partnerId: partnerId,
},
});
if (existingPlan) {
throw new BadRequestException('Plan with this code already exists');
}
const plan = await this.prisma.plan.create({
data: {
partnerId: partnerId,
code: dto.code,
name: dto.name,
description: dto.description,
amount: dto.amount,
currency: dto.currency,
interval: dto.interval,
intervalCount: dto.intervalCount || 1,
trialDays: dto.trialDays || 0,
features: dto.features || [],
limits: dto.limits || {},
metadata: dto.metadata || {},
active: true,
},
});
return plan;
}
async findAll(partnerId: string, filters?: {
active?: boolean;
interval?: string;
page?: number;
limit?: number;
}) {
const where: Prisma.PlanWhereInput = {
partnerId: partnerId,
};
if (filters?.active !== undefined) {
where.active = filters.active;
}
if (filters?.interval) {
where.interval = filters.interval;
}
const page = filters?.page || 1;
const limit = filters?.limit || 20;
const skip = (page - 1) * limit;
const [plans, total] = await Promise.all([
this.prisma.plan.findMany({
where,
skip,
take: limit,
orderBy: { createdAt: 'desc' },
include: {
_count: {
select: {
subscriptions: true,
},
},
},
}),
this.prisma.plan.count({ where }),
]);
return {
data: plans,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}
async findOne(planId: string, partnerId: string) {
const plan = await this.prisma.plan.findFirst({
where: {
id: planId,
partnerId: partnerId,
},
include: {
_count: {
select: {
subscriptions: true,
},
},
},
});
if (!plan) {
throw new NotFoundException('Plan not found');
}
// Calculer les statistiques
const stats = await this.getStatistics(planId);
return {
...plan,
statistics: stats,
};
}
async update(planId: string, partnerId: string, dto: UpdatePlanDto) {
const plan = await this.prisma.plan.findFirst({
where: {
id: planId,
partnerId: partnerId,
},
});
if (!plan) {
throw new NotFoundException('Plan not found');
}
// Vérifier s'il y a des subscriptions actives
const activeSubscriptions = await this.prisma.subscription.count({
where: {
planId: planId,
status: { in: ['ACTIVE', 'TRIAL'] },
},
});
// Empêcher certaines modifications si des subscriptions actives
if (activeSubscriptions > 0) {
if (dto.amount !== undefined && dto.amount !== plan.amount) {
throw new BadRequestException(
'Cannot change amount while there are active subscriptions',
);
}
if (dto.interval !== undefined && dto.interval !== plan.interval) {
throw new BadRequestException(
'Cannot change interval while there are active subscriptions',
);
}
}
const updatedPlan = await this.prisma.plan.update({
where: { id: planId },
data: {
name: dto.name,
description: dto.description,
amount: dto.amount,
currency: dto.currency,
interval: dto.interval,
intervalCount: dto.intervalCount,
trialDays: dto.trialDays,
features: dto.features,
limits: dto.limits,
metadata: dto.metadata,
},
});
return updatedPlan;
}
async toggleStatus(planId: string, partnerId: string) {
const plan = await this.prisma.plan.findFirst({
where: {
id: planId,
partnerId: partnerId,
},
});
if (!plan) {
throw new NotFoundException('Plan not found');
}
const updatedPlan = await this.prisma.plan.update({
where: { id: planId },
data: {
active: !plan.active,
},
});
return updatedPlan;
}
async delete(planId: string, partnerId: string) {
const plan = await this.prisma.plan.findFirst({
where: {
id: planId,
partnerId: partnerId,
},
});
if (!plan) {
throw new NotFoundException('Plan not found');
}
// Vérifier s'il y a des subscriptions
const subscriptionsCount = await this.prisma.subscription.count({
where: { planId: planId },
});
if (subscriptionsCount > 0) {
throw new BadRequestException(
'Cannot delete plan with existing subscriptions',
);
}
await this.prisma.plan.delete({
where: { id: planId },
});
return { message: 'Plan deleted successfully' };
}
async duplicate(planId: string, partnerId: string, newCode: string) {
const plan = await this.prisma.plan.findFirst({
where: {
id: planId,
partnerId: partnerId,
},
});
if (!plan) {
throw new NotFoundException('Plan not found');
}
const duplicatedPlan = await this.prisma.plan.create({
data: {
partnerId: partnerId,
code: newCode,
name: `${plan.name} (Copy)`,
description: plan.description,
amount: plan.amount,
currency: plan.currency,
interval: plan.interval,
intervalCount: plan.intervalCount,
trialDays: plan.trialDays,
//features: plan.features,
//limits: plan.limits,
metadata: {metadata:plan.metadata, duplicatedFrom: plan.id },
active: false, // Désactivé par défaut
},
});
return duplicatedPlan;
}
private async getStatistics(planId: string) {
const [
totalSubscriptions,
activeSubscriptions,
trialSubscriptions,
cancelledSubscriptions,
revenue,
avgLifetime,
] = await Promise.all([
this.prisma.subscription.count({
where: { planId },
}),
this.prisma.subscription.count({
where: { planId, status: 'ACTIVE' },
}),
this.prisma.subscription.count({
where: { planId, status: 'TRIAL' },
}),
this.prisma.subscription.count({
where: { planId, status: 'CANCELLED' },
}),
this.calculateRevenue(planId),
this.calculateAverageLifetime(planId),
]);
return {
totalSubscriptions,
activeSubscriptions,
trialSubscriptions,
cancelledSubscriptions,
revenue,
avgLifetime,
churnRate: totalSubscriptions > 0
? (cancelledSubscriptions / totalSubscriptions) * 100
: 0,
};
}
private async calculateRevenue(planId: string) {
const result = await this.prisma.payment.aggregate({
where: {
subscription: {
planId: planId,
},
status: 'SUCCESS',
},
_sum: {
amount: true,
},
});
return result._sum.amount || 0;
}
private async calculateAverageLifetime(planId: string) {
const subscriptions = await this.prisma.subscription.findMany({
where: {
planId,
status: { in: ['CANCELLED', 'EXPIRED'] },
},
select: {
createdAt: true,
cancelledAt: true,
suspendedAt: true,
},
});
if (subscriptions.length === 0) return 0;
const lifetimes = subscriptions.map(sub => {
const endDate = sub.cancelledAt || sub.suspendedAt || new Date();
return endDate.getTime() - sub.createdAt.getTime();
});
const avgLifetime = lifetimes.reduce((a, b) => a + b, 0) / lifetimes.length;
return Math.floor(avgLifetime / (1000 * 60 * 60 * 24)); // Retourner en jours
}
}

View File

@ -7,58 +7,76 @@ import {
Body,
Param,
Query,
Headers,
UseGuards,
Request,
Logger,
ParseIntPipe,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { ApiTags, ApiOperation, ApiQuery,ApiBearerAuth, ApiResponse } from '@nestjs/swagger';
import { SubscriptionsService } from './subscriptions.service';
import { CreateSubscriptionDto, UpdateSubscriptionDto } from './dto/subscription.dto';
import { CreateSubscriptionDto, SubscriptionQueryDto, UpdateSubscriptionDto } from './dto/subscription.dto';
import { JwtAuthGuard } from 'src/common/guards/jwt-auth.guard';
import { PaginationDto } from 'src/common/dto/pagination.dto';
@ApiTags('subscriptions')
@Controller('subscriptions')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
//@UseGuards(JwtAuthGuard)
//@ApiBearerAuth()
export class SubscriptionsController {
private readonly logger = new Logger(SubscriptionsController.name);
constructor(private readonly subscriptionsService: SubscriptionsService) {}
@Post()
@ApiOperation({ summary: 'Create a new subscription' })
async create(@Request() req, @Body() dto: CreateSubscriptionDto) {
return this.subscriptionsService.create(req.user.partnerId, dto);
async create(
@Headers('X-Merchant-ID') merchantId: string,
@Headers('X-COUNTRY') country: string,
@Headers('X-OPERATOR') operator: string,
@Request() req, @Body() dto: CreateSubscriptionDto) {
this.logger.log('Merchant ID from header:'+ merchantId);
this.logger.debug(
`[request to hub ]: ${JSON.stringify(dto, null, 2)}`,
)
return this.subscriptionsService.create(merchantId, dto,country,operator);
}
@Get('/')
@ApiOperation({ summary: 'Get subscription list with pagination' })
@ApiQuery({ type: PaginationDto })
@ApiResponse({
status: 200,
description: 'Paginated list of subscriptions',
})
async getAll(@Request() req, @Query() paginationDto: SubscriptionQueryDto,) {
return this.subscriptionsService.findAll(paginationDto);
}
@Get('merchant/:merchantId')
@ApiOperation({ summary: 'Get subscription list by merchant' })
async getAllByMErchant(@Request() req, @Param('merchantId', ParseIntPipe,) merchantId: number, paginationDto: Omit<SubscriptionQueryDto, 'merchantPartnerId'> ,) {
const page = {...paginationDto, merchantPartnerId: merchantId};
return this.subscriptionsService.findAll(page);
}
@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);
}
@Patch(':id')
@ApiOperation({ summary: 'Update subscription' })
async update(
@Request() req,
@Param('id') id: string,
@Body() dto: UpdateSubscriptionDto,
) {
return this.subscriptionsService.update(id, req.user.partnerId, dto);
}
@Delete(':id')
@ApiOperation({ summary: 'Cancel subscription' })
async cancel(
@Request() req,
@Param('id') id: string,
@Param('id') id: number,
@Body('reason') reason?: string,
) {
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

@ -4,11 +4,11 @@ import { SubscriptionsController } from './subscriptions.controller';
import { SubscriptionsService } from './subscriptions.service';
import { SubscriptionScheduler } from './schedulers/subscription.scheduler';
import { SubscriptionProcessor } from './processors/subscription.processor';
import { PlanService } from './services/plan.service';
import { BillingService } from './services/billing.service';
//import { BillingService } from './services/billing.service';
import { PrismaService } from '../../shared/services/prisma.service';
import { PaymentsModule } from '../payments/payments.module';
import { HttpModule } from '@nestjs/axios';
import { OperatorsModule } from '../operators/operators.module';
@Module({
imports: [
@ -19,17 +19,17 @@ import { PaymentsModule } from '../payments/payments.module';
BullModule.registerQueue({
name: 'billing',
}),
PaymentsModule,
OperatorsModule
// PaymentsModule,
],
controllers: [SubscriptionsController],
providers: [
SubscriptionsService,
SubscriptionScheduler,
SubscriptionProcessor,
PlanService,
BillingService,
//BillingService,
PrismaService,
],
exports: [SubscriptionsService, PlanService],
exports: [SubscriptionsService],
})
export class SubscriptionsModule {}

View File

@ -1,32 +1,178 @@
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
import { Injectable, BadRequestException, NotFoundException, Logger } from '@nestjs/common';
import { InjectQueue } from '@nestjs/bull';
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 { CreateSubscriptionDto, SubscriptionQueryDto, UpdateSubscriptionDto } from './dto/subscription.dto';
import { Prisma, Subscription } from 'generated/prisma';
import { OperatorsService } from '../operators/operators.service';
import { PaginationDto } from 'src/common/dto/pagination.dto';
import { PaginatedResponse } from 'src/common/interfaces/paginated-response.interface';
//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.');
}
list(arg0: { partnerId: any; status: string | undefined; userId: string | undefined; page: number; limit: number; }) {
throw new Error('Method not implemented.');
}
getInvoices(id: string, partnerId: any) {
throw new Error('Method not implemented.');
}
private readonly logger = new Logger(SubscriptionsService.name);
constructor(
private readonly prisma: PrismaService,
private readonly paymentsService: PaymentsService,
private readonly operatorsService: OperatorsService,
@InjectQueue('subscriptions') private subscriptionQueue: bull.Queue,
@InjectQueue('billing') private billingQueue: bull.Queue,
) {}
async create(partnerId: string, dto: CreateSubscriptionDto) {
// Vérifier l'utilisateur
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;
}
async findAllByMerchant(merchantId: number): Promise<Subscription[]> {
// Check if merchant exists
return this.prisma.subscription.findMany({
where: { merchantPartnerId: merchantId },
orderBy: {
createdAt: 'desc',
},
});
}
async findAll( paginationDto: SubscriptionQueryDto,): Promise<PaginatedResponse<Subscription>> {
const { page = 1, limit = 10, status,
periodicity,
merchantPartnerId,
customerId,
serviceId,
startDateFrom,
startDateTo,
endDateFrom,
endDateTo,
createdFrom,
createdTo,
nextPaymentFrom,
nextPaymentTo, } = paginationDto;
const skip = (page - 1) * limit;
// Construction du where clause dynamique
const where: Prisma.SubscriptionWhereInput = {};
// Filtre par status
if (status) {
where.status = status;
}
// Filtre par periodicity
if (periodicity) {
where.periodicity = periodicity;
}
// Filtre par IDs
if (merchantPartnerId) {
where.merchantPartnerId = merchantPartnerId;
}
if (customerId) {
where.customerId = customerId;
}
if (serviceId) {
where.serviceId = serviceId;
}
// Filtres sur startDate
if (startDateFrom || startDateTo) {
where.startDate = {};
if (startDateFrom) {
where.startDate.gte = new Date(startDateFrom);
}
if (startDateTo) {
where.startDate.lte = new Date(startDateTo);
}
}
// Filtres sur endDate
if (endDateFrom || endDateTo) {
where.endDate = {};
if (endDateFrom) {
where.endDate.gte = new Date(endDateFrom);
}
if (endDateTo) {
where.endDate.lte = new Date(endDateTo);
}
}
// Filtres sur createdAt
if (createdFrom || createdTo) {
where.createdAt = {};
if (createdFrom) {
where.createdAt.gte = new Date(createdFrom);
}
if (createdTo) {
where.createdAt.lte = new Date(createdTo);
}
}
// Filtres sur nextPaymentDate
if (nextPaymentFrom || nextPaymentTo) {
where.nextPaymentDate = {};
if (nextPaymentFrom) {
where.nextPaymentDate.gte = new Date(nextPaymentFrom);
}
if (nextPaymentTo) {
where.nextPaymentDate.lte = new Date(nextPaymentTo);
}
}
// Check if merchant exists
const [subscriptions, total] = await Promise.all([
this.prisma.subscription.findMany({
where,
skip,
take: limit,
orderBy: {
createdAt: 'desc',
},
// Vous pouvez inclure des relations si nécessaire
// include: {
// merchantPartner: true,
// service: true,
// },
}),
this.prisma.subscription.count(),
]);
const totalPages = Math.ceil(total / limit);
return {
data: subscriptions,
meta: {
total,
page,
limit,
totalPages,
hasNextPage: page < totalPages,
hasPreviousPage: page > 1,
},
};
}
getInvoices(id: string, partnerId: any) {
throw new Error('Method not implemented.');
}
async create(partnerId: string, dto: CreateSubscriptionDto, country:string,operator:string) {
/* todo Vérifier l'utilisateur
const user = await this.prisma.user.findFirst({
where: {
@ -39,8 +185,9 @@ export class SubscriptionsService {
if (!user) {
throw new BadRequestException('Invalid user token for this partner');
}
*/
// Vérifier le plan
/* Vérifier le plan
const plan = await this.prisma.plan.findUnique({
where: { id: dto.planId },
});
@ -48,12 +195,16 @@ export class SubscriptionsService {
if (!plan || !plan.active) {
throw new BadRequestException('Invalid or inactive plan');
}
*/
// Vérifier s'il n'y a pas déjà une subscription active
const existingSubscription = await this.prisma.subscription.findFirst({
where: {
userId: user.id,
planId: plan.id,
token: dto.userToken,
planId: dto.planId ,
status: { in: ['ACTIVE', 'TRIAL'] },
},
});
@ -62,60 +213,58 @@ export class SubscriptionsService {
throw new BadRequestException('User already has an active subscription for this plan');
}
// Calculer les dates
const now = new Date();
const trialDays = dto.trialDays || plan.trialDays || 0;
const hasTrialPeriod = trialDays > 0;
const adapter = this.operatorsService.getAdapter(
operator,
country,
);
const subscriptionParams = {
userToken: dto.userToken,
userAlias: dto.userToken, //todo make alias in contrat
amount: 200,//plan.amount,todo
currency: 'XOF',//plan.currency,todo
description: 'dto.description',//plan.description,todo
productId: dto.planId +'',
merchantId: partnerId,
periodicity: '86400', // todo 86400 (daily), 604800 (weekly), 0 (monthly) only those values will be accepted
};
const result = await adapter.createSubscription(subscriptionParams);
this.logger.debug(
`result from adapter ${JSON.stringify(result, null, 2)} for subscription creation`,
);
const trialEndsAt = hasTrialPeriod
? new Date(now.getTime() + trialDays * 24 * 60 * 60 * 1000)
: null;
const currentPeriodStart = now;
const currentPeriodEnd = this.calculatePeriodEnd(plan, currentPeriodStart);
const nextBillingDate = hasTrialPeriod ? trialEndsAt : currentPeriodEnd;
// Créer la subscription
const subscription = await this.prisma.subscription.create({
data: {
userId: user.id,
planId: plan.id,
partnerId: partnerId,
status: hasTrialPeriod ? 'TRIAL' : 'PENDING',
currentPeriodStart,
currentPeriodEnd,
nextBillingDate,
trialEndsAt,
metadata: {
...dto.metadata,
userAlias: user.userAlias,
operator: user.operator.code,
country: user.country,
},
},
include: {
plan: true,
user: true,
},
customerId: 1, //user.id, todo
externalReference: result.subscriptionId,
merchantPartnerId: 4,// todo , parseInt(partnerId),
token: dto.userToken,
planId: dto.planId,
serviceId: 1, //plan.serviceId, todo
periodicity: "Daily",
amount: 20,
currency: "XOF",
status: 'ACTIVE',//todo mapping result.status 'SUCCESS' ? 'ACTIVE' : 'PENDING',
//currentPeriodStart: new Date(),
//currentPeriodEnd: new Date(), // todo À ajuster selon la périodicité
// nextBillingDate: new Date(), // todo À ajuster selon la périodicité
//renewalCount: 0,
startDate: new Date(),
failureCount: 0,
nextPaymentDate: new Date(), // todo À ajuster selon la périodicité
metadata: dto.metadata,
}
});
// Si pas de période d'essai, traiter le premier paiement
if (!hasTrialPeriod) {
await this.processInitialPayment(subscription, dto.callbackUrl);
} else {
// Activer directement en période d'essai
await this.prisma.subscription.update({
where: { id: subscription.id },
data: { status: 'TRIAL' },
});
// Programmer la fin de la période d'essai
await this.billingQueue.add(
'trial-end',
{ subscriptionId: subscription.id },
{ delay: trialDays * 24 * 60 * 60 * 1000 },
);
}
// Notifier le partenaire via webhook
if (dto.callbackUrl) {
@ -129,11 +278,14 @@ export class SubscriptionsService {
return subscription;
}
async update(subscriptionId: string, partnerId: string, dto: UpdateSubscriptionDto) {
async cancel(subscriptionId: number, partnerId: number, reason?: string) {
const subscription = await this.prisma.subscription.findFirst({
where: {
id: subscriptionId,
partnerId: partnerId,
merchantPartnerId: partnerId,
},
});
@ -141,74 +293,15 @@ export class SubscriptionsService {
throw new NotFoundException('Subscription not found');
}
const updateData: any = {};
// Gérer le changement de statut
if (dto.status) {
if (dto.status === 'PAUSED' && subscription.status === 'ACTIVE') {
updateData.status = 'PAUSED';
updateData.pausedAt = new Date();
} else if (dto.status === 'ACTIVE' && subscription.status === 'SUSPENDED') {
updateData.status = 'ACTIVE';
updateData.pausedAt = null;
// Recalculer la prochaine date de facturation
updateData.nextBillingDate = this.calculateNextBillingDate(subscription);
}
}
// Gérer le changement de plan
if (dto.planId && dto.planId !== subscription.planId) {
const newPlan = await this.prisma.plan.findUnique({
where: { id: dto.planId },
});
if (!newPlan || !newPlan.active) {
throw new BadRequestException('Invalid plan');
}
updateData.planId = newPlan.id;
updateData.amount = newPlan.amount;
updateData.currency = newPlan.currency;
updateData.planChangeScheduledFor = dto.immediate ? new Date() : subscription.currentPeriodEnd;
}
if (dto.metadata) {
updateData.metadata = { metadata:subscription.metadata, ...dto.metadata };
}
const updatedSubscription = await this.prisma.subscription.update({
where: { id: subscriptionId },
data: updateData,
include: {
plan: true,
user: true,
},
});
return updatedSubscription;
}
async cancel(subscriptionId: string, partnerId: string, reason?: string) {
const subscription = await this.prisma.subscription.findFirst({
where: {
id: subscriptionId,
partnerId: partnerId,
},
});
if (!subscription) {
throw new NotFoundException('Subscription not found');
}
if (subscription.status === 'CANCELLED') {
if (subscription.status === 'SUSPENDED' ) {
throw new BadRequestException('Subscription already cancelled');
}
const updatedSubscription = await this.prisma.subscription.update({
where: { id: subscriptionId },
data: {
status: 'CANCELLED',
cancelledAt: new Date(),
status: 'SUSPENDED',
//cancelledAt: new Date(),
metadata: {
cancellationDetails: {
reason,
@ -226,7 +319,7 @@ export class SubscriptionsService {
await job.remove();
}
}
/*TODO
// Notifier via webhook
const partner = await this.prisma.partner.findUnique({
where: { id: partnerId },
@ -243,7 +336,7 @@ export class SubscriptionsService {
onFailure?: string;
};
}
/*
if (partner?.callbacks?subscription?.onCancel) {
await this.subscriptionQueue.add('webhook-notification', {
url: partner.callbacks.subscription.onCancel,
@ -256,14 +349,11 @@ export class SubscriptionsService {
return updatedSubscription;
}
async processRenewal(subscriptionId: string) {
async processRenewal(subscriptionId: number) {
/*todo
const subscription = await this.prisma.subscription.findUnique({
where: { id: subscriptionId },
include: {
user: true,
plan: true,
partner: true,
},
});
if (!subscription) {
@ -311,25 +401,25 @@ export class SubscriptionsService {
});
// Programmer le prochain renouvellement
/* todo
const delay = subscription.nextBillingDate.getTime() - Date.now();
await this.billingQueue.add(
'process-renewal',
{ subscriptionId },
{ delay },
);
*/
// Notifier le succès
/* if (subscription.partner?.callbacks?.subscription?.onRenew) {
if (subscription.partner?.callbacks?.subscription?.onRenew) {
await this.subscriptionQueue.add('webhook-notification', {
url: subscription.partner.callbacks.subscription.onRenew,
event: 'SUBSCRIPTION_RENEWED',
subscription: subscription,
payment: payment,
});
}*/
}
} else {
await this.handleRenewalFailure(subscription);
}
@ -337,63 +427,11 @@ export class SubscriptionsService {
console.error(`Renewal failed for subscription ${subscriptionId}:`, error);
await this.handleRenewalFailure(subscription);
}
*/
}
//todo
private async processInitialPayment(subscription: any, callbackUrl?: string) {
try {
const payment = await this.paymentsService.createCharge({
userToken: subscription.user.userToken,
amount: subscription.amount,
currency: subscription.currency,
description: `Subscription: ${subscription.plan.name}`,
reference: `SUB-INIT-${subscription.id}-${Date.now()}`,
callbackUrl: callbackUrl,
metadata: {
subscriptionId: subscription.id,
type: 'initial',
},
partnerId:""
});
if (payment.status === 'SUCCESS') {
//todo
await this.prisma.subscription.update({
where: { id: subscription.id },
data: {
status: 'ACTIVE',
createdAt: new Date(),
lastPaymentId: payment.id,
//lastPaymentDate: new Date(),
},
});
// Programmer le premier renouvellement
const delay = subscription.nextBillingDate.getTime() - Date.now();
await this.billingQueue.add(
'process-renewal',
{ subscriptionId: subscription.id },
{ delay },
);
} else {
await this.prisma.subscription.update({
where: { id: subscription.id },
data: {
status: 'FAILED',
//todo failureReason: payment.failureReason,
},
});
}
} catch (error) {
await this.prisma.subscription.update({
where: { id: subscription.id },
data: {
status: 'FAILED',
//failureReason: error.message,
},
});
throw error;
}
}
private async handleRenewalFailure(subscription: any) {
const failureCount = (subscription.failureCount || 0) + 1;
@ -405,8 +443,7 @@ export class SubscriptionsService {
where: { id: subscription.id },
data: {
status: 'SUSPENDED',
failureCount,
suspendedAt: new Date(),
//suspensionReason: `Payment failed ${maxRetries} times`,
},
});