Compare commits
10 Commits
1ddc8e9ee4
...
e4c4383ceb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4c4383ceb | ||
|
|
0af15e26fc | ||
|
|
6ea3ece796 | ||
|
|
d8ad43a56a | ||
|
|
767201ec06 | ||
|
|
f7820ddccf | ||
|
|
8406d79800 | ||
|
|
039e9f067d | ||
|
|
08127aa36e | ||
|
|
fdfdd1a83e |
76
Dockerfile
Normal file
76
Dockerfile
Normal 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"]
|
||||
@ -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
|
||||
|
||||
|
||||
182
prisma/migrations/20251114000724_init/migration.sql
Normal file
182
prisma/migrations/20251114000724_init/migration.sql
Normal 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;
|
||||
8
prisma/migrations/20251114000847_init/migration.sql
Normal file
8
prisma/migrations/20251114000847_init/migration.sql
Normal 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;
|
||||
11
prisma/migrations/20251114001048_init/migration.sql
Normal file
11
prisma/migrations/20251114001048_init/migration.sql
Normal 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;
|
||||
2
prisma/migrations/20251114001441_init/migration.sql
Normal file
2
prisma/migrations/20251114001441_init/migration.sql
Normal file
@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "SubscriptionStatus" ADD VALUE 'PENDING';
|
||||
2
prisma/migrations/20251114001808_init/migration.sql
Normal file
2
prisma/migrations/20251114001808_init/migration.sql
Normal file
@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "subscriptions" ADD COLUMN "metadata" JSONB;
|
||||
8
prisma/migrations/20251114004316_init/migration.sql
Normal file
8
prisma/migrations/20251114004316_init/migration.sql
Normal 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;
|
||||
2
prisma/migrations/20251114004508_init/migration.sql
Normal file
2
prisma/migrations/20251114004508_init/migration.sql
Normal file
@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "subscriptions" ALTER COLUMN "failureCount" DROP NOT NULL;
|
||||
10
prisma/migrations/20251114004913_init/migration.sql
Normal file
10
prisma/migrations/20251114004913_init/migration.sql
Normal 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';
|
||||
2
prisma/migrations/20251114005111_init/migration.sql
Normal file
2
prisma/migrations/20251114005111_init/migration.sql
Normal file
@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "subscriptions" ADD COLUMN "suspendedAt" TIMESTAMP(3);
|
||||
16
prisma/migrations/20251114005218_init/migration.sql
Normal file
16
prisma/migrations/20251114005218_init/migration.sql
Normal 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;
|
||||
8
prisma/migrations/20251114005353_init/migration.sql
Normal file
8
prisma/migrations/20251114005353_init/migration.sql
Normal 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;
|
||||
2
prisma/migrations/20251114005553_init/migration.sql
Normal file
2
prisma/migrations/20251114005553_init/migration.sql
Normal file
@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "payments" ADD COLUMN "failureReason" TEXT;
|
||||
10
prisma/migrations/20251114011315_init/migration.sql
Normal file
10
prisma/migrations/20251114011315_init/migration.sql
Normal 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;
|
||||
13
prisma/migrations/20251114011352_init/migration.sql
Normal file
13
prisma/migrations/20251114011352_init/migration.sql
Normal 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;
|
||||
3
prisma/migrations/20251114011531_init/migration.sql
Normal file
3
prisma/migrations/20251114011531_init/migration.sql
Normal file
@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "payments" ADD COLUMN "amount" DOUBLE PRECISION,
|
||||
ADD COLUMN "completedAt" TIMESTAMP(3);
|
||||
8
prisma/migrations/20251114011742_init/migration.sql
Normal file
8
prisma/migrations/20251114011742_init/migration.sql
Normal 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;
|
||||
5
prisma/migrations/20251114013019_init/migration.sql
Normal file
5
prisma/migrations/20251114013019_init/migration.sql
Normal file
@ -0,0 +1,5 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "payments" ADD COLUMN "metadata" JSONB;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "reversement_requests" ADD COLUMN "metadata" JSONB;
|
||||
8
prisma/migrations/20251114014245_init/migration.sql
Normal file
8
prisma/migrations/20251114014245_init/migration.sql
Normal 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;
|
||||
2
prisma/migrations/20251114124248_init/migration.sql
Normal file
2
prisma/migrations/20251114124248_init/migration.sql
Normal file
@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "payments" ADD COLUMN "link" TEXT;
|
||||
2
prisma/migrations/20251114125457_init/migration.sql
Normal file
2
prisma/migrations/20251114125457_init/migration.sql
Normal file
@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "payments" ADD COLUMN "subscriptionId" INTEGER;
|
||||
2
prisma/migrations/20251114130651_init/migration.sql
Normal file
2
prisma/migrations/20251114130651_init/migration.sql
Normal file
@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "payments" ADD COLUMN "reference" TEXT;
|
||||
272
prisma/schema copy.prisma
Normal file
272
prisma/schema copy.prisma
Normal 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])
|
||||
}
|
||||
@ -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)
|
||||
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])
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
59
src/common/commonde.module.ts
Normal file
59
src/common/commonde.module.ts
Normal 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 {}
|
||||
31
src/common/dto/pagination.dto.ts
Normal file
31
src/common/dto/pagination.dto.ts
Normal 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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
11
src/common/interfaces/paginated-response.interface.ts
Normal file
11
src/common/interfaces/paginated-response.interface.ts
Normal file
@ -0,0 +1,11 @@
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
meta: {
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
hasNextPage: boolean;
|
||||
hasPreviousPage: boolean;
|
||||
};
|
||||
}
|
||||
253
src/common/services/cache.redis.ts
Normal file
253
src/common/services/cache.redis.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
},
|
||||
}));
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -1,29 +1,54 @@
|
||||
/**
|
||||
* Structure de la requête pour l'API Orange DCB Challenge v2
|
||||
*/
|
||||
|
||||
|
||||
export interface OrangeChallengeRequest {
|
||||
challenge:{
|
||||
country: string;
|
||||
method: string;
|
||||
service: string;
|
||||
partnerId: string;
|
||||
identifier: {
|
||||
type: string;
|
||||
value: string;
|
||||
};
|
||||
confirmationCode: string;
|
||||
message: string;
|
||||
otpLength: number;
|
||||
senderName: 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;
|
||||
description?: string;
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
@ -35,13 +60,20 @@ export interface OrangeChallengeResponse {
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
@ -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: {
|
||||
|
||||
@ -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>;
|
||||
|
||||
|
||||
@ -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}`);
|
||||
|
||||
@ -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'),
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
46
src/modules/operators/adapters/orange.config.ts
Normal file
46
src/modules/operators/adapters/orange.config.ts
Normal file
@ -0,0 +1,46 @@
|
||||
export interface OrangeConfig {
|
||||
baseUrl: string;
|
||||
partnerId: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
defaultService: string;
|
||||
defaultOtpLength: number;
|
||||
defaultSenderName: string;
|
||||
defaultOtpMessage: string;
|
||||
tokenEndpoint: string;
|
||||
challengeEndpoint: string;
|
||||
timeout: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_ORANGE_CONFIG: Partial<OrangeConfig> = {
|
||||
defaultOtpLength: 4,
|
||||
defaultOtpMessage: 'To confirm your purchase please enter the code %OTP%',
|
||||
tokenEndpoint: '/oauth/v3/token',
|
||||
challengeEndpoint: '/challenge/v1/challenges',
|
||||
timeout: 30000, // 30 secondes
|
||||
};
|
||||
|
||||
/**
|
||||
* Mapping des codes pays ISO vers les codes Orange
|
||||
*/
|
||||
export const COUNTRY_CODE_MAPPING: Record<string, string> = {
|
||||
'SN': 'SEN', // Sénégal
|
||||
'CI': 'CIV', // Côte d'Ivoire
|
||||
'CM': 'CMR', // Cameroun
|
||||
'CD': 'COD', // RD Congo
|
||||
'BF': 'BFA', // Burkina Faso
|
||||
'TN': 'TUN', // Tunisie
|
||||
'ML': 'MLI', // Mali
|
||||
'GN': 'GIN', // Guinée
|
||||
'NE': 'NER', // Niger
|
||||
'MG': 'MDG', // Madagascar
|
||||
};
|
||||
|
||||
/**
|
||||
* Mapping des méthodes OTP génériques vers Orange
|
||||
*/
|
||||
export const OTP_METHOD_MAPPING: Record<string, string> = {
|
||||
'SMS': 'OTP-SMS-AUTH',
|
||||
'USSD': 'OTP-USSD-AUTH',
|
||||
'IVR': 'OTP-IVR-AUTH',
|
||||
};
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@ -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';
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -1,22 +1,213 @@
|
||||
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; }) {
|
||||
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.');
|
||||
}
|
||||
getPaymentByReference(reference: string, partnerId: any) {
|
||||
throw new Error('Method not implemented.');
|
||||
async getPaymentByReference(reference: string) {
|
||||
const plan = await this.prisma.payment.findFirst({
|
||||
where: { reference: reference },
|
||||
});
|
||||
return plan
|
||||
}
|
||||
getPayment(paymentId: string, partnerId: any) {
|
||||
throw new Error('Method not implemented.');
|
||||
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.');
|
||||
}
|
||||
@ -26,55 +217,65 @@ export class PaymentsService {
|
||||
processPayment(paymentId: any): any {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
constructor(
|
||||
private readonly operatorsService: OperatorsService,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly eventEmitter: EventEmitter2,
|
||||
) {}
|
||||
|
||||
|
||||
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,
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
@ -163,20 +369,6 @@ async listPayments(filters: any) {
|
||||
skip,
|
||||
take: limit,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
msisdn: true,
|
||||
},
|
||||
},
|
||||
subscription: {
|
||||
select: {
|
||||
id: true,
|
||||
planId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
this.prisma.payment.count({ where }),
|
||||
]);
|
||||
@ -228,9 +420,8 @@ async getStatistics(params: {
|
||||
}),
|
||||
]);
|
||||
|
||||
const successRate = totalPayments > 0
|
||||
? (successfulPayments / totalPayments) * 100
|
||||
: 0;
|
||||
const successRate =
|
||||
totalPayments > 0 ? (successfulPayments / totalPayments) * 100 : 0;
|
||||
|
||||
return {
|
||||
totalPayments,
|
||||
@ -261,7 +452,7 @@ async validatePayment(params: any) {
|
||||
// Vérifier les limites
|
||||
const todayPayments = await this.prisma.payment.count({
|
||||
where: {
|
||||
userId: user.id,
|
||||
customerId: 1, // todo À remplacer par user.id
|
||||
status: 'SUCCESS',
|
||||
createdAt: {
|
||||
gte: new Date(new Date().setHours(0, 0, 0, 0)),
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@ -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 {}
|
||||
|
||||
@ -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`,
|
||||
},
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user