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
|
## License
|
||||||
|
|
||||||
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/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")
|
url = env("DATABASE_URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
enum OperatorCode {
|
model Transaction {
|
||||||
ORANGE
|
id Int @id @default(autoincrement())
|
||||||
MTN
|
date DateTime @default(now())
|
||||||
AIRTEL
|
amount Float
|
||||||
VODACOM
|
tax Float
|
||||||
MOOV
|
status TransactionStatus
|
||||||
|
merchantPartnerId Int
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
reversementRequests ReversementRequest[]
|
||||||
|
|
||||||
|
@@map("transactions")
|
||||||
}
|
}
|
||||||
|
|
||||||
enum PaymentStatus {
|
enum TransactionStatus {
|
||||||
PENDING
|
|
||||||
SUCCESS
|
SUCCESS
|
||||||
FAILED
|
FAILED
|
||||||
REFUNDED
|
PENDING
|
||||||
}
|
}
|
||||||
|
|
||||||
enum SubscriptionStatus {
|
enum SubscriptionStatus {
|
||||||
PENDING
|
|
||||||
TRIAL
|
|
||||||
ACTIVE
|
ACTIVE
|
||||||
|
TRIAL
|
||||||
|
PENDING
|
||||||
SUSPENDED
|
SUSPENDED
|
||||||
CANCELLED
|
|
||||||
EXPIRED
|
EXPIRED
|
||||||
FAILED
|
CANCELLED
|
||||||
|
}
|
||||||
|
enum Periodicity {
|
||||||
|
Daily
|
||||||
|
Weekly
|
||||||
|
Monthly
|
||||||
|
OneTime
|
||||||
}
|
}
|
||||||
|
|
||||||
model Operator {
|
model Subscription {
|
||||||
id String @id @default(cuid())
|
id Int @id @default(autoincrement())
|
||||||
code OperatorCode
|
externalReference String?
|
||||||
name String
|
periodicity Periodicity
|
||||||
country String
|
startDate DateTime
|
||||||
config Json
|
endDate DateTime?
|
||||||
active Boolean @default(true)
|
amount Float
|
||||||
createdAt DateTime @default(now())
|
currency String
|
||||||
updatedAt DateTime @updatedAt
|
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 {
|
model User {
|
||||||
@ -55,218 +125,10 @@ model User {
|
|||||||
userToken String @unique
|
userToken String @unique
|
||||||
userAlias String
|
userAlias String
|
||||||
operatorId String
|
operatorId String
|
||||||
partnerId String
|
merchantPartnerId Int
|
||||||
country String
|
country String
|
||||||
metadata Json?
|
metadata Json?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
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: {
|
redis: {
|
||||||
host: configService.get('app.redis.host'),
|
host: configService.get('app.redis.host'),
|
||||||
port: configService.get('app.redis.port'),
|
port: configService.get('app.redis.port'),
|
||||||
|
password: configService.get('app.redis.password'),
|
||||||
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
inject: [ConfigService],
|
inject: [ConfigService],
|
||||||
@ -41,7 +43,9 @@ import { SubscriptionsModule } from './modules/subscriptions/subscriptions.modul
|
|||||||
store: redisStore,
|
store: redisStore,
|
||||||
host: configService.get('app.redis.host'),
|
host: configService.get('app.redis.host'),
|
||||||
port: configService.get('app.redis.port'),
|
port: configService.get('app.redis.port'),
|
||||||
|
password: configService.get('app.redis.password'),
|
||||||
ttl: 600, // 10 minutes default
|
ttl: 600, // 10 minutes default
|
||||||
|
|
||||||
}),
|
}),
|
||||||
inject: [ConfigService],
|
inject: [ConfigService],
|
||||||
isGlobal: true,
|
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');
|
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;
|
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: {
|
redis: {
|
||||||
host: process.env.REDIS_HOST || 'localhost',
|
host: process.env.REDIS_HOST || 'localhost',
|
||||||
port: parseInt(process.env.REDIS_PORT?? "6379", 10) || 6379,
|
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', () => ({
|
export default registerAs('operators', () => ({
|
||||||
ORANGE_CIV: {
|
ORANGE_CIV: {
|
||||||
name: 'Orange Côte d Ivoire',
|
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',
|
authType: 'OTP',
|
||||||
endpoints: {
|
endpoints: {
|
||||||
auth: {
|
auth: {
|
||||||
@ -20,7 +20,7 @@ export default registerAs('operators', () => ({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
headers: {
|
headers: {
|
||||||
'X-OAPI-Application-Id': 'BIZAO',
|
'X-OAPI-Application-Id': 'DCB-HUB',
|
||||||
'X-Orange-MCO': 'OCI',
|
'X-Orange-MCO': 'OCI',
|
||||||
},
|
},
|
||||||
transformers: {
|
transformers: {
|
||||||
@ -30,7 +30,7 @@ export default registerAs('operators', () => ({
|
|||||||
},
|
},
|
||||||
ORANGE_SEN: {
|
ORANGE_SEN: {
|
||||||
name: 'Orange Sénégal',
|
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',
|
authType: 'OTP',
|
||||||
endpoints: {
|
endpoints: {
|
||||||
auth: {
|
auth: {
|
||||||
@ -47,7 +47,7 @@ export default registerAs('operators', () => ({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
headers: {
|
headers: {
|
||||||
'X-OAPI-Application-Id': 'BIZAO',
|
'X-OAPI-Application-Id': 'DCB-HUB',
|
||||||
'X-Orange-MCO': 'OSN',
|
'X-Orange-MCO': 'OSN',
|
||||||
},
|
},
|
||||||
transformers: {
|
transformers: {
|
||||||
|
|||||||
@ -1,29 +1,32 @@
|
|||||||
/**
|
/**
|
||||||
* Structure de la requête pour l'API Orange DCB Challenge v2
|
* Structure de la requête pour l'API Orange DCB Challenge v2
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
||||||
export interface OrangeChallengeRequest {
|
export interface OrangeChallengeRequest {
|
||||||
country: string;
|
challenge:{
|
||||||
method: string;
|
country: string;
|
||||||
service: string;
|
method: string;
|
||||||
partnerId: string;
|
service: string;
|
||||||
identifier: {
|
partnerId: string;
|
||||||
type: string;
|
inputs: any[];
|
||||||
value: string;
|
}
|
||||||
};
|
|
||||||
confirmationCode: string;
|
|
||||||
message: string;
|
|
||||||
otpLength: number;
|
|
||||||
senderName: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Structure de la réponse de l'API Orange DCB Challenge
|
* Structure de la réponse de l'API Orange DCB Challenge
|
||||||
*/
|
*/
|
||||||
export interface OrangeChallengeResponse {
|
export interface OrangeChallengeResponse {
|
||||||
challengeId?: string;
|
challenge: {
|
||||||
message?: string;
|
method: string,
|
||||||
expiresIn?: number;
|
result: any[],
|
||||||
sessionId?: string;
|
country: string,
|
||||||
|
service: string,
|
||||||
|
partnerId:string,
|
||||||
|
inputs: [ ]
|
||||||
|
}
|
||||||
|
location:string; // "/challenge/v1/challenges/c87d3360-c7bc-488f-86aa-02a537eaf1cc"
|
||||||
error?: {
|
error?: {
|
||||||
code: number | string;
|
code: number | string;
|
||||||
message: string;
|
message: string;
|
||||||
@ -31,17 +34,46 @@ export interface OrangeChallengeResponse {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OrangeVerifyResponse {
|
||||||
|
challenge: {
|
||||||
|
method: string,
|
||||||
|
country: string,
|
||||||
|
service: string,
|
||||||
|
partnerId:string,
|
||||||
|
inputs: [ ]
|
||||||
|
result:any[ ],/*[
|
||||||
|
{
|
||||||
|
type: 'ise2',
|
||||||
|
value: 'PDKSUB-200-KzIyMTc3MTcxNzE3MS1TRU4tMTc2MTc4MzI2NjAy'
|
||||||
|
}
|
||||||
|
]*/
|
||||||
|
}
|
||||||
|
|
||||||
|
error?: {
|
||||||
|
code: number | string;
|
||||||
|
message: string;
|
||||||
|
description?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builder pour construire des requêtes Orange Challenge
|
* Builder pour construire des requêtes Orange Challenge
|
||||||
*/
|
*/
|
||||||
export class OrangeChallengeRequestBuilder {
|
export class OrangeChallengeRequestBuilder {
|
||||||
private request: Partial<OrangeChallengeRequest> = {};
|
private request: OrangeChallengeRequest = {
|
||||||
|
challenge:{
|
||||||
|
country:'',
|
||||||
|
method: '',
|
||||||
|
service: '',
|
||||||
|
partnerId: '',
|
||||||
|
inputs:[]}
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Définir le pays
|
* Définir le pays
|
||||||
*/
|
*/
|
||||||
withCountry(country: string): this {
|
withCountry(country: string): this {
|
||||||
this.request.country = country;
|
this.request.challenge.country = country;
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -49,7 +81,7 @@ export class OrangeChallengeRequestBuilder {
|
|||||||
* Définir la méthode d'authentification
|
* Définir la méthode d'authentification
|
||||||
*/
|
*/
|
||||||
withMethod(method: string): this {
|
withMethod(method: string): this {
|
||||||
this.request.method = method;
|
this.request.challenge.method = method;
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -57,7 +89,7 @@ export class OrangeChallengeRequestBuilder {
|
|||||||
* Définir le service
|
* Définir le service
|
||||||
*/
|
*/
|
||||||
withService(service: string): this {
|
withService(service: string): this {
|
||||||
this.request.service = service;
|
this.request.challenge.service = service;
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -65,7 +97,7 @@ export class OrangeChallengeRequestBuilder {
|
|||||||
* Définir l'ID du partenaire
|
* Définir l'ID du partenaire
|
||||||
*/
|
*/
|
||||||
withPartnerId(partnerId: string): this {
|
withPartnerId(partnerId: string): this {
|
||||||
this.request.partnerId = partnerId;
|
this.request.challenge.partnerId = partnerId;
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -73,10 +105,12 @@ export class OrangeChallengeRequestBuilder {
|
|||||||
* Définir l'identifiant (numéro de téléphone, etc.)
|
* Définir l'identifiant (numéro de téléphone, etc.)
|
||||||
*/
|
*/
|
||||||
withIdentifier(type: string, value: string): this {
|
withIdentifier(type: string, value: string): this {
|
||||||
this.request.identifier = {
|
this.request.challenge.inputs?.push({
|
||||||
type,
|
"type": type,//, or “ISE2”
|
||||||
value
|
"value": value// or “PDKSUB-XXXXXX”
|
||||||
};
|
},
|
||||||
|
)
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -84,23 +118,41 @@ export class OrangeChallengeRequestBuilder {
|
|||||||
* Définir le code de confirmation (OTP)
|
* Définir le code de confirmation (OTP)
|
||||||
*/
|
*/
|
||||||
withConfirmationCode(code: string): this {
|
withConfirmationCode(code: string): this {
|
||||||
this.request.confirmationCode = code;
|
this.request.challenge.inputs?.push(
|
||||||
|
{
|
||||||
|
"type": "confirmationCode",
|
||||||
|
"value": code
|
||||||
|
},
|
||||||
|
);
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Définir le message OTP
|
* Définir le message OTP
|
||||||
*/
|
*/
|
||||||
|
//todo voir value par defaut
|
||||||
withMessage(message: string): this {
|
withMessage(message: string): this {
|
||||||
this.request.message = message;
|
this.request.challenge.inputs?.push(
|
||||||
|
{
|
||||||
|
"type": "message",
|
||||||
|
"value": message
|
||||||
|
},
|
||||||
|
)
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Définir la longueur de l'OTP
|
* Définir la longueur de l'OTP
|
||||||
*/
|
*/
|
||||||
|
//todo mettre la valeur par defaut
|
||||||
withOtpLength(length: number): this {
|
withOtpLength(length: number): this {
|
||||||
this.request.otpLength = length;
|
this.request.challenge.inputs?.push(
|
||||||
|
{
|
||||||
|
"type": "otpLength",
|
||||||
|
"value": length
|
||||||
|
},
|
||||||
|
|
||||||
|
)
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -108,7 +160,22 @@ export class OrangeChallengeRequestBuilder {
|
|||||||
* Définir le nom de l'expéditeur
|
* Définir le nom de l'expéditeur
|
||||||
*/
|
*/
|
||||||
withSenderName(senderName: string): this {
|
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;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -117,20 +184,20 @@ export class OrangeChallengeRequestBuilder {
|
|||||||
*/
|
*/
|
||||||
build(): OrangeChallengeRequest {
|
build(): OrangeChallengeRequest {
|
||||||
// Validation des champs obligatoires
|
// Validation des champs obligatoires
|
||||||
if (!this.request.country) {
|
if (!this.request.challenge.country) {
|
||||||
throw new Error('Country is required');
|
throw new Error('Country is required');
|
||||||
}
|
}
|
||||||
if (!this.request.method) {
|
if (!this.request.challenge.method) {
|
||||||
throw new Error('Method is required');
|
throw new Error('Method is required');
|
||||||
}
|
}
|
||||||
if (!this.request.service) {
|
if (!this.request.challenge.service) {
|
||||||
throw new Error('Service is required');
|
throw new Error('Service is required');
|
||||||
}
|
}
|
||||||
if (!this.request.partnerId) {
|
if (!this.request.challenge.partnerId) {
|
||||||
throw new Error('Partner ID is required');
|
throw new Error('Partner ID is required');
|
||||||
}
|
}
|
||||||
if (!this.request.identifier) {
|
if (!this.request.challenge.inputs) {
|
||||||
throw new Error('Identifier is required');
|
throw new Error('inputs is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.request as OrangeChallengeRequest;
|
return this.request as OrangeChallengeRequest;
|
||||||
@ -140,7 +207,14 @@ export class OrangeChallengeRequestBuilder {
|
|||||||
* Réinitialiser le builder
|
* Réinitialiser le builder
|
||||||
*/
|
*/
|
||||||
reset(): this {
|
reset(): this {
|
||||||
this.request = {};
|
this.request ={
|
||||||
|
challenge:{
|
||||||
|
country:'',
|
||||||
|
method: '',
|
||||||
|
service: '',
|
||||||
|
partnerId: '',
|
||||||
|
inputs:[]}
|
||||||
|
};
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -2,7 +2,8 @@ import axios, { AxiosInstance, AxiosError } from 'axios';
|
|||||||
import {
|
import {
|
||||||
OrangeChallengeRequest,
|
OrangeChallengeRequest,
|
||||||
OrangeChallengeResponse,
|
OrangeChallengeResponse,
|
||||||
OrangeChallengeRequestBuilder
|
OrangeChallengeRequestBuilder,
|
||||||
|
OrangeVerifyResponse
|
||||||
} from './dtos/orange.challenge.dto'
|
} from './dtos/orange.challenge.dto'
|
||||||
import {
|
import {
|
||||||
OrangeConfig,
|
OrangeConfig,
|
||||||
@ -13,13 +14,17 @@ import {
|
|||||||
|
|
||||||
//import { OtpChallengeResponseDto, OtpChallengeStatusEnum } from '../../dtos/otp-challenge-response.dto';
|
//import { OtpChallengeResponseDto, OtpChallengeStatusEnum } from '../../dtos/otp-challenge-response.dto';
|
||||||
import { OtpChallengeRequestDto } from '../dto/challenge.request.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
|
* Adaptateur pour l'API Orange DCB v2
|
||||||
* Gère l'authentification OAuth2 et les appels à l'API Challenge
|
* Gère l'authentification OAuth2 et les appels à l'API Challenge
|
||||||
*/
|
*/
|
||||||
export class OrangeAdapter {
|
export class OrangeAdapter {
|
||||||
|
private readonly logger = new Logger(OrangeAdapter.name);
|
||||||
|
|
||||||
private axiosInstance: AxiosInstance;
|
private axiosInstance: AxiosInstance;
|
||||||
private config: OrangeConfig;
|
private config: OrangeConfig;
|
||||||
private accessToken: string | null = null;
|
private accessToken: string | null = null;
|
||||||
@ -58,6 +63,9 @@ export class OrangeAdapter {
|
|||||||
`${this.config.clientId}:${this.config.clientSecret}`
|
`${this.config.clientId}:${this.config.clientSecret}`
|
||||||
).toString('base64');
|
).toString('base64');
|
||||||
|
|
||||||
|
//this.logger.debug( `request to get acces token , ${this.config.baseUrl}${this.config.tokenEndpoint}`)
|
||||||
|
|
||||||
|
|
||||||
const response = await axios.post(
|
const response = await axios.post(
|
||||||
`${this.config.baseUrl}${this.config.tokenEndpoint}`,
|
`${this.config.baseUrl}${this.config.tokenEndpoint}`,
|
||||||
'grant_type=client_credentials',
|
'grant_type=client_credentials',
|
||||||
@ -83,7 +91,49 @@ export class OrangeAdapter {
|
|||||||
/**
|
/**
|
||||||
* Convertir la requête générique en format Orange
|
* 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();
|
const builder = new OrangeChallengeRequestBuilder();
|
||||||
|
|
||||||
// Mapper le pays
|
// Mapper le pays
|
||||||
@ -109,17 +159,11 @@ export class OrangeAdapter {
|
|||||||
builder.withConfirmationCode(''); // Orange requiert ce champ même vide
|
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
|
// Nom de l'expéditeur
|
||||||
const senderName = request.config?.senderName || this.config.defaultSenderName;
|
const senderName = request.config?.senderName || this.config.defaultSenderName;
|
||||||
builder.withSenderName(senderName);
|
builder.withInfo("ise2");
|
||||||
|
|
||||||
return builder.build();
|
return builder.build();
|
||||||
}
|
}
|
||||||
@ -127,17 +171,18 @@ export class OrangeAdapter {
|
|||||||
/**
|
/**
|
||||||
* Convertir la réponse Orange en format générique
|
* Convertir la réponse Orange en format générique
|
||||||
*/
|
*/
|
||||||
private mapFromOrangeResponse(
|
private mapFromOrangeChallengeResponse(
|
||||||
orangeResponse: OrangeChallengeResponse,
|
orangeResponse: OrangeChallengeResponse,
|
||||||
request: OtpChallengeRequestDto
|
request: OtpChallengeRequestDto
|
||||||
): OtpChallengeResponseDto {
|
): OtpChallengeResponseDto {
|
||||||
|
const partsChallengeLocation= orangeResponse.location.split('/');
|
||||||
const response: OtpChallengeResponseDto = {
|
const response: OtpChallengeResponseDto = {
|
||||||
challengeId: orangeResponse.challengeId || '',
|
challengeId: partsChallengeLocation[partsChallengeLocation.length - 1],
|
||||||
merchantId: request.merchantId,
|
merchantId: request.merchantId,
|
||||||
status: this.mapOrangeStatus(orangeResponse),
|
status: this.mapOrangeStatus(orangeResponse),
|
||||||
message: orangeResponse.message,
|
message: orangeResponse.challenge.result+"",
|
||||||
expiresIn: orangeResponse.expiresIn,
|
expiresIn: new Date().getTime(),
|
||||||
sessionId: orangeResponse.sessionId,
|
//sessionId: orangeResponse.sessionId,
|
||||||
requiresConfirmation: true,
|
requiresConfirmation: true,
|
||||||
metadata: {
|
metadata: {
|
||||||
provider: 'orange',
|
provider: 'orange',
|
||||||
@ -159,6 +204,35 @@ export class OrangeAdapter {
|
|||||||
return response;
|
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
|
* Mapper le statut Orange vers le statut générique
|
||||||
*/
|
*/
|
||||||
@ -167,13 +241,23 @@ export class OrangeAdapter {
|
|||||||
return OtpChallengeStatusEnum.FAILED;
|
return OtpChallengeStatusEnum.FAILED;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (orangeResponse.challengeId) {
|
if (orangeResponse.location) {
|
||||||
return OtpChallengeStatusEnum.SENT;
|
return OtpChallengeStatusEnum.SENT;
|
||||||
}
|
}
|
||||||
|
|
||||||
return OtpChallengeStatusEnum.PENDING;
|
return OtpChallengeStatusEnum.PENDING;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private mapOrangeResponseStatus(orangeResponse: OrangeVerifyResponse): OtpChallengeStatusEnum {
|
||||||
|
if (orangeResponse.error) {
|
||||||
|
return OtpChallengeStatusEnum.FAILED;
|
||||||
|
}else{
|
||||||
|
return OtpChallengeStatusEnum.VERIFIED;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gérer les erreurs HTTP
|
* Gérer les erreurs HTTP
|
||||||
*/
|
*/
|
||||||
@ -197,9 +281,14 @@ export class OrangeAdapter {
|
|||||||
try {
|
try {
|
||||||
// Obtenir le token
|
// Obtenir le token
|
||||||
const token = await this.getAccessToken();
|
const token = await this.getAccessToken();
|
||||||
|
//this.logger.debug(`initiateChallenge --> acces token ${token}`);
|
||||||
|
|
||||||
// Mapper la requête
|
// 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
|
// Appeler l'API Orange
|
||||||
const response = await this.axiosInstance.post<OrangeChallengeResponse>(
|
const response = await this.axiosInstance.post<OrangeChallengeResponse>(
|
||||||
@ -213,7 +302,7 @@ export class OrangeAdapter {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Mapper la réponse
|
// Mapper la réponse
|
||||||
return this.mapFromOrangeResponse(response.data, request);
|
return this.mapFromOrangeChallengeResponse(response.data, request);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// En cas d'erreur, retourner une réponse avec le statut FAILED
|
// En cas d'erreur, retourner une réponse avec le statut FAILED
|
||||||
return {
|
return {
|
||||||
@ -236,7 +325,7 @@ export class OrangeAdapter {
|
|||||||
challengeId: string,
|
challengeId: string,
|
||||||
otpCode: string,
|
otpCode: string,
|
||||||
originalRequest: OtpChallengeRequestDto
|
originalRequest: OtpChallengeRequestDto
|
||||||
): Promise<OtpChallengeResponseDto> {
|
): Promise<OtpVerifResponseDto> {
|
||||||
try {
|
try {
|
||||||
// Créer une nouvelle requête avec le code de confirmation
|
// Créer une nouvelle requête avec le code de confirmation
|
||||||
const verifyRequest: OtpChallengeRequestDto = {
|
const verifyRequest: OtpChallengeRequestDto = {
|
||||||
@ -248,11 +337,15 @@ export class OrangeAdapter {
|
|||||||
const token = await this.getAccessToken();
|
const token = await this.getAccessToken();
|
||||||
|
|
||||||
// Mapper la requête
|
// 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
|
// Appeler l'API Orange pour vérification todo use request otp challenge
|
||||||
const response = await this.axiosInstance.post<OrangeChallengeResponse>(
|
//
|
||||||
this.config.challengeEndpoint,
|
const response = await this.axiosInstance.post<OrangeVerifyResponse>(
|
||||||
|
`${this.config.challengeEndpoint}/${challengeId}`,
|
||||||
orangeRequest,
|
orangeRequest,
|
||||||
{
|
{
|
||||||
headers: {
|
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
|
// 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é
|
// Si pas d'erreur, c'est vérifié
|
||||||
if (!mappedResponse.error) {
|
if (!mappedResponse.error) {
|
||||||
@ -272,7 +373,7 @@ export class OrangeAdapter {
|
|||||||
return mappedResponse;
|
return mappedResponse;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
challengeId,
|
userAlias:'undefined',
|
||||||
merchantId: originalRequest.merchantId,
|
merchantId: originalRequest.merchantId,
|
||||||
status: OtpChallengeStatusEnum.FAILED,
|
status: OtpChallengeStatusEnum.FAILED,
|
||||||
error: {
|
error: {
|
||||||
|
|||||||
@ -40,6 +40,36 @@ export class OtpChallengeResponseDto {
|
|||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
requiresConfirmation?: boolean;
|
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()
|
@IsOptional()
|
||||||
metadata?: Record<string, any>;
|
metadata?: Record<string, any>;
|
||||||
|
|
||||||
|
|||||||
@ -23,6 +23,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
OtpChallengeResponseDto,
|
OtpChallengeResponseDto,
|
||||||
OtpChallengeStatusEnum,
|
OtpChallengeStatusEnum,
|
||||||
|
OtpVerifResponseDto,
|
||||||
} from './dto/challenge.response.dto';
|
} from './dto/challenge.response.dto';
|
||||||
import { OtpChallengeRequestDto } from './dto/challenge.request.dto';
|
import { OtpChallengeRequestDto } from './dto/challenge.request.dto';
|
||||||
import { OtpChallengeService } from './otp.challenge.service';
|
import { OtpChallengeService } from './otp.challenge.service';
|
||||||
@ -177,7 +178,7 @@ export class OtpChallengeController {
|
|||||||
@Body('otpCode') otpCode: string,
|
@Body('otpCode') otpCode: string,
|
||||||
@Headers('X-Merchant-ID') merchantId: string,
|
@Headers('X-Merchant-ID') merchantId: string,
|
||||||
@Headers('x-API-KEY') apiKey: string,
|
@Headers('x-API-KEY') apiKey: string,
|
||||||
): Promise<OtpChallengeResponseDto> {
|
): Promise<OtpVerifResponseDto> {
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`[VERIFY] Merchant: ${merchantId}, Challenge: ${challengeId}`,
|
`[VERIFY] Merchant: ${merchantId}, Challenge: ${challengeId}`,
|
||||||
);
|
);
|
||||||
@ -193,6 +194,7 @@ export class OtpChallengeController {
|
|||||||
otpCode,
|
otpCode,
|
||||||
merchantId,
|
merchantId,
|
||||||
);
|
);
|
||||||
|
this.logger.log(`[VERIFY] Result - object: ${response}`);
|
||||||
|
|
||||||
// Logger le résultat
|
// Logger le résultat
|
||||||
this.logger.log(`[VERIFY] Result - Status: ${response.status}`);
|
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 { OtpChallengeController } from './otp.challenge.controller';
|
||||||
import { OrangeConfig } from './adaptor/orange.config';
|
import { OrangeConfig } from './adaptor/orange.config';
|
||||||
import { OtpChallengeService } from './otp.challenge.service';
|
import { OtpChallengeService } from './otp.challenge.service';
|
||||||
|
import { CommonModule } from 'src/common/commonde.module';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Module pour le challenge OTP
|
* Module pour le challenge OTP
|
||||||
* Gère l'injection de dépendances et la configuration
|
* Gère l'injection de dépendances et la configuration
|
||||||
*/
|
*/
|
||||||
@Module({
|
@Module({
|
||||||
imports: [ConfigModule],
|
imports: [ConfigModule, CommonModule],
|
||||||
controllers: [OtpChallengeController],
|
controllers: [OtpChallengeController],
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
provide: 'ORANGE_CONFIG',
|
provide: 'ORANGE_CONFIG',
|
||||||
useFactory: (configService: ConfigService): OrangeConfig => ({
|
useFactory: (configService: ConfigService): OrangeConfig => ({
|
||||||
baseUrl: configService.get<string>('ORANGE_BASE_URL', 'https://webhook.site/69ce9344-f87b-421a-a494-c59eca7c54ce'),
|
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'),
|
partnerId: configService.get<string>('ORANGE_PARTNER_ID', 'PDKSUB'),
|
||||||
clientId: configService.get<string>('ORANGE_CLIENT_ID', 'admin'),
|
clientId: configService.get<string>('ORANGE_CLIENT_ID', 'admin'),
|
||||||
clientSecret: configService.get<string>('ORANGE_CLIENT_SECRET', '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 type { OrangeConfig } from './adaptor/orange.config';
|
||||||
import { OrangeAdapter } from './adaptor/orange.adaptor';
|
import { OrangeAdapter } from './adaptor/orange.adaptor';
|
||||||
import { OtpChallengeRequestDto } from './dto/challenge.request.dto';
|
import { OtpChallengeRequestDto } from './dto/challenge.request.dto';
|
||||||
import { IOtpChallengeService } from './otp.challenge.interface';
|
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
|
* Service Hub pour gérer les challenges OTP
|
||||||
@ -11,12 +13,17 @@ import { OtpChallengeResponseDto, OtpChallengeStatusEnum } from './dto/challenge
|
|||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class OtpChallengeService implements IOtpChallengeService {
|
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 orangeAdapter: OrangeAdapter;
|
||||||
private challengeCache: Map<string, { request: OtpChallengeRequestDto; response: OtpChallengeResponseDto }>;
|
|
||||||
|
|
||||||
constructor(@Inject('ORANGE_CONFIG') private readonly orangeConfig: OrangeConfig) {
|
constructor(
|
||||||
this.orangeAdapter = new OrangeAdapter(orangeConfig);
|
//@Inject(CACHE_MANAGER) private cacheManager: Cache,
|
||||||
this.challengeCache = new Map();
|
@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 {
|
try {
|
||||||
|
|
||||||
// Appeler l'adaptateur Orange
|
// Appeler l'adaptateur Orange
|
||||||
|
|
||||||
const response = await this.orangeAdapter.initiateChallenge(request);
|
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)
|
if (response.challengeId || true) {
|
||||||
const expirationTime = (response.expiresIn || 300) * 1000;
|
// this.cacheManager
|
||||||
setTimeout(() => {
|
await this.cacheService.set(response.challengeId , {request:request,response:response}, {
|
||||||
this.challengeCache.delete(response.challengeId);
|
prefix: this.CACHE_PREFIX,
|
||||||
}, expirationTime);
|
ttl: this.DEFAULT_TTL,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
@ -51,14 +58,15 @@ export class OtpChallengeService implements IOtpChallengeService {
|
|||||||
challengeId: string,
|
challengeId: string,
|
||||||
otpCode: string,
|
otpCode: string,
|
||||||
merchantId: string
|
merchantId: string
|
||||||
): Promise<OtpChallengeResponseDto> {
|
): Promise<any> {
|
||||||
try {
|
try {
|
||||||
// Récupérer le challenge depuis le cache
|
// 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) {
|
if (!cached) {
|
||||||
return {
|
return {
|
||||||
challengeId,
|
userAlias:"",
|
||||||
merchantId,
|
merchantId,
|
||||||
status: OtpChallengeStatusEnum.FAILED,
|
status: OtpChallengeStatusEnum.FAILED,
|
||||||
error: {
|
error: {
|
||||||
@ -72,7 +80,7 @@ export class OtpChallengeService implements IOtpChallengeService {
|
|||||||
// Vérifier que le merchantId correspond
|
// Vérifier que le merchantId correspond
|
||||||
if (cached.request.merchantId !== merchantId) {
|
if (cached.request.merchantId !== merchantId) {
|
||||||
return {
|
return {
|
||||||
challengeId,
|
userAlias:"",
|
||||||
merchantId,
|
merchantId,
|
||||||
status: OtpChallengeStatusEnum.FAILED,
|
status: OtpChallengeStatusEnum.FAILED,
|
||||||
error: {
|
error: {
|
||||||
@ -92,13 +100,13 @@ export class OtpChallengeService implements IOtpChallengeService {
|
|||||||
|
|
||||||
// Mettre à jour le cache
|
// Mettre à jour le cache
|
||||||
if (response.status === OtpChallengeStatusEnum.VERIFIED) {
|
if (response.status === OtpChallengeStatusEnum.VERIFIED) {
|
||||||
this.challengeCache.set(challengeId, { request: cached.request, response });
|
this.cacheService.set(challengeId, { request: cached.request, response });
|
||||||
}
|
}
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
challengeId,
|
userAlias:"",
|
||||||
merchantId,
|
merchantId,
|
||||||
status: OtpChallengeStatusEnum.FAILED,
|
status: OtpChallengeStatusEnum.FAILED,
|
||||||
error: {
|
error: {
|
||||||
|
|||||||
@ -24,19 +24,13 @@ export interface RefundResponse{
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SubscriptionParams{
|
|
||||||
|
|
||||||
}
|
|
||||||
export interface SubscriptionResponse{
|
|
||||||
|
|
||||||
}
|
|
||||||
export interface IOperatorAdapter {
|
export interface IOperatorAdapter {
|
||||||
initializeAuth(params: AuthInitParams): Promise<AuthInitResponse>;
|
initializeAuth(params: AuthInitParams): Promise<AuthInitResponse>;
|
||||||
validateAuth(params: AuthValidateParams): Promise<AuthValidateResponse>;
|
validateAuth(params: AuthValidateParams): Promise<AuthValidateResponse>;
|
||||||
charge(params: ChargeParams): Promise<ChargeResponse>;
|
charge(params: ChargeParams): Promise<ChargeResponse>;
|
||||||
refund(params: RefundParams): Promise<RefundResponse>;
|
refund(params: RefundParams): Promise<RefundResponse>;
|
||||||
sendSms(params: SmsParams): Promise<SmsResponse>;
|
sendSms(params: SmsParams): Promise<SmsResponse>;
|
||||||
createSubscription?(
|
createSubscription(
|
||||||
params: SubscriptionParams,
|
params: SubscriptionParams,
|
||||||
): Promise<SubscriptionResponse>;
|
): Promise<SubscriptionResponse>;
|
||||||
cancelSubscription?(subscriptionId: string): Promise<void>;
|
cancelSubscription?(subscriptionId: string): Promise<void>;
|
||||||
@ -70,5 +64,27 @@ export interface ChargeResponse {
|
|||||||
status: 'SUCCESS' | 'FAILED' | 'PENDING';
|
status: 'SUCCESS' | 'FAILED' | 'PENDING';
|
||||||
operatorReference: string;
|
operatorReference: string;
|
||||||
amount: number;
|
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;
|
currency: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||||
import { HttpService } from '@nestjs/axios';
|
import { HttpService } from '@nestjs/axios';
|
||||||
|
import axios, { AxiosInstance, AxiosError } from 'axios';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { firstValueFrom } from 'rxjs';
|
import { firstValueFrom } from 'rxjs';
|
||||||
import {
|
import {
|
||||||
@ -8,33 +9,57 @@ import {
|
|||||||
AuthInitResponse,
|
AuthInitResponse,
|
||||||
ChargeParams,
|
ChargeParams,
|
||||||
ChargeResponse,
|
ChargeResponse,
|
||||||
|
SubscriptionParams,
|
||||||
|
SubscriptionResponse,
|
||||||
} from './operator.adapter.interface';
|
} from './operator.adapter.interface';
|
||||||
import { OrangeTransformer } from '../transformers/orange.transformer';
|
import { OrangeTransformer } from '../transformers/orange.transformer';
|
||||||
|
import {
|
||||||
|
DEFAULT_ORANGE_CONFIG,
|
||||||
|
COUNTRY_CODE_MAPPING,
|
||||||
|
} from './orange.config';
|
||||||
|
|
||||||
|
import type { OrangeConfig } from './orange.config';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class OrangeAdapter implements IOperatorAdapter {
|
export class OrangeAdapter implements IOperatorAdapter {
|
||||||
|
private readonly logger = new Logger(OrangeAdapter.name);
|
||||||
|
private config: OrangeConfig;
|
||||||
private baseUrl: string;
|
private baseUrl: string;
|
||||||
private accessToken: string;
|
private accessToken: string;
|
||||||
private transformer: OrangeTransformer;
|
private transformer: OrangeTransformer;
|
||||||
|
private tokenExpiresAt: number = 0;
|
||||||
|
private axiosInstance: AxiosInstance;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly httpService: HttpService,
|
private readonly httpService: HttpService,
|
||||||
private readonly configService: ConfigService,
|
@Inject('ORANGE_CONFIG')config: OrangeConfig,
|
||||||
) {
|
) {
|
||||||
this.baseUrl = this.configService.get('ORANGE_API_URL') as string;
|
this.config = { ...DEFAULT_ORANGE_CONFIG, ...config } as OrangeConfig;
|
||||||
this.accessToken = this.configService.get('ORANGE_ACCESS_TOKEN') as string;
|
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();
|
this.transformer = new OrangeTransformer();
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async initializeAuth(params: AuthInitParams): Promise<AuthInitResponse> {
|
async initializeAuth(params: AuthInitParams): Promise<AuthInitResponse> {
|
||||||
const countryCode = this.getCountryCode(params.country);
|
const countryCode = this.getCountryCode(params.country);
|
||||||
|
|
||||||
const bizaoRequest = {
|
const hubRequest = {
|
||||||
challenge: {
|
challenge: {
|
||||||
method: 'OTP-SMS-AUTH',
|
method: 'OTP-SMS-AUTH',
|
||||||
country: countryCode,
|
country: countryCode,
|
||||||
service: 'BIZAO',
|
service: 'DCB_HUB',
|
||||||
partnerId: 'PDKSUB',
|
partnerId: 'PDKSUB',
|
||||||
inputs: [
|
inputs: [
|
||||||
{
|
{
|
||||||
@ -64,7 +89,7 @@ export class OrangeAdapter implements IOperatorAdapter {
|
|||||||
const response = await firstValueFrom(
|
const response = await firstValueFrom(
|
||||||
this.httpService.post(
|
this.httpService.post(
|
||||||
`${this.baseUrl}/challenge/v1/challenges`,
|
`${this.baseUrl}/challenge/v1/challenges`,
|
||||||
bizaoRequest,
|
hubRequest,
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${this.accessToken}`,
|
Authorization: `Bearer ${this.accessToken}`,
|
||||||
@ -87,11 +112,11 @@ export class OrangeAdapter implements IOperatorAdapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async validateAuth(params: any): Promise<any> {
|
async validateAuth(params: any): Promise<any> {
|
||||||
const bizaoRequest = {
|
const hubRequest = {
|
||||||
challenge: {
|
challenge: {
|
||||||
method: 'OTP-SMS-AUTH',
|
method: 'OTP-SMS-AUTH',
|
||||||
country: params.country,
|
country: params.country,
|
||||||
service: 'BIZAO',
|
service: 'DCB_HUB',
|
||||||
partnerId: 'PDKSUB',
|
partnerId: 'PDKSUB',
|
||||||
inputs: [
|
inputs: [
|
||||||
{
|
{
|
||||||
@ -113,7 +138,7 @@ export class OrangeAdapter implements IOperatorAdapter {
|
|||||||
const response = await firstValueFrom(
|
const response = await firstValueFrom(
|
||||||
this.httpService.post(
|
this.httpService.post(
|
||||||
`${this.baseUrl}/challenge/v1/challenges/${params.challengeId}`,
|
`${this.baseUrl}/challenge/v1/challenges/${params.challengeId}`,
|
||||||
bizaoRequest,
|
hubRequest,
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${this.accessToken}`,
|
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> {
|
async charge(params: ChargeParams): Promise<ChargeResponse> {
|
||||||
const bizaoRequest = {
|
this.logger.debug(
|
||||||
|
`[orange adapter charge ]: ${JSON.stringify(params, null, 2)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const hubRequest = {
|
||||||
amountTransaction: {
|
amountTransaction: {
|
||||||
endUserId: 'acr:OrangeAPIToken',
|
endUserId: 'acr:OrangeAPIToken',
|
||||||
paymentAmount: {
|
paymentAmount: {
|
||||||
@ -149,31 +289,43 @@ export class OrangeAdapter implements IOperatorAdapter {
|
|||||||
description: params.description,
|
description: params.description,
|
||||||
},
|
},
|
||||||
chargingMetaData: {
|
chargingMetaData: {
|
||||||
onBehalfOf: 'PaymentHub',
|
onBehalfOf: 'PaymentHub', //from config todo
|
||||||
serviceId: 'BIZAO',
|
purchaseCategoryCode: 'Service', //todo from config
|
||||||
|
serviceId: 'DCB_HUB',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
transactionOperationStatus: 'Charged',
|
transactionOperationStatus: 'Charged',
|
||||||
referenceCode: params.reference,
|
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(
|
const response = await firstValueFrom(
|
||||||
this.httpService.post(
|
this.httpService.post(
|
||||||
`${this.baseUrl}/payment/v1/acr%3AOrangeAPIToken/transactions/amount`,
|
`${this.config.baseUrl}/payment/mea/v1/acr%3AX-Orange-ISE2/transactions/amount`,
|
||||||
bizaoRequest,
|
hubRequest,
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${this.accessToken}`,
|
Authorization: `Bearer ${token}`,
|
||||||
'bizao-token': params.userToken,
|
'X-Orange-ISE2': params.userToken,
|
||||||
'bizao-alias': params.userAlias,
|
'X-Orange-MCO': 'orange', //from country todo
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.logger.debug(`[response fromm orange ]: ${JSON.stringify(response.data, null, 2)}`,)
|
||||||
|
|
||||||
return this.transformer.transformChargeResponse(response.data);
|
return this.transformer.transformChargeResponse(response.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -182,6 +334,8 @@ export class OrangeAdapter implements IOperatorAdapter {
|
|||||||
throw new Error('Refund not implemented for Orange');
|
throw new Error('Refund not implemented for Orange');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async sendSms(params: any): Promise<any> {
|
async sendSms(params: any): Promise<any> {
|
||||||
const smsRequest = {
|
const smsRequest = {
|
||||||
outboundSMSMessageRequest: {
|
outboundSMSMessageRequest: {
|
||||||
@ -204,11 +358,11 @@ export class OrangeAdapter implements IOperatorAdapter {
|
|||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${this.accessToken}`,
|
Authorization: `Bearer ${this.accessToken}`,
|
||||||
'X-OAPI-Application-Id': 'BIZAO',
|
'X-OAPI-Application-Id': 'DCB_HUB',
|
||||||
'X-OAPI-Contact-Id': 'b2b-bizao-97b5878',
|
'X-OAPI-Contact-Id': 'b2b-DCB_HUB-97b5878',
|
||||||
'X-OAPI-Resource-Type': 'SMS_OSM',
|
'X-OAPI-Resource-Type': 'SMS_OSM',
|
||||||
'bizao-alias': params.userAlias,
|
'DCB_HUB-alias': params.userAlias,
|
||||||
'bizao-token': params.userToken,
|
'DCB_HUB-token': params.userToken,
|
||||||
'X-Orange-MCO': this.getMCO(params.country),
|
'X-Orange-MCO': this.getMCO(params.country),
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
@ -257,4 +411,53 @@ export class OrangeAdapter implements IOperatorAdapter {
|
|||||||
};
|
};
|
||||||
return senderMap[country];
|
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',
|
description: 'List of operators',
|
||||||
type: [OperatorResponseDto],
|
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')
|
@Get(':operatorCode/statistics')
|
||||||
@ApiOperation({ summary: 'Get operator statistics' })
|
@ApiOperation({ summary: 'Get operator statistics' })
|
||||||
@ -141,42 +91,9 @@ export class OperatorsController {
|
|||||||
return this.operatorsService.detectOperatorByMsisdn(msisdn);
|
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 { OrangeTransformer } from './transformers/orange.transformer';
|
||||||
import { MTNTransformer } from './transformers/mtn.transformer';
|
import { MTNTransformer } from './transformers/mtn.transformer';
|
||||||
import { PrismaService } from '../../shared/services/prisma.service';
|
import { PrismaService } from '../../shared/services/prisma.service';
|
||||||
|
import { OrangeConfig } from './adapters/orange.config';
|
||||||
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@ -15,9 +17,32 @@ import { PrismaService } from '../../shared/services/prisma.service';
|
|||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
maxRedirects: 3,
|
maxRedirects: 3,
|
||||||
}),
|
}),
|
||||||
|
ConfigModule
|
||||||
|
|
||||||
],
|
],
|
||||||
controllers: [OperatorsController],
|
controllers: [OperatorsController],
|
||||||
providers: [
|
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,
|
OperatorsService,
|
||||||
OperatorAdapterFactory,
|
OperatorAdapterFactory,
|
||||||
OrangeAdapter,
|
OrangeAdapter,
|
||||||
|
|||||||
@ -1,10 +1,11 @@
|
|||||||
import { BadRequestException, NotFoundException } from "@nestjs/common";
|
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { PrismaService } from "src/shared/services/prisma.service";
|
import { PrismaService } from "src/shared/services/prisma.service";
|
||||||
import { OperatorAdapterFactory } from "./adapters/operator-adapter.factory";
|
import { OperatorAdapterFactory } from "./adapters/operator-adapter.factory";
|
||||||
import { HttpService } from "@nestjs/axios";
|
import { HttpService } from "@nestjs/axios";
|
||||||
import { firstValueFrom } from 'rxjs';
|
import { firstValueFrom } from 'rxjs';
|
||||||
//todo tomaj
|
//todo tomaj
|
||||||
|
@Injectable()
|
||||||
export class OperatorsService{
|
export class OperatorsService{
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@ -18,149 +19,6 @@ export class OperatorsService{
|
|||||||
return this.adapterFactory.getAdapter(operator, country);
|
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) {
|
async getOperatorStatistics(params: any) {
|
||||||
const { partnerId, operatorCode, startDate, endDate } = params;
|
const { partnerId, operatorCode, startDate, endDate } = params;
|
||||||
|
|
||||||
@ -191,8 +49,8 @@ export class OperatorsService{
|
|||||||
}),
|
}),
|
||||||
this.prisma.payment.findMany({
|
this.prisma.payment.findMany({
|
||||||
where,
|
where,
|
||||||
distinct: ['userId'],
|
//distinct: ['userId'],
|
||||||
select: { userId: true },
|
//select: { userId: true },
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -220,18 +78,14 @@ export class OperatorsService{
|
|||||||
this.prisma.payment.count({
|
this.prisma.payment.count({
|
||||||
where: {
|
where: {
|
||||||
createdAt: { gte: oneHourAgo },
|
createdAt: { gte: oneHourAgo },
|
||||||
user: {
|
|
||||||
operator: { code: operatorCode as any },
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
this.prisma.payment.count({
|
this.prisma.payment.count({
|
||||||
where: {
|
where: {
|
||||||
createdAt: { gte: oneHourAgo },
|
createdAt: { gte: oneHourAgo },
|
||||||
status: 'FAILED',
|
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 {
|
private getCountryName(code: string): string {
|
||||||
const countries = {
|
const countries = {
|
||||||
|
|||||||
@ -2,29 +2,46 @@ import { Injectable } from '@nestjs/common';
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class OrangeTransformer {
|
export class OrangeTransformer {
|
||||||
transformChargeResponse(bizaoResponse: any): any {
|
transformChargeResponse(orangeResponse: any): any {
|
||||||
return {
|
return {
|
||||||
paymentId: bizaoResponse.amountTransaction?.serverReferenceCode,
|
paymentId: orangeResponse.amountTransaction?.serverReferenceCode,
|
||||||
status: this.mapStatus(
|
status: this.mapStatus(
|
||||||
bizaoResponse.amountTransaction?.transactionOperationStatus,
|
orangeResponse.amountTransaction?.transactionOperationStatus,
|
||||||
),
|
),
|
||||||
operatorReference: bizaoResponse.amountTransaction?.serverReferenceCode,
|
operatorReference: orangeResponse.amountTransaction?.serverReferenceCode,
|
||||||
amount: parseFloat(
|
amount: parseFloat(
|
||||||
bizaoResponse.amountTransaction?.paymentAmount?.totalAmountCharged,
|
orangeResponse.amountTransaction?.paymentAmount?.totalAmountCharged,
|
||||||
),
|
),
|
||||||
|
resourceURL: orangeResponse.amountTransaction?.resourceURL,
|
||||||
currency:
|
currency:
|
||||||
bizaoResponse.amountTransaction?.paymentAmount?.chargingInformation
|
orangeResponse.amountTransaction?.paymentAmount?.chargingInformation
|
||||||
?.currency,
|
?.currency,
|
||||||
createdAt: new Date(),
|
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 = {
|
const statusMap = {
|
||||||
|
Completed: 'SUCCESS',
|
||||||
Charged: 'SUCCESS',
|
Charged: 'SUCCESS',
|
||||||
Failed: 'FAILED',
|
Failed: 'FAILED',
|
||||||
Pending: 'PENDING',
|
Pending: 'PENDING',
|
||||||
};
|
};
|
||||||
return statusMap[bizaoStatus] || 'PENDING';
|
return statusMap[orangeStatus] || 'PENDING';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,9 +6,13 @@ import {
|
|||||||
Min,
|
Min,
|
||||||
IsEnum,
|
IsEnum,
|
||||||
IsDateString,
|
IsDateString,
|
||||||
|
isNumber,
|
||||||
|
IsInt,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
|
import { PaymentType, TransactionStatus } from 'generated/prisma';
|
||||||
|
import { PaginationDto } from 'src/common/dto/pagination.dto';
|
||||||
|
|
||||||
export class ChargeDto {
|
export class ChargeDto {
|
||||||
@ApiProperty({ description: 'User token from authentication' })
|
@ApiProperty({ description: 'User token from authentication' })
|
||||||
@ -35,8 +39,8 @@ export class ChargeDto {
|
|||||||
|
|
||||||
@ApiProperty({ required: false, description: 'Subscription ID if recurring' })
|
@ApiProperty({ required: false, description: 'Subscription ID if recurring' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsNumber()
|
||||||
subscriptionId?: string;
|
subscriptionId?: number;
|
||||||
|
|
||||||
@ApiProperty({ required: false, description: 'Callback URL for notifications' })
|
@ApiProperty({ required: false, description: 'Callback URL for notifications' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@ -49,14 +53,22 @@ export class ChargeDto {
|
|||||||
|
|
||||||
@ApiProperty({ required: false, description: 'partnerId ' })
|
@ApiProperty({ required: false, description: 'partnerId ' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
partnerId: string;
|
partnerId: number;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, description: 'country ' })
|
||||||
|
@IsOptional()
|
||||||
|
country: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, description: 'operator ' })
|
||||||
|
@IsOptional()
|
||||||
|
operator: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class RefundDto {
|
export class RefundDto {
|
||||||
@ApiProperty({ required: false, description: 'Amount to refund (partial refund)' })
|
@ApiProperty({ required: false, description: 'Amount to refund (partial refund)' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@Min(0)
|
@Min(1)
|
||||||
amount?: number;
|
amount?: number;
|
||||||
|
|
||||||
@ApiProperty({ description: 'Reason for refund' })
|
@ApiProperty({ description: 'Reason for refund' })
|
||||||
@ -68,46 +80,6 @@ export class RefundDto {
|
|||||||
metadata?: Record<string, any>;
|
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 {
|
export class PaymentResponseDto {
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
@ -159,3 +131,156 @@ export class PaymentListResponseDto {
|
|||||||
totalPages: number;
|
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,
|
HttpCode,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
|
Headers,
|
||||||
|
Logger,
|
||||||
|
ParseIntPipe,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
ApiTags,
|
ApiTags,
|
||||||
@ -32,10 +35,12 @@ import { ApiKeyGuard } from '../../common/guards/api-key.guard';
|
|||||||
@ApiTags('payments')
|
@ApiTags('payments')
|
||||||
@Controller('payments')
|
@Controller('payments')
|
||||||
export class PaymentsController {
|
export class PaymentsController {
|
||||||
|
private readonly logger = new Logger(PaymentsController.name);
|
||||||
|
|
||||||
constructor(private readonly paymentsService: PaymentsService) {}
|
constructor(private readonly paymentsService: PaymentsService) {}
|
||||||
|
|
||||||
@Post('charge')
|
@Post('charge')
|
||||||
@UseGuards(JwtAuthGuard)
|
//@UseGuards(JwtAuthGuard)
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@HttpCode(HttpStatus.CREATED)
|
@HttpCode(HttpStatus.CREATED)
|
||||||
@ApiOperation({ summary: 'Create a new charge' })
|
@ApiOperation({ summary: 'Create a new charge' })
|
||||||
@ -46,10 +51,18 @@ export class PaymentsController {
|
|||||||
})
|
})
|
||||||
@ApiResponse({ status: 400, description: 'Bad request' })
|
@ApiResponse({ status: 400, description: 'Bad request' })
|
||||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
@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({
|
return this.paymentsService.createCharge({
|
||||||
...chargeDto,
|
...chargeDto,
|
||||||
partnerId: req.user.partnerId,
|
country: coutnry,
|
||||||
|
operator: operator,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -76,7 +89,7 @@ export class PaymentsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(':paymentId')
|
@Get(':paymentId')
|
||||||
@UseGuards(JwtAuthGuard)
|
//@UseGuards(JwtAuthGuard)
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiOperation({ summary: 'Get payment details' })
|
@ApiOperation({ summary: 'Get payment details' })
|
||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
@ -85,14 +98,15 @@ export class PaymentsController {
|
|||||||
type: PaymentResponseDto,
|
type: PaymentResponseDto,
|
||||||
})
|
})
|
||||||
@ApiResponse({ status: 404, description: 'Payment not found' })
|
@ApiResponse({ status: 404, description: 'Payment not found' })
|
||||||
async getPayment(@Request() req, @Param('paymentId') paymentId: string) {
|
async getPayment(@Request() req, @Param('paymentId') paymentId: number) {
|
||||||
return this.paymentsService.getPayment(paymentId, req.user.partnerId);
|
console.log('Fetching payment with ID:', paymentId);
|
||||||
|
return this.paymentsService.getPayment(paymentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@Get('reference/:reference')
|
@Get('reference/:reference')
|
||||||
@UseGuards(JwtAuthGuard)
|
//@UseGuards(JwtAuthGuard)
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiOperation({ summary: 'Get payment by reference' })
|
@ApiOperation({ summary: 'Get payment by reference' })
|
||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
@ -105,13 +119,43 @@ export class PaymentsController {
|
|||||||
@Param('reference') reference: string,
|
@Param('reference') reference: string,
|
||||||
) {
|
) {
|
||||||
return this.paymentsService.getPaymentByReference(
|
return this.paymentsService.getPaymentByReference(
|
||||||
reference,
|
reference
|
||||||
req.user.partnerId,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@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')
|
@Post(':paymentId/retry')
|
||||||
@UseGuards(JwtAuthGuard)
|
// @UseGuards(JwtAuthGuard)
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({ summary: 'Retry a failed payment' })
|
@ApiOperation({ summary: 'Retry a failed payment' })
|
||||||
@ -130,7 +174,7 @@ export class PaymentsController {
|
|||||||
|
|
||||||
|
|
||||||
@Post('validate')
|
@Post('validate')
|
||||||
@UseGuards(JwtAuthGuard)
|
//@UseGuards(JwtAuthGuard)
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({ summary: 'Validate payment before processing' })
|
@ApiOperation({ summary: 'Validate payment before processing' })
|
||||||
@ -143,7 +187,7 @@ export class PaymentsController {
|
|||||||
|
|
||||||
// Webhook endpoints
|
// Webhook endpoints
|
||||||
@Post('webhook/callback')
|
@Post('webhook/callback')
|
||||||
@UseGuards(ApiKeyGuard)
|
//@UseGuards(ApiKeyGuard)
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({ summary: 'Webhook callback for payment updates' })
|
@ApiOperation({ summary: 'Webhook callback for payment updates' })
|
||||||
async handleWebhook(@Request() req, @Body() payload: any) {
|
async handleWebhook(@Request() req, @Body() payload: any) {
|
||||||
|
|||||||
@ -6,6 +6,8 @@ import { PaymentProcessor } from './processors/payment.processor';
|
|||||||
import { WebhookService } from './services/webhook.service';
|
import { WebhookService } from './services/webhook.service';
|
||||||
import { PrismaService } from '../../shared/services/prisma.service';
|
import { PrismaService } from '../../shared/services/prisma.service';
|
||||||
import { OperatorsModule } from '../operators/operators.module';
|
import { OperatorsModule } from '../operators/operators.module';
|
||||||
|
import { Subscription } from 'rxjs';
|
||||||
|
import { SubscriptionsModule } from '../subscriptions/subscriptions.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@ -16,6 +18,8 @@ import { OperatorsModule } from '../operators/operators.module';
|
|||||||
name: 'webhooks',
|
name: 'webhooks',
|
||||||
}),
|
}),
|
||||||
OperatorsModule,
|
OperatorsModule,
|
||||||
|
SubscriptionsModule,
|
||||||
|
|
||||||
],
|
],
|
||||||
controllers: [PaymentsController],
|
controllers: [PaymentsController],
|
||||||
providers: [PaymentsService, PaymentProcessor, WebhookService, PrismaService],
|
providers: [PaymentsService, PaymentProcessor, WebhookService, PrismaService],
|
||||||
|
|||||||
@ -1,80 +1,281 @@
|
|||||||
import { Injectable, BadRequestException } from '@nestjs/common';
|
import { Injectable, BadRequestException, Logger } from '@nestjs/common';
|
||||||
import { OperatorsService } from '../operators/operators.service';
|
import { OperatorsService } from '../operators/operators.service';
|
||||||
import { PrismaService } from '../../shared/services/prisma.service';
|
import { PrismaService } from '../../shared/services/prisma.service';
|
||||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
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 { 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()
|
@Injectable()
|
||||||
export class PaymentsService {
|
export class PaymentsService {
|
||||||
handleWebhook(arg0: { partnerId: any; event: any; payload: any; signature: any; }) {
|
private readonly logger = new Logger(PaymentsService.name);
|
||||||
throw new Error('Method not implemented.');
|
|
||||||
}
|
|
||||||
getPaymentByReference(reference: string, partnerId: any) {
|
constructor(
|
||||||
throw new Error('Method not implemented.');
|
|
||||||
}
|
|
||||||
getPayment(paymentId: string, partnerId: any) {
|
|
||||||
throw new Error('Method not implemented.');
|
|
||||||
}
|
|
||||||
refundPayment(paymentId: string, partnerId: any, refundDto: RefundDto) {
|
|
||||||
throw new Error('Method not implemented.');
|
|
||||||
}
|
|
||||||
retryPayment(paymentId: any, attempt: any) {
|
|
||||||
throw new Error('Method not implemented.');
|
|
||||||
}
|
|
||||||
processPayment(paymentId: any) :any{
|
|
||||||
throw new Error('Method not implemented.');
|
|
||||||
}
|
|
||||||
constructor(
|
|
||||||
private readonly operatorsService: OperatorsService,
|
private readonly operatorsService: OperatorsService,
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly eventEmitter: EventEmitter2,
|
private readonly eventEmitter: EventEmitter2,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
handleWebhook(arg0: {
|
||||||
|
partnerId: any;
|
||||||
|
event: any;
|
||||||
|
payload: any;
|
||||||
|
signature: any;
|
||||||
|
}) {
|
||||||
|
throw new Error('Method not implemented.');
|
||||||
|
}
|
||||||
|
async getPaymentByReference(reference: string) {
|
||||||
|
const plan = await this.prisma.payment.findFirst({
|
||||||
|
where: { reference: reference },
|
||||||
|
});
|
||||||
|
return plan
|
||||||
|
}
|
||||||
|
async getPayment(id: number) {
|
||||||
|
const data = await this.prisma.payment.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async findAllByMerchant(merchantId: number): Promise<any[]> {
|
||||||
|
// Check if merchant exists
|
||||||
|
|
||||||
|
return this.prisma.payment.findMany({
|
||||||
|
where: { merchantPartnerId: merchantId },
|
||||||
|
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAllByMerchantSubscription(merchantId: number, subscriptionId: number): Promise<any[]> {
|
||||||
|
// Check if merchant exists
|
||||||
|
|
||||||
|
return this.prisma.payment.findMany({
|
||||||
|
where: { merchantPartnerId: merchantId, subscriptionId: subscriptionId },
|
||||||
|
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAll(
|
||||||
|
queryDto: PaymentQueryDto,
|
||||||
|
): Promise<PaginatedResponse<Payment>> {
|
||||||
|
const {
|
||||||
|
page = 1,
|
||||||
|
limit = 10,
|
||||||
|
sortBy = 'createdAt',
|
||||||
|
sortOrder = 'desc',
|
||||||
|
} = queryDto;
|
||||||
|
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
const where = this.buildWhereClause(queryDto);
|
||||||
|
|
||||||
|
// Construction du orderBy dynamique
|
||||||
|
const orderBy: Prisma.PaymentOrderByWithRelationInput = {
|
||||||
|
[sortBy]: sortOrder,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Exécuter les requêtes en parallèle
|
||||||
|
const [payments, total] = await Promise.all([
|
||||||
|
this.prisma.payment.findMany({
|
||||||
|
where,
|
||||||
|
skip,
|
||||||
|
take: limit,
|
||||||
|
orderBy,
|
||||||
|
// Optionnel: inclure les relations
|
||||||
|
// include: {
|
||||||
|
// merchantPartner: true,
|
||||||
|
// subscription: true,
|
||||||
|
// reversementRequests: true,
|
||||||
|
// },
|
||||||
|
}),
|
||||||
|
this.prisma.payment.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(total / limit);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: payments,
|
||||||
|
meta: {
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
totalPages,
|
||||||
|
hasNextPage: page < totalPages,
|
||||||
|
hasPreviousPage: page > 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private buildWhereClause(
|
||||||
|
filters: Partial<PaymentQueryDto>
|
||||||
|
): Prisma.PaymentWhereInput {
|
||||||
|
const {
|
||||||
|
type,
|
||||||
|
status,
|
||||||
|
merchantPartnerId,
|
||||||
|
customerId,
|
||||||
|
subscriptionId,
|
||||||
|
externalReference,
|
||||||
|
reference,
|
||||||
|
currency,
|
||||||
|
amountMin,
|
||||||
|
amountMax,
|
||||||
|
createdFrom,
|
||||||
|
createdTo,
|
||||||
|
completedFrom,
|
||||||
|
completedTo,
|
||||||
|
hasFailureReason,
|
||||||
|
} = filters;
|
||||||
|
|
||||||
|
const where: Prisma.PaymentWhereInput = {};
|
||||||
|
|
||||||
|
// Filtres simples
|
||||||
|
if (type) where.type = type;
|
||||||
|
if (status) where.status = status;
|
||||||
|
if (merchantPartnerId) where.merchantPartnerId = merchantPartnerId;
|
||||||
|
if (customerId) where.customerId = customerId;
|
||||||
|
if (subscriptionId) where.subscriptionId = subscriptionId;
|
||||||
|
if (currency) where.currency = currency;
|
||||||
|
|
||||||
|
// Filtres de recherche par référence
|
||||||
|
if (externalReference) {
|
||||||
|
where.externalReference = {
|
||||||
|
contains: externalReference,
|
||||||
|
mode: 'insensitive',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reference) {
|
||||||
|
where.reference = {
|
||||||
|
contains: reference,
|
||||||
|
mode: 'insensitive',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtre sur les montants
|
||||||
|
if (amountMin !== undefined || amountMax !== undefined) {
|
||||||
|
where.amount = {};
|
||||||
|
if (amountMin !== undefined) {
|
||||||
|
where.amount.gte = amountMin;
|
||||||
|
}
|
||||||
|
if (amountMax !== undefined) {
|
||||||
|
where.amount.lte = amountMax;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtres sur createdAt
|
||||||
|
if (createdFrom || createdTo) {
|
||||||
|
where.createdAt = {};
|
||||||
|
if (createdFrom) {
|
||||||
|
where.createdAt.gte = new Date(createdFrom);
|
||||||
|
}
|
||||||
|
if (createdTo) {
|
||||||
|
where.createdAt.lte = new Date(createdTo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtres sur completedAt
|
||||||
|
if (completedFrom || completedTo) {
|
||||||
|
where.completedAt = {};
|
||||||
|
if (completedFrom) {
|
||||||
|
where.completedAt.gte = new Date(completedFrom);
|
||||||
|
}
|
||||||
|
if (completedTo) {
|
||||||
|
where.completedAt.lte = new Date(completedTo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtre sur les paiements échoués
|
||||||
|
if (hasFailureReason !== undefined) {
|
||||||
|
if (hasFailureReason) {
|
||||||
|
where.failureReason = { not: null };
|
||||||
|
} else {
|
||||||
|
where.failureReason = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return where;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
refundPayment(paymentId: string, partnerId: any, refundDto: RefundDto) {
|
||||||
|
throw new Error('Method not implemented.');
|
||||||
|
}
|
||||||
|
retryPayment(paymentId: any, attempt: any) {
|
||||||
|
throw new Error('Method not implemented.');
|
||||||
|
}
|
||||||
|
processPayment(paymentId: any): any {
|
||||||
|
throw new Error('Method not implemented.');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async createCharge(chargeDto: ChargeDto) {
|
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({
|
const user = await this.prisma.user.findUnique({
|
||||||
where: { userToken: chargeDto.userToken },
|
where: { userToken: chargeDto.userToken },
|
||||||
include: { operator: true },
|
// include: { operator: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new BadRequestException('Invalid user token');
|
throw new BadRequestException('Invalid user token');
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
// Créer la transaction dans la base
|
// Créer la transaction dans la base
|
||||||
const payment = await this.prisma.payment.create({
|
const payment = await this.prisma.payment.create({
|
||||||
|
|
||||||
data: {
|
data: {
|
||||||
partnerId:"",
|
subscriptionId: chargeDto.subscriptionId,
|
||||||
userId: user.id,
|
merchantPartnerId: chargeDto.partnerId, // À remplacer par le bon partnerId
|
||||||
amount: chargeDto.amount,
|
customerId: 1, // todo À remplacer par user.id
|
||||||
|
amount: chargeDto.amount,
|
||||||
currency: chargeDto.currency,
|
currency: chargeDto.currency,
|
||||||
description: chargeDto.description,
|
type: PaymentType.MM,
|
||||||
reference: chargeDto.reference || this.generateReference(),
|
reference: chargeDto.reference || this.generateReference(),
|
||||||
status: PaymentStatus.PENDING,
|
//description: chargeDto.description,
|
||||||
|
//reference: chargeDto.reference || this.generateReference(),
|
||||||
|
status: TransactionStatus.PENDING,
|
||||||
metadata: chargeDto.metadata,
|
metadata: chargeDto.metadata,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Router vers le bon opérateur
|
// Router vers le bon opérateur
|
||||||
|
this.logger.debug(
|
||||||
|
`[getting adaptator for ]: ${chargeDto.operator}_${chargeDto.country} `,
|
||||||
|
);
|
||||||
const adapter = this.operatorsService.getAdapter(
|
const adapter = this.operatorsService.getAdapter(
|
||||||
user.operator.code,
|
chargeDto.operator,
|
||||||
user.country,
|
chargeDto.country,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.debug(
|
||||||
|
`Processing payment ${payment.id} through operator adapter ${adapter.constructor.name}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const chargeParams = {
|
const chargeParams = {
|
||||||
userToken: user.userToken,
|
userToken: chargeDto.userToken,
|
||||||
userAlias: user.userAlias,
|
userAlias: chargeDto.userToken, //todo make alias in contrat
|
||||||
amount: chargeDto.amount,
|
amount: chargeDto.amount,
|
||||||
currency: chargeDto.currency,
|
currency: chargeDto.currency,
|
||||||
description: chargeDto.description,
|
description: chargeDto.description,
|
||||||
reference: payment.reference,
|
subscriptionId: chargeDto.subscriptionId,
|
||||||
|
reference: chargeDto.reference + '', //todo make reference in contrat
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await adapter.charge(chargeParams);
|
const result = await adapter.charge(chargeParams);
|
||||||
|
this.logger.debug(
|
||||||
|
`result frm adaptaor ${result} for payment ${payment.id}`,
|
||||||
|
);
|
||||||
|
|
||||||
// Mettre à jour le paiement
|
// Mettre à jour le paiement
|
||||||
const updatedPayment = await this.prisma.payment.update({
|
const updatedPayment = await this.prisma.payment.update({
|
||||||
@ -82,9 +283,10 @@ export class PaymentsService {
|
|||||||
data: {
|
data: {
|
||||||
status:
|
status:
|
||||||
result.status === 'SUCCESS'
|
result.status === 'SUCCESS'
|
||||||
? PaymentStatus.SUCCESS
|
? TransactionStatus.SUCCESS
|
||||||
: PaymentStatus.FAILED,
|
: TransactionStatus.FAILED,
|
||||||
operatorReference: result.operatorReference,
|
externalReference: result.operatorReference,
|
||||||
|
link: result.resourceURL,
|
||||||
completedAt: new Date(),
|
completedAt: new Date(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -92,7 +294,7 @@ export class PaymentsService {
|
|||||||
// Émettre un événement
|
// Émettre un événement
|
||||||
this.eventEmitter.emit('payment.completed', {
|
this.eventEmitter.emit('payment.completed', {
|
||||||
payment: updatedPayment,
|
payment: updatedPayment,
|
||||||
operator: user.operator.code,
|
operator: 'user.operator.code',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Appeler le callback du partenaire si fourni
|
// Appeler le callback du partenaire si fourni
|
||||||
@ -102,16 +304,20 @@ export class PaymentsService {
|
|||||||
|
|
||||||
return updatedPayment;
|
return updatedPayment;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
this.logger.debug(
|
||||||
|
`error ${error.message} processing payment ${payment.id}`,
|
||||||
|
);
|
||||||
|
|
||||||
// En cas d'erreur, marquer comme échoué
|
// En cas d'erreur, marquer comme échoué
|
||||||
await this.prisma.payment.update({
|
const resultFinal = await this.prisma.payment.update({
|
||||||
where: { id: payment.id },
|
where: { id: payment.id },
|
||||||
data: {
|
data: {
|
||||||
status: PaymentStatus.FAILED,
|
status: TransactionStatus.FAILED,
|
||||||
failureReason: error.message,
|
failureReason: error.message,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
throw error;
|
return { ...resultFinal };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -126,163 +332,148 @@ export class PaymentsService {
|
|||||||
|
|
||||||
// Ajouter ces méthodes dans PaymentsService
|
// Ajouter ces méthodes dans PaymentsService
|
||||||
|
|
||||||
async listPayments(filters: any) {
|
async listPayments(filters: any) {
|
||||||
const where: any = {
|
const where: any = {
|
||||||
partnerId: filters.partnerId,
|
partnerId: filters.partnerId,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (filters.status) {
|
if (filters.status) {
|
||||||
where.status = filters.status;
|
where.status = filters.status;
|
||||||
}
|
|
||||||
|
|
||||||
if (filters.userId) {
|
|
||||||
where.userId = filters.userId;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filters.subscriptionId) {
|
|
||||||
where.subscriptionId = filters.subscriptionId;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filters.startDate || filters.endDate) {
|
|
||||||
where.createdAt = {};
|
|
||||||
if (filters.startDate) {
|
|
||||||
where.createdAt.gte = new Date(filters.startDate);
|
|
||||||
}
|
}
|
||||||
if (filters.endDate) {
|
|
||||||
where.createdAt.lte = new Date(filters.endDate);
|
if (filters.userId) {
|
||||||
|
where.userId = filters.userId;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const page = filters.page || 1;
|
if (filters.subscriptionId) {
|
||||||
const limit = filters.limit || 20;
|
where.subscriptionId = filters.subscriptionId;
|
||||||
const skip = (page - 1) * limit;
|
}
|
||||||
|
|
||||||
const [payments, total] = await Promise.all([
|
if (filters.startDate || filters.endDate) {
|
||||||
this.prisma.payment.findMany({
|
where.createdAt = {};
|
||||||
where,
|
if (filters.startDate) {
|
||||||
skip,
|
where.createdAt.gte = new Date(filters.startDate);
|
||||||
take: limit,
|
}
|
||||||
orderBy: { createdAt: 'desc' },
|
if (filters.endDate) {
|
||||||
include: {
|
where.createdAt.lte = new Date(filters.endDate);
|
||||||
user: {
|
}
|
||||||
select: {
|
}
|
||||||
id: true,
|
|
||||||
msisdn: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
subscription: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
planId: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
this.prisma.payment.count({ where }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
const page = filters.page || 1;
|
||||||
data: payments,
|
const limit = filters.limit || 20;
|
||||||
meta: {
|
const skip = (page - 1) * limit;
|
||||||
total,
|
|
||||||
page,
|
|
||||||
limit,
|
|
||||||
totalPages: Math.ceil(total / limit),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async getStatistics(params: {
|
const [payments, total] = await Promise.all([
|
||||||
partnerId: string;
|
this.prisma.payment.findMany({
|
||||||
period: string;
|
where,
|
||||||
startDate?: Date;
|
skip,
|
||||||
endDate?: Date;
|
take: limit,
|
||||||
}) {
|
orderBy: { createdAt: 'desc' },
|
||||||
const { partnerId, period, startDate, endDate } = params;
|
}),
|
||||||
|
this.prisma.payment.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
const where: any = { partnerId };
|
|
||||||
|
|
||||||
if (startDate || endDate) {
|
|
||||||
where.createdAt = {};
|
|
||||||
if (startDate) where.createdAt.gte = startDate;
|
|
||||||
if (endDate) where.createdAt.lte = endDate;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [
|
|
||||||
totalPayments,
|
|
||||||
successfulPayments,
|
|
||||||
failedPayments,
|
|
||||||
totalRevenue,
|
|
||||||
avgPaymentAmount,
|
|
||||||
] = await Promise.all([
|
|
||||||
this.prisma.payment.count({ where }),
|
|
||||||
this.prisma.payment.count({ where: { ...where, status: 'SUCCESS' } }),
|
|
||||||
this.prisma.payment.count({ where: { ...where, status: 'FAILED' } }),
|
|
||||||
this.prisma.payment.aggregate({
|
|
||||||
where: { ...where, status: 'SUCCESS' },
|
|
||||||
_sum: { amount: true },
|
|
||||||
}),
|
|
||||||
this.prisma.payment.aggregate({
|
|
||||||
where: { ...where, status: 'SUCCESS' },
|
|
||||||
_avg: { amount: true },
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const successRate = totalPayments > 0
|
|
||||||
? (successfulPayments / totalPayments) * 100
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
return {
|
|
||||||
totalPayments,
|
|
||||||
successfulPayments,
|
|
||||||
failedPayments,
|
|
||||||
successRate: Math.round(successRate * 100) / 100,
|
|
||||||
totalRevenue: totalRevenue._sum.amount || 0,
|
|
||||||
avgPaymentAmount: avgPaymentAmount._avg.amount || 0,
|
|
||||||
period,
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async validatePayment(params: any) {
|
|
||||||
// Valider le user token
|
|
||||||
const user = await this.prisma.user.findUnique({
|
|
||||||
where: { userToken: params.userToken },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return {
|
return {
|
||||||
valid: false,
|
data: payments,
|
||||||
error: 'Invalid user token',
|
meta: {
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
totalPages: Math.ceil(total / limit),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vérifier les limites
|
async getStatistics(params: {
|
||||||
const todayPayments = await this.prisma.payment.count({
|
partnerId: string;
|
||||||
where: {
|
period: string;
|
||||||
userId: user.id,
|
startDate?: Date;
|
||||||
status: 'SUCCESS',
|
endDate?: Date;
|
||||||
createdAt: {
|
}) {
|
||||||
gte: new Date(new Date().setHours(0, 0, 0, 0)),
|
const { partnerId, period, startDate, endDate } = params;
|
||||||
},
|
|
||||||
},
|
const where: any = { partnerId };
|
||||||
});
|
|
||||||
|
if (startDate || endDate) {
|
||||||
|
where.createdAt = {};
|
||||||
|
if (startDate) where.createdAt.gte = startDate;
|
||||||
|
if (endDate) where.createdAt.lte = endDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [
|
||||||
|
totalPayments,
|
||||||
|
successfulPayments,
|
||||||
|
failedPayments,
|
||||||
|
totalRevenue,
|
||||||
|
avgPaymentAmount,
|
||||||
|
] = await Promise.all([
|
||||||
|
this.prisma.payment.count({ where }),
|
||||||
|
this.prisma.payment.count({ where: { ...where, status: 'SUCCESS' } }),
|
||||||
|
this.prisma.payment.count({ where: { ...where, status: 'FAILED' } }),
|
||||||
|
this.prisma.payment.aggregate({
|
||||||
|
where: { ...where, status: 'SUCCESS' },
|
||||||
|
_sum: { amount: true },
|
||||||
|
}),
|
||||||
|
this.prisma.payment.aggregate({
|
||||||
|
where: { ...where, status: 'SUCCESS' },
|
||||||
|
_avg: { amount: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const successRate =
|
||||||
|
totalPayments > 0 ? (successfulPayments / totalPayments) * 100 : 0;
|
||||||
|
|
||||||
if (todayPayments >= 10) {
|
|
||||||
return {
|
return {
|
||||||
valid: false,
|
totalPayments,
|
||||||
error: 'Daily payment limit reached',
|
successfulPayments,
|
||||||
|
failedPayments,
|
||||||
|
successRate: Math.round(successRate * 100) / 100,
|
||||||
|
totalRevenue: totalRevenue._sum.amount || 0,
|
||||||
|
avgPaymentAmount: avgPaymentAmount._avg.amount || 0,
|
||||||
|
period,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
async validatePayment(params: any) {
|
||||||
valid: true,
|
// Valider le user token
|
||||||
user: {
|
const user = await this.prisma.user.findUnique({
|
||||||
id: user.id,
|
where: { userToken: params.userToken },
|
||||||
msisdn: user.msisdn,
|
});
|
||||||
country: user.country,
|
|
||||||
},
|
if (!user) {
|
||||||
};
|
return {
|
||||||
}
|
valid: false,
|
||||||
|
error: 'Invalid user token',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier les limites
|
||||||
|
const todayPayments = await this.prisma.payment.count({
|
||||||
|
where: {
|
||||||
|
customerId: 1, // todo À remplacer par user.id
|
||||||
|
status: 'SUCCESS',
|
||||||
|
createdAt: {
|
||||||
|
gte: new Date(new Date().setHours(0, 0, 0, 0)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (todayPayments >= 10) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: 'Daily payment limit reached',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: true,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
msisdn: user.msisdn,
|
||||||
|
country: user.country,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 { 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 {
|
export class CreateSubscriptionDto {
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
@ -8,13 +11,12 @@ export class CreateSubscriptionDto {
|
|||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
@IsString()
|
@IsString()
|
||||||
planId: string;
|
userAlias: string;
|
||||||
|
|
||||||
@ApiProperty({ required: false })
|
|
||||||
@IsOptional()
|
@ApiProperty()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@Min(0)
|
planId: number;
|
||||||
trialDays?: number;
|
|
||||||
|
|
||||||
@ApiProperty({ required: false })
|
@ApiProperty({ required: false })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@ -27,15 +29,15 @@ export class CreateSubscriptionDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class UpdateSubscriptionDto {
|
export class UpdateSubscriptionDto {
|
||||||
@ApiProperty({ required: false, enum: ['ACTIVE', 'PAUSED'] })
|
@ApiProperty({ required: false, enum: ['ACTIVE', 'SUSPENDED'] })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsEnum(['ACTIVE', 'PAUSED'])
|
@IsEnum(['ACTIVE', 'SUSPENDED'])
|
||||||
status?: string;
|
status?: string;
|
||||||
|
|
||||||
@ApiProperty({ required: false })
|
@ApiProperty({ required: false })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
planId?: string;
|
planId?: number;
|
||||||
|
|
||||||
@ApiProperty({ required: false })
|
@ApiProperty({ required: false })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@ -46,3 +48,122 @@ export class UpdateSubscriptionDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
metadata?: Record<string, any>;
|
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({
|
const subscriptions = await this.prisma.subscription.findMany({
|
||||||
where: {
|
where: {
|
||||||
status: 'ACTIVE',
|
status: 'ACTIVE',
|
||||||
nextBillingDate: {
|
nextPaymentDate: {
|
||||||
lte: now,
|
lte: now,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -43,7 +43,7 @@ export class SubscriptionScheduler {
|
|||||||
const expiringTrials = await this.prisma.subscription.findMany({
|
const expiringTrials = await this.prisma.subscription.findMany({
|
||||||
where: {
|
where: {
|
||||||
status: 'TRIAL',
|
status: 'TRIAL',
|
||||||
trialEndsAt: {
|
nextPaymentDate: {
|
||||||
lte: now,
|
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,
|
Body,
|
||||||
Param,
|
Param,
|
||||||
Query,
|
Query,
|
||||||
|
Headers,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
Request,
|
Request,
|
||||||
|
Logger,
|
||||||
|
ParseIntPipe,
|
||||||
} from '@nestjs/common';
|
} 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 { 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 { JwtAuthGuard } from 'src/common/guards/jwt-auth.guard';
|
||||||
|
import { PaginationDto } from 'src/common/dto/pagination.dto';
|
||||||
|
|
||||||
@ApiTags('subscriptions')
|
@ApiTags('subscriptions')
|
||||||
@Controller('subscriptions')
|
@Controller('subscriptions')
|
||||||
@UseGuards(JwtAuthGuard)
|
//@UseGuards(JwtAuthGuard)
|
||||||
@ApiBearerAuth()
|
//@ApiBearerAuth()
|
||||||
export class SubscriptionsController {
|
export class SubscriptionsController {
|
||||||
|
private readonly logger = new Logger(SubscriptionsController.name);
|
||||||
constructor(private readonly subscriptionsService: SubscriptionsService) {}
|
constructor(private readonly subscriptionsService: SubscriptionsService) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@ApiOperation({ summary: 'Create a new subscription' })
|
@ApiOperation({ summary: 'Create a new subscription' })
|
||||||
async create(@Request() req, @Body() dto: CreateSubscriptionDto) {
|
async create(
|
||||||
return this.subscriptionsService.create(req.user.partnerId, dto);
|
@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')
|
@Get(':id')
|
||||||
@ApiOperation({ summary: 'Get subscription details' })
|
@ApiOperation({ summary: 'Get subscription details' })
|
||||||
async get(@Request() req, @Param('id') id: string) {
|
async get(@Request() req, @Param('id') id: number) {
|
||||||
return this.subscriptionsService.get(id, req.user.partnerId);
|
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')
|
@Delete(':id')
|
||||||
@ApiOperation({ summary: 'Cancel subscription' })
|
@ApiOperation({ summary: 'Cancel subscription' })
|
||||||
async cancel(
|
async cancel(
|
||||||
@Request() req,
|
@Request() req,
|
||||||
@Param('id') id: string,
|
@Param('id') id: number,
|
||||||
@Body('reason') reason?: string,
|
@Body('reason') reason?: string,
|
||||||
) {
|
) {
|
||||||
return this.subscriptionsService.cancel(id, req.user.partnerId, reason);
|
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 { SubscriptionsService } from './subscriptions.service';
|
||||||
import { SubscriptionScheduler } from './schedulers/subscription.scheduler';
|
import { SubscriptionScheduler } from './schedulers/subscription.scheduler';
|
||||||
import { SubscriptionProcessor } from './processors/subscription.processor';
|
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 { PrismaService } from '../../shared/services/prisma.service';
|
||||||
import { PaymentsModule } from '../payments/payments.module';
|
import { PaymentsModule } from '../payments/payments.module';
|
||||||
import { HttpModule } from '@nestjs/axios';
|
import { HttpModule } from '@nestjs/axios';
|
||||||
|
import { OperatorsModule } from '../operators/operators.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@ -19,17 +19,17 @@ import { PaymentsModule } from '../payments/payments.module';
|
|||||||
BullModule.registerQueue({
|
BullModule.registerQueue({
|
||||||
name: 'billing',
|
name: 'billing',
|
||||||
}),
|
}),
|
||||||
PaymentsModule,
|
OperatorsModule
|
||||||
|
// PaymentsModule,
|
||||||
],
|
],
|
||||||
controllers: [SubscriptionsController],
|
controllers: [SubscriptionsController],
|
||||||
providers: [
|
providers: [
|
||||||
SubscriptionsService,
|
SubscriptionsService,
|
||||||
SubscriptionScheduler,
|
SubscriptionScheduler,
|
||||||
SubscriptionProcessor,
|
SubscriptionProcessor,
|
||||||
PlanService,
|
//BillingService,
|
||||||
BillingService,
|
|
||||||
PrismaService,
|
PrismaService,
|
||||||
],
|
],
|
||||||
exports: [SubscriptionsService, PlanService],
|
exports: [SubscriptionsService],
|
||||||
})
|
})
|
||||||
export class SubscriptionsModule {}
|
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 { InjectQueue } from '@nestjs/bull';
|
||||||
import bull from 'bull';
|
import bull from 'bull';
|
||||||
import { PrismaService } from '../../shared/services/prisma.service';
|
import { PrismaService } from '../../shared/services/prisma.service';
|
||||||
import { PaymentsService } from '../payments/payments.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 } from '@prisma/client';
|
||||||
//import { SubscriptionStatus, Prisma } from '@prisma/client';
|
//import { SubscriptionStatus, Prisma } from '@prisma/client';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SubscriptionsService {
|
export class SubscriptionsService {
|
||||||
get(id: string, partnerId: any) {
|
private readonly logger = new Logger(SubscriptionsService.name);
|
||||||
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.');
|
|
||||||
}
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly paymentsService: PaymentsService,
|
private readonly operatorsService: OperatorsService,
|
||||||
@InjectQueue('subscriptions') private subscriptionQueue: bull.Queue,
|
@InjectQueue('subscriptions') private subscriptionQueue: bull.Queue,
|
||||||
@InjectQueue('billing') private billingQueue: bull.Queue,
|
@InjectQueue('billing') private billingQueue: bull.Queue,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async create(partnerId: string, dto: CreateSubscriptionDto) {
|
async get(id: number):Promise<any> {
|
||||||
// Vérifier l'utilisateur
|
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({
|
const user = await this.prisma.user.findFirst({
|
||||||
where: {
|
where: {
|
||||||
@ -39,8 +185,9 @@ export class SubscriptionsService {
|
|||||||
if (!user) {
|
if (!user) {
|
||||||
throw new BadRequestException('Invalid user token for this partner');
|
throw new BadRequestException('Invalid user token for this partner');
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
// Vérifier le plan
|
/* Vérifier le plan
|
||||||
const plan = await this.prisma.plan.findUnique({
|
const plan = await this.prisma.plan.findUnique({
|
||||||
where: { id: dto.planId },
|
where: { id: dto.planId },
|
||||||
});
|
});
|
||||||
@ -48,12 +195,16 @@ export class SubscriptionsService {
|
|||||||
if (!plan || !plan.active) {
|
if (!plan || !plan.active) {
|
||||||
throw new BadRequestException('Invalid or inactive plan');
|
throw new BadRequestException('Invalid or inactive plan');
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Vérifier s'il n'y a pas déjà une subscription active
|
// Vérifier s'il n'y a pas déjà une subscription active
|
||||||
const existingSubscription = await this.prisma.subscription.findFirst({
|
const existingSubscription = await this.prisma.subscription.findFirst({
|
||||||
where: {
|
where: {
|
||||||
userId: user.id,
|
|
||||||
planId: plan.id,
|
token: dto.userToken,
|
||||||
|
planId: dto.planId ,
|
||||||
status: { in: ['ACTIVE', 'TRIAL'] },
|
status: { in: ['ACTIVE', 'TRIAL'] },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -62,60 +213,58 @@ export class SubscriptionsService {
|
|||||||
throw new BadRequestException('User already has an active subscription for this plan');
|
throw new BadRequestException('User already has an active subscription for this plan');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculer les dates
|
const adapter = this.operatorsService.getAdapter(
|
||||||
const now = new Date();
|
operator,
|
||||||
const trialDays = dto.trialDays || plan.trialDays || 0;
|
country,
|
||||||
const hasTrialPeriod = trialDays > 0;
|
|
||||||
|
);
|
||||||
|
|
||||||
|
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
|
// Créer la subscription
|
||||||
const subscription = await this.prisma.subscription.create({
|
const subscription = await this.prisma.subscription.create({
|
||||||
data: {
|
data: {
|
||||||
userId: user.id,
|
customerId: 1, //user.id, todo
|
||||||
planId: plan.id,
|
externalReference: result.subscriptionId,
|
||||||
partnerId: partnerId,
|
merchantPartnerId: 4,// todo , parseInt(partnerId),
|
||||||
status: hasTrialPeriod ? 'TRIAL' : 'PENDING',
|
token: dto.userToken,
|
||||||
currentPeriodStart,
|
planId: dto.planId,
|
||||||
currentPeriodEnd,
|
serviceId: 1, //plan.serviceId, todo
|
||||||
nextBillingDate,
|
periodicity: "Daily",
|
||||||
trialEndsAt,
|
amount: 20,
|
||||||
metadata: {
|
currency: "XOF",
|
||||||
...dto.metadata,
|
status: 'ACTIVE',//todo mapping result.status 'SUCCESS' ? 'ACTIVE' : 'PENDING',
|
||||||
userAlias: user.userAlias,
|
//currentPeriodStart: new Date(),
|
||||||
operator: user.operator.code,
|
//currentPeriodEnd: new Date(), // todo À ajuster selon la périodicité
|
||||||
country: user.country,
|
// nextBillingDate: new Date(), // todo À ajuster selon la périodicité
|
||||||
},
|
//renewalCount: 0,
|
||||||
},
|
startDate: new Date(),
|
||||||
include: {
|
failureCount: 0,
|
||||||
plan: true,
|
nextPaymentDate: new Date(), // todo À ajuster selon la périodicité
|
||||||
user: true,
|
metadata: dto.metadata,
|
||||||
},
|
}
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Si pas de période d'essai, traiter le premier paiement
|
// 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
|
// Notifier le partenaire via webhook
|
||||||
if (dto.callbackUrl) {
|
if (dto.callbackUrl) {
|
||||||
@ -129,11 +278,14 @@ export class SubscriptionsService {
|
|||||||
return subscription;
|
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({
|
const subscription = await this.prisma.subscription.findFirst({
|
||||||
where: {
|
where: {
|
||||||
id: subscriptionId,
|
id: subscriptionId,
|
||||||
partnerId: partnerId,
|
merchantPartnerId: partnerId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -141,74 +293,15 @@ export class SubscriptionsService {
|
|||||||
throw new NotFoundException('Subscription not found');
|
throw new NotFoundException('Subscription not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateData: any = {};
|
if (subscription.status === 'SUSPENDED' ) {
|
||||||
|
|
||||||
// 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') {
|
|
||||||
throw new BadRequestException('Subscription already cancelled');
|
throw new BadRequestException('Subscription already cancelled');
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedSubscription = await this.prisma.subscription.update({
|
const updatedSubscription = await this.prisma.subscription.update({
|
||||||
where: { id: subscriptionId },
|
where: { id: subscriptionId },
|
||||||
data: {
|
data: {
|
||||||
status: 'CANCELLED',
|
status: 'SUSPENDED',
|
||||||
cancelledAt: new Date(),
|
//cancelledAt: new Date(),
|
||||||
metadata: {
|
metadata: {
|
||||||
cancellationDetails: {
|
cancellationDetails: {
|
||||||
reason,
|
reason,
|
||||||
@ -226,7 +319,7 @@ export class SubscriptionsService {
|
|||||||
await job.remove();
|
await job.remove();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/*TODO
|
||||||
// Notifier via webhook
|
// Notifier via webhook
|
||||||
const partner = await this.prisma.partner.findUnique({
|
const partner = await this.prisma.partner.findUnique({
|
||||||
where: { id: partnerId },
|
where: { id: partnerId },
|
||||||
@ -243,7 +336,7 @@ export class SubscriptionsService {
|
|||||||
onFailure?: string;
|
onFailure?: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
/*
|
|
||||||
if (partner?.callbacks?subscription?.onCancel) {
|
if (partner?.callbacks?subscription?.onCancel) {
|
||||||
await this.subscriptionQueue.add('webhook-notification', {
|
await this.subscriptionQueue.add('webhook-notification', {
|
||||||
url: partner.callbacks.subscription.onCancel,
|
url: partner.callbacks.subscription.onCancel,
|
||||||
@ -256,14 +349,11 @@ export class SubscriptionsService {
|
|||||||
return updatedSubscription;
|
return updatedSubscription;
|
||||||
}
|
}
|
||||||
|
|
||||||
async processRenewal(subscriptionId: string) {
|
async processRenewal(subscriptionId: number) {
|
||||||
|
/*todo
|
||||||
const subscription = await this.prisma.subscription.findUnique({
|
const subscription = await this.prisma.subscription.findUnique({
|
||||||
where: { id: subscriptionId },
|
where: { id: subscriptionId },
|
||||||
include: {
|
|
||||||
user: true,
|
|
||||||
plan: true,
|
|
||||||
partner: true,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!subscription) {
|
if (!subscription) {
|
||||||
@ -311,25 +401,25 @@ export class SubscriptionsService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Programmer le prochain renouvellement
|
// Programmer le prochain renouvellement
|
||||||
/* todo
|
|
||||||
const delay = subscription.nextBillingDate.getTime() - Date.now();
|
const delay = subscription.nextBillingDate.getTime() - Date.now();
|
||||||
await this.billingQueue.add(
|
await this.billingQueue.add(
|
||||||
'process-renewal',
|
'process-renewal',
|
||||||
{ subscriptionId },
|
{ subscriptionId },
|
||||||
{ delay },
|
{ delay },
|
||||||
);
|
);
|
||||||
*/
|
|
||||||
|
|
||||||
// Notifier le succès
|
// Notifier le succès
|
||||||
|
|
||||||
/* if (subscription.partner?.callbacks?.subscription?.onRenew) {
|
if (subscription.partner?.callbacks?.subscription?.onRenew) {
|
||||||
await this.subscriptionQueue.add('webhook-notification', {
|
await this.subscriptionQueue.add('webhook-notification', {
|
||||||
url: subscription.partner.callbacks.subscription.onRenew,
|
url: subscription.partner.callbacks.subscription.onRenew,
|
||||||
event: 'SUBSCRIPTION_RENEWED',
|
event: 'SUBSCRIPTION_RENEWED',
|
||||||
subscription: subscription,
|
subscription: subscription,
|
||||||
payment: payment,
|
payment: payment,
|
||||||
});
|
});
|
||||||
}*/
|
}
|
||||||
} else {
|
} else {
|
||||||
await this.handleRenewalFailure(subscription);
|
await this.handleRenewalFailure(subscription);
|
||||||
}
|
}
|
||||||
@ -337,63 +427,11 @@ export class SubscriptionsService {
|
|||||||
console.error(`Renewal failed for subscription ${subscriptionId}:`, error);
|
console.error(`Renewal failed for subscription ${subscriptionId}:`, error);
|
||||||
await this.handleRenewalFailure(subscription);
|
await this.handleRenewalFailure(subscription);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
*/
|
||||||
}
|
}
|
||||||
//todo
|
//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) {
|
private async handleRenewalFailure(subscription: any) {
|
||||||
const failureCount = (subscription.failureCount || 0) + 1;
|
const failureCount = (subscription.failureCount || 0) + 1;
|
||||||
@ -405,8 +443,7 @@ export class SubscriptionsService {
|
|||||||
where: { id: subscription.id },
|
where: { id: subscription.id },
|
||||||
data: {
|
data: {
|
||||||
status: 'SUSPENDED',
|
status: 'SUSPENDED',
|
||||||
failureCount,
|
|
||||||
suspendedAt: new Date(),
|
|
||||||
//suspensionReason: `Payment failed ${maxRetries} times`,
|
//suspensionReason: `Payment failed ${maxRetries} times`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user