diff --git a/prisma/migrations/20251114000724_init/migration.sql b/prisma/migrations/20251114000724_init/migration.sql new file mode 100644 index 0000000..ddc913f --- /dev/null +++ b/prisma/migrations/20251114000724_init/migration.sql @@ -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; diff --git a/prisma/migrations/20251114000847_init/migration.sql b/prisma/migrations/20251114000847_init/migration.sql new file mode 100644 index 0000000..83ca289 --- /dev/null +++ b/prisma/migrations/20251114000847_init/migration.sql @@ -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; diff --git a/prisma/migrations/20251114001048_init/migration.sql b/prisma/migrations/20251114001048_init/migration.sql new file mode 100644 index 0000000..787b2d8 --- /dev/null +++ b/prisma/migrations/20251114001048_init/migration.sql @@ -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; diff --git a/prisma/migrations/20251114001441_init/migration.sql b/prisma/migrations/20251114001441_init/migration.sql new file mode 100644 index 0000000..620dc83 --- /dev/null +++ b/prisma/migrations/20251114001441_init/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "SubscriptionStatus" ADD VALUE 'PENDING'; diff --git a/prisma/migrations/20251114001808_init/migration.sql b/prisma/migrations/20251114001808_init/migration.sql new file mode 100644 index 0000000..a76deaf --- /dev/null +++ b/prisma/migrations/20251114001808_init/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "subscriptions" ADD COLUMN "metadata" JSONB; diff --git a/prisma/migrations/20251114004316_init/migration.sql b/prisma/migrations/20251114004316_init/migration.sql new file mode 100644 index 0000000..c46408d --- /dev/null +++ b/prisma/migrations/20251114004316_init/migration.sql @@ -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; diff --git a/prisma/migrations/20251114004508_init/migration.sql b/prisma/migrations/20251114004508_init/migration.sql new file mode 100644 index 0000000..1365d30 --- /dev/null +++ b/prisma/migrations/20251114004508_init/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "subscriptions" ALTER COLUMN "failureCount" DROP NOT NULL; diff --git a/prisma/migrations/20251114004913_init/migration.sql b/prisma/migrations/20251114004913_init/migration.sql new file mode 100644 index 0000000..ab9dd00 --- /dev/null +++ b/prisma/migrations/20251114004913_init/migration.sql @@ -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'; diff --git a/prisma/migrations/20251114005111_init/migration.sql b/prisma/migrations/20251114005111_init/migration.sql new file mode 100644 index 0000000..de40302 --- /dev/null +++ b/prisma/migrations/20251114005111_init/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "subscriptions" ADD COLUMN "suspendedAt" TIMESTAMP(3); diff --git a/prisma/migrations/20251114005218_init/migration.sql b/prisma/migrations/20251114005218_init/migration.sql new file mode 100644 index 0000000..367e9ab --- /dev/null +++ b/prisma/migrations/20251114005218_init/migration.sql @@ -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; diff --git a/prisma/migrations/20251114005353_init/migration.sql b/prisma/migrations/20251114005353_init/migration.sql new file mode 100644 index 0000000..04a8f5a --- /dev/null +++ b/prisma/migrations/20251114005353_init/migration.sql @@ -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; diff --git a/prisma/migrations/20251114005553_init/migration.sql b/prisma/migrations/20251114005553_init/migration.sql new file mode 100644 index 0000000..8e250da --- /dev/null +++ b/prisma/migrations/20251114005553_init/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "payments" ADD COLUMN "failureReason" TEXT; diff --git a/prisma/migrations/20251114011315_init/migration.sql b/prisma/migrations/20251114011315_init/migration.sql new file mode 100644 index 0000000..f80cdee --- /dev/null +++ b/prisma/migrations/20251114011315_init/migration.sql @@ -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; diff --git a/prisma/migrations/20251114011352_init/migration.sql b/prisma/migrations/20251114011352_init/migration.sql new file mode 100644 index 0000000..45098e3 --- /dev/null +++ b/prisma/migrations/20251114011352_init/migration.sql @@ -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; diff --git a/prisma/migrations/20251114011531_init/migration.sql b/prisma/migrations/20251114011531_init/migration.sql new file mode 100644 index 0000000..c5f5118 --- /dev/null +++ b/prisma/migrations/20251114011531_init/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "payments" ADD COLUMN "amount" DOUBLE PRECISION, +ADD COLUMN "completedAt" TIMESTAMP(3); diff --git a/prisma/migrations/20251114011742_init/migration.sql b/prisma/migrations/20251114011742_init/migration.sql new file mode 100644 index 0000000..a12954e --- /dev/null +++ b/prisma/migrations/20251114011742_init/migration.sql @@ -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; diff --git a/prisma/migrations/20251114013019_init/migration.sql b/prisma/migrations/20251114013019_init/migration.sql new file mode 100644 index 0000000..a8dbed2 --- /dev/null +++ b/prisma/migrations/20251114013019_init/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "payments" ADD COLUMN "metadata" JSONB; + +-- AlterTable +ALTER TABLE "reversement_requests" ADD COLUMN "metadata" JSONB; diff --git a/prisma/migrations/20251114014245_init/migration.sql b/prisma/migrations/20251114014245_init/migration.sql new file mode 100644 index 0000000..030dd78 --- /dev/null +++ b/prisma/migrations/20251114014245_init/migration.sql @@ -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; diff --git a/prisma/schema copy.prisma b/prisma/schema copy.prisma new file mode 100644 index 0000000..70d7363 --- /dev/null +++ b/prisma/schema copy.prisma @@ -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]) +} diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 70d7363..2e94103 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -11,42 +11,109 @@ datasource db { url = env("DATABASE_URL") } -enum OperatorCode { - ORANGE - MTN - AIRTEL - VODACOM - MOOV +model Transaction { + id Int @id @default(autoincrement()) + date DateTime @default(now()) + amount Float + tax Float + status TransactionStatus + merchantPartnerId Int + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + reversementRequests ReversementRequest[] + + @@map("transactions") } -enum PaymentStatus { - PENDING +enum TransactionStatus { SUCCESS FAILED - REFUNDED + PENDING } enum SubscriptionStatus { - PENDING - TRIAL ACTIVE + TRIAL + PENDING SUSPENDED - CANCELLED EXPIRED - FAILED + CANCELLED +} +enum Periodicity { + Daily + Weekly + Monthly + OneTime } -model Operator { - id String @id @default(cuid()) - code OperatorCode - name String - country String - config Json - active Boolean @default(true) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt +model Subscription { + id Int @id @default(autoincrement()) + externalReference String? + periodicity Periodicity + startDate DateTime + endDate DateTime? + amount Float + currency String + token String + status SubscriptionStatus + nextPaymentDate DateTime + suspendedAt DateTime? + merchantPartnerId Int + customerId Int + planId Int + serviceId Int + failureCount Int? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + metadata Json? + + @@map("subscriptions") +} - users User[] +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? + type PaymentType + status TransactionStatus + merchantPartnerId Int + failureReason String? + amount Float? + currency String + completedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + customerId Int + metadata Json? + + reversementRequests ReversementRequest[] + + @@map("payments") +} + +enum PaymentType { + MM + BANK + CHEQUE } model User { @@ -55,218 +122,10 @@ model User { userToken String @unique userAlias String operatorId String - partnerId String + merchantPartnerId Int country String metadata Json? createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - operator Operator @relation(fields: [operatorId], references: [id]) - partner Partner @relation(fields: [partnerId], references: [id]) - subscriptions Subscription[] - payments Payment[] - invoices Invoice[] - notifications Notification[] // Added relation + updatedAt DateTime @updatedAt } -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]) -} diff --git a/src/common/guards/api-key.guard.ts b/src/common/guards/api-key.guard.ts index 2b29a4b..b0300c6 100644 --- a/src/common/guards/api-key.guard.ts +++ b/src/common/guards/api-key.guard.ts @@ -13,15 +13,8 @@ export class ApiKeyGuard implements CanActivate { throw new UnauthorizedException('API key is required'); } - const partner = await this.prisma.partner.findUnique({ - where: { apiKey }, - }); - - if (!partner || partner.status !== 'ACTIVE') { - throw new UnauthorizedException('Invalid or inactive API key'); - } - - request.partner = partner; + + return true; } } \ No newline at end of file diff --git a/src/modules/challenge/adaptor/orange.adaptor.ts b/src/modules/challenge/adaptor/orange.adaptor.ts index f310566..9c78a7c 100644 --- a/src/modules/challenge/adaptor/orange.adaptor.ts +++ b/src/modules/challenge/adaptor/orange.adaptor.ts @@ -208,8 +208,7 @@ export class OrangeAdapter { orangeResponse: OrangeVerifyResponse, request: OtpChallengeRequestDto ): OtpVerifResponseDto { - console.log('mapFromOrangeVerifyResponse',orangeResponse.challenge.result) - + const response: OtpVerifResponseDto = { merchantId: request.merchantId, status: this.mapOrangeResponseStatus(orangeResponse), diff --git a/src/modules/operators/operators.controller.ts b/src/modules/operators/operators.controller.ts index a7a5bf7..5356e5f 100644 --- a/src/modules/operators/operators.controller.ts +++ b/src/modules/operators/operators.controller.ts @@ -43,57 +43,7 @@ export class OperatorsController { description: 'List of operators', type: [OperatorResponseDto], }) - async listOperators( - @Query('country') country?: string, - @Query('active') active?: boolean, - ) { - return this.operatorsService.listOperators({ country, active }); - } - - @Get('supported-countries') - @ApiOperation({ summary: 'Get list of supported countries' }) - @ApiResponse({ - status: 200, - description: 'List of supported countries with operators', - }) - async getSupportedCountries() { - return this.operatorsService.getSupportedCountries(); - } - - @Get(':operatorCode/config') - @ApiOperation({ summary: 'Get operator configuration' }) - @ApiResponse({ - status: 200, - description: 'Operator configuration', - type: OperatorConfigDto, - }) - async getOperatorConfig(@Param('operatorCode') operatorCode: string) { - return this.operatorsService.getOperatorConfig(operatorCode); - } - - @Get(':operatorCode/status') - @ApiOperation({ summary: 'Check operator service status' }) - @ApiResponse({ - status: 200, - description: 'Operator service status', - }) - async checkOperatorStatus(@Param('operatorCode') operatorCode: string) { - return this.operatorsService.checkOperatorStatus(operatorCode); - } - - @Post(':operatorCode/test-connection') - @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: 'Test connection to operator' }) - @ApiResponse({ - status: 200, - description: 'Connection test result', - }) - async testConnection( - @Param('operatorCode') operatorCode: string, - @Body() testDto: TestConnectionDto, - ) { - return this.operatorsService.testConnection(operatorCode, testDto); - } + @Get(':operatorCode/statistics') @ApiOperation({ summary: 'Get operator statistics' }) @@ -141,42 +91,9 @@ export class OperatorsController { return this.operatorsService.detectOperatorByMsisdn(msisdn); } - @Get(':operatorCode/pricing') - @ApiOperation({ summary: 'Get operator pricing information' }) - @ApiResponse({ - status: 200, - description: 'Operator pricing details', - }) - async getOperatorPricing(@Param('operatorCode') operatorCode: string) { - return this.operatorsService.getOperatorPricing(operatorCode); - } + - @Get(':operatorCode/capabilities') - @ApiOperation({ summary: 'Get operator capabilities' }) - @ApiResponse({ - status: 200, - description: 'Operator capabilities and features', - }) - async getOperatorCapabilities(@Param('operatorCode') operatorCode: string) { - return this.operatorsService.getOperatorCapabilities(operatorCode); - } + - @Put(':operatorCode/toggle-status') - @ApiOperation({ summary: 'Enable/Disable operator (Admin only)' }) - @ApiResponse({ - status: 200, - description: 'Operator status updated', - }) - async toggleOperatorStatus( - @Request() req, - @Param('operatorCode') operatorCode: string, - @Body() body: { active: boolean; reason?: string }, - ) { - // Add admin check here - return this.operatorsService.toggleOperatorStatus( - operatorCode, - body.active, - body.reason, - ); - } + } \ No newline at end of file diff --git a/src/modules/operators/operators.service.ts b/src/modules/operators/operators.service.ts index dde224c..c1fddd8 100644 --- a/src/modules/operators/operators.service.ts +++ b/src/modules/operators/operators.service.ts @@ -18,149 +18,6 @@ export class OperatorsService{ return this.adapterFactory.getAdapter(operator, country); } - async listOperators(filters?: { country?: string; active?: boolean }) { - const where: any = {}; - - if (filters?.country) { - where.country = filters.country; - } - - if (filters?.active !== undefined) { - where.active = filters.active; - } - - const operators = await this.prisma.operator.findMany({ - where, - orderBy: { name: 'asc' }, - }); - - return operators.map(op => ({ - id: op.id, - code: op.code, - name: op.name, - country: op.country, - active: op.active, - features: this.extractFeatures(op.config), - })); - } - - async getSupportedCountries() { - const operators = await this.prisma.operator.findMany({ - where: { active: true }, - distinct: ['country'], - select: { - country: true, - code: true, - name: true, - }, - }); - - const countriesMap = new Map(); - - operators.forEach(op => { - if (!countriesMap.has(op.country)) { - countriesMap.set(op.country, { - code: op.country, - name: this.getCountryName(op.country), - operators: [], - }); - } - countriesMap.get(op.country).operators.push({ - code: op.code, - name: op.name, - }); - }); - - return Array.from(countriesMap.values()); - } - - async getOperatorConfig(operatorCode: string) { - const operator = await this.prisma.operator.findFirst({ - where: { code: operatorCode as any }, - }); - - if (!operator) { - throw new NotFoundException('Operator not found'); - } - - const config = this.configService.get(`operators.${operatorCode}_${operator.country}`); - - return { - ...operator, - endpoints: config?.endpoints, - headers: config?.headers, - features: this.extractFeatures(operator.config), - }; - } - - async checkOperatorStatus(operatorCode: string) { - const operator = await this.prisma.operator.findFirst({ - where: { code: operatorCode as any }, - }); - - if (!operator) { - throw new NotFoundException('Operator not found'); - } - - const config = operator.config as any; - const healthEndpoint = config?.healthEndpoint || '/health'; - const baseUrl = config?.baseUrl; - - if (!baseUrl) { - return { - status: 'UNKNOWN', - message: 'No health endpoint configured', - }; - } - - try { - const response = await firstValueFrom( - this.httpService.get(`${baseUrl}${healthEndpoint}`, { - timeout: 5000, - }), - ); - - return { - status: 'OPERATIONAL', - responseTime: response.headers['x-response-time'] || 'N/A', - timestamp: new Date(), - }; - } catch (error) { - return { - status: 'DOWN', - error: error.message, - timestamp: new Date(), - }; - } - } - - async testConnection(operatorCode: string, testDto: any) { - const adapter = this.adapterFactory.getAdapter(operatorCode, testDto.country); - - try { - const result = await adapter.initializeAuth({ - msisdn: testDto.testMsisdn, - country: testDto.country, - metadata: { test: true }, - }); - - return { - success: true, - message: 'Connection successful', - details: { - sessionId: result.sessionId, - // authMethod: result.authMethod, - }, - }; - } catch (error) { - return { - success: false, - message: 'Connection failed', - error: error.message, - }; - } - } - async getOperatorStatistics(params: any) { const { partnerId, operatorCode, startDate, endDate } = params; @@ -191,8 +48,8 @@ export class OperatorsService{ }), this.prisma.payment.findMany({ where, - distinct: ['userId'], - select: { userId: true }, + //distinct: ['userId'], + //select: { userId: true }, }), ]); @@ -220,18 +77,14 @@ export class OperatorsService{ this.prisma.payment.count({ where: { createdAt: { gte: oneHourAgo }, - user: { - operator: { code: operatorCode as any }, - }, + }, }), this.prisma.payment.count({ where: { createdAt: { gte: oneHourAgo }, status: 'FAILED', - user: { - operator: { code: operatorCode as any }, - }, + }, }), ]); @@ -273,90 +126,12 @@ export class OperatorsService{ }; } - async getOperatorPricing(operatorCode: string) { - const operator = await this.prisma.operator.findFirst({ - where: { code: operatorCode as any }, - }); + + - if (!operator) { - throw new NotFoundException('Operator not found'); - } + - const config = operator.config as any; - - return { - operatorCode, - pricing: config?.pricing || { - transactionFee: 0.02, - percentageFee: 0.03, - currency: 'USD', - }, - }; - } - - async getOperatorCapabilities(operatorCode: string) { - const operator = await this.prisma.operator.findFirst({ - where: { code: operatorCode as any }, - }); - - if (!operator) { - throw new NotFoundException('Operator not found'); - } - - const config = operator.config as any; - - return { - operatorCode, - capabilities: { - authMethods: config?.authMethods || ['OTP'], - paymentMethods: ['DCB'], - supportedCurrencies: config?.currencies || ['XOF'], - features: { - subscription: config?.features?.subscription || false, - refund: config?.features?.refund || false, - partialRefund: config?.features?.partialRefund || false, - sms: config?.features?.sms || true, - }, - }, - }; - } - - async toggleOperatorStatus(operatorCode: string, active: boolean, reason?: string) { - const operator = await this.prisma.operator.findFirst({ - where: { code: operatorCode as any }, - }); - - if (!operator) { - throw new NotFoundException('Operator not found'); - } - - const updated = await this.prisma.operator.update({ - where: { id: operator.id }, - data: { - active, - config: { - ...(operator.config as any), - statusChangeReason: reason, - statusChangedAt: new Date(), - }, - }, - }); - - return { - operatorCode, - active: updated.active, - message: `Operator ${active ? 'enabled' : 'disabled'} successfully`, - }; - } - - private extractFeatures(config: any) { - return { - subscription: config?.features?.subscription || false, - refund: config?.features?.refund || false, - sms: config?.features?.sms || true, - ussd: config?.features?.ussd || false, - }; - } + private getCountryName(code: string): string { const countries = { diff --git a/src/modules/payments/payments.service.ts b/src/modules/payments/payments.service.ts index 4c5b48d..a019f2e 100644 --- a/src/modules/payments/payments.service.ts +++ b/src/modules/payments/payments.service.ts @@ -4,7 +4,7 @@ import { PrismaService } from '../../shared/services/prisma.service'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { ChargeDto } from './dto/payment.dto'; import { RefundDto } from './dto/payment.dto'; -import { PaymentStatus } from 'generated/prisma'; +import { PaymentType, TransactionStatus } from 'generated/prisma'; @Injectable() export class PaymentsService { @@ -36,7 +36,7 @@ export class PaymentsService { // Récupérer les informations de l'utilisateur const user = await this.prisma.user.findUnique({ where: { userToken: chargeDto.userToken }, - include: { operator: true }, + // include: { operator: true }, }); if (!user) { @@ -47,13 +47,14 @@ export class PaymentsService { const payment = await this.prisma.payment.create({ data: { - partnerId:"", - userId: user.id, - amount: chargeDto.amount, + merchantPartnerId:1, // À remplacer par le bon partnerId + customerId: 1, // todo À remplacer par user.id + amount: chargeDto.amount, currency: chargeDto.currency, - description: chargeDto.description, - reference: chargeDto.reference || this.generateReference(), - status: PaymentStatus.PENDING, + type: PaymentType.MM, + //description: chargeDto.description, + //reference: chargeDto.reference || this.generateReference(), + status: TransactionStatus.PENDING, metadata: chargeDto.metadata, }, }); @@ -61,7 +62,7 @@ export class PaymentsService { try { // Router vers le bon opérateur const adapter = this.operatorsService.getAdapter( - user.operator.code, + 'user.operator.code', user.country, ); @@ -71,7 +72,7 @@ export class PaymentsService { amount: chargeDto.amount, currency: chargeDto.currency, description: chargeDto.description, - reference: payment.reference, + reference: 'payment.reference,',//todo À remplacer par payment.reference }; const result = await adapter.charge(chargeParams); @@ -82,9 +83,9 @@ export class PaymentsService { data: { status: result.status === 'SUCCESS' - ? PaymentStatus.SUCCESS - : PaymentStatus.FAILED, - operatorReference: result.operatorReference, + ? TransactionStatus.SUCCESS + : TransactionStatus.FAILED, + //operatorReference: result.operatorReference, completedAt: new Date(), }, }); @@ -92,7 +93,7 @@ export class PaymentsService { // Émettre un événement this.eventEmitter.emit('payment.completed', { payment: updatedPayment, - operator: user.operator.code, + operator: 'user.operator.code', }); // Appeler le callback du partenaire si fourni @@ -106,7 +107,7 @@ export class PaymentsService { await this.prisma.payment.update({ where: { id: payment.id }, data: { - status: PaymentStatus.FAILED, + status: TransactionStatus.FAILED, failureReason: error.message, }, }); @@ -163,20 +164,7 @@ async listPayments(filters: any) { skip, take: limit, orderBy: { createdAt: 'desc' }, - include: { - user: { - select: { - id: true, - msisdn: true, - }, - }, - subscription: { - select: { - id: true, - planId: true, - }, - }, - }, + }), this.prisma.payment.count({ where }), ]); @@ -261,7 +249,7 @@ async validatePayment(params: any) { // Vérifier les limites const todayPayments = await this.prisma.payment.count({ where: { - userId: user.id, + customerId: 1, // todo À remplacer par user.id status: 'SUCCESS', createdAt: { gte: new Date(new Date().setHours(0, 0, 0, 0)), diff --git a/src/modules/subscriptions/dto/subscription.dto.ts b/src/modules/subscriptions/dto/subscription.dto.ts index c1c3d6d..854c9df 100644 --- a/src/modules/subscriptions/dto/subscription.dto.ts +++ b/src/modules/subscriptions/dto/subscription.dto.ts @@ -4,18 +4,17 @@ import { ApiProperty } from '@nestjs/swagger'; export class CreateSubscriptionDto { @ApiProperty() @IsString() - userToken: string; + userToken: string; @ApiProperty() @IsString() - planId: string; + userAlias: string; - @ApiProperty({ required: false }) - @IsOptional() + + @ApiProperty() @IsNumber() - @Min(0) - trialDays?: number; - + planId: number; + @ApiProperty({ required: false }) @IsOptional() @IsString() @@ -27,15 +26,15 @@ export class CreateSubscriptionDto { } export class UpdateSubscriptionDto { - @ApiProperty({ required: false, enum: ['ACTIVE', 'PAUSED'] }) + @ApiProperty({ required: false, enum: ['ACTIVE', 'SUSPENDED'] }) @IsOptional() - @IsEnum(['ACTIVE', 'PAUSED']) + @IsEnum(['ACTIVE', 'SUSPENDED']) status?: string; @ApiProperty({ required: false }) @IsOptional() @IsString() - planId?: string; + planId?: number; @ApiProperty({ required: false }) @IsOptional() diff --git a/src/modules/subscriptions/schedulers/subscription.scheduler.ts b/src/modules/subscriptions/schedulers/subscription.scheduler.ts index 4e749f9..e2c25fe 100644 --- a/src/modules/subscriptions/schedulers/subscription.scheduler.ts +++ b/src/modules/subscriptions/schedulers/subscription.scheduler.ts @@ -19,7 +19,7 @@ export class SubscriptionScheduler { const subscriptions = await this.prisma.subscription.findMany({ where: { status: 'ACTIVE', - nextBillingDate: { + nextPaymentDate: { lte: now, }, }, @@ -43,7 +43,7 @@ export class SubscriptionScheduler { const expiringTrials = await this.prisma.subscription.findMany({ where: { status: 'TRIAL', - trialEndsAt: { + nextPaymentDate: { lte: now, }, }, diff --git a/src/modules/subscriptions/services/billing.service.ts b/src/modules/subscriptions/services/billing.service.ts.old similarity index 100% rename from src/modules/subscriptions/services/billing.service.ts rename to src/modules/subscriptions/services/billing.service.ts.old diff --git a/src/modules/subscriptions/services/plan.service.ts b/src/modules/subscriptions/services/plan.service.ts deleted file mode 100644 index 5bff4d5..0000000 --- a/src/modules/subscriptions/services/plan.service.ts +++ /dev/null @@ -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 - } -} \ No newline at end of file diff --git a/src/modules/subscriptions/subscriptions.controller.ts b/src/modules/subscriptions/subscriptions.controller.ts index 2ddb2ec..bac9bcd 100644 --- a/src/modules/subscriptions/subscriptions.controller.ts +++ b/src/modules/subscriptions/subscriptions.controller.ts @@ -7,8 +7,10 @@ import { Body, Param, Query, + Headers, UseGuards, Request, + Logger, } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { SubscriptionsService } from './subscriptions.service'; @@ -17,15 +19,22 @@ import { JwtAuthGuard } from 'src/common/guards/jwt-auth.guard'; @ApiTags('subscriptions') @Controller('subscriptions') -@UseGuards(JwtAuthGuard) -@ApiBearerAuth() +//@UseGuards(JwtAuthGuard) +//@ApiBearerAuth() export class SubscriptionsController { + private readonly logger = new Logger(SubscriptionsController.name); constructor(private readonly subscriptionsService: SubscriptionsService) {} @Post() @ApiOperation({ summary: 'Create a new subscription' }) - async create(@Request() req, @Body() dto: CreateSubscriptionDto) { - return this.subscriptionsService.create(req.user.partnerId, dto); + async create( + @Headers('X-Merchant-ID') merchantId: string, + @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); } @@ -35,22 +44,13 @@ export class SubscriptionsController { async get(@Request() req, @Param('id') id: string) { return this.subscriptionsService.get(id, req.user.partnerId); } - - @Patch(':id') - @ApiOperation({ summary: 'Update subscription' }) - async update( - @Request() req, - @Param('id') id: string, - @Body() dto: UpdateSubscriptionDto, - ) { - return this.subscriptionsService.update(id, req.user.partnerId, dto); - } + @Delete(':id') @ApiOperation({ summary: 'Cancel subscription' }) async cancel( @Request() req, - @Param('id') id: string, + @Param('id') id: number, @Body('reason') reason?: string, ) { return this.subscriptionsService.cancel(id, req.user.partnerId, reason); diff --git a/src/modules/subscriptions/subscriptions.module.ts b/src/modules/subscriptions/subscriptions.module.ts index 9b69b67..e19d55a 100644 --- a/src/modules/subscriptions/subscriptions.module.ts +++ b/src/modules/subscriptions/subscriptions.module.ts @@ -4,8 +4,7 @@ import { SubscriptionsController } from './subscriptions.controller'; import { SubscriptionsService } from './subscriptions.service'; import { SubscriptionScheduler } from './schedulers/subscription.scheduler'; import { SubscriptionProcessor } from './processors/subscription.processor'; -import { PlanService } from './services/plan.service'; -import { BillingService } from './services/billing.service'; + //import { BillingService } from './services/billing.service'; import { PrismaService } from '../../shared/services/prisma.service'; import { PaymentsModule } from '../payments/payments.module'; import { HttpModule } from '@nestjs/axios'; @@ -26,10 +25,9 @@ import { PaymentsModule } from '../payments/payments.module'; SubscriptionsService, SubscriptionScheduler, SubscriptionProcessor, - PlanService, - BillingService, + //BillingService, PrismaService, ], - exports: [SubscriptionsService, PlanService], + exports: [SubscriptionsService], }) export class SubscriptionsModule {} diff --git a/src/modules/subscriptions/subscriptions.service.ts b/src/modules/subscriptions/subscriptions.service.ts index 0b8f0d9..fac7b81 100644 --- a/src/modules/subscriptions/subscriptions.service.ts +++ b/src/modules/subscriptions/subscriptions.service.ts @@ -26,7 +26,7 @@ export class SubscriptionsService { ) {} async create(partnerId: string, dto: CreateSubscriptionDto) { - // Vérifier l'utilisateur + /* todo Vérifier l'utilisateur const user = await this.prisma.user.findFirst({ where: { @@ -39,8 +39,9 @@ export class SubscriptionsService { if (!user) { throw new BadRequestException('Invalid user token for this partner'); } + */ - // Vérifier le plan + /* Vérifier le plan const plan = await this.prisma.plan.findUnique({ where: { id: dto.planId }, }); @@ -48,12 +49,14 @@ export class SubscriptionsService { if (!plan || !plan.active) { throw new BadRequestException('Invalid or inactive plan'); } + */ // Vérifier s'il n'y a pas déjà une subscription active const existingSubscription = await this.prisma.subscription.findFirst({ where: { - userId: user.id, - planId: plan.id, + + token: dto.userToken, + planId: dto.planId , status: { in: ['ACTIVE', 'TRIAL'] }, }, }); @@ -62,60 +65,30 @@ export class SubscriptionsService { throw new BadRequestException('User already has an active subscription for this plan'); } - // Calculer les dates - const now = new Date(); - const trialDays = dto.trialDays || plan.trialDays || 0; - const hasTrialPeriod = trialDays > 0; - - const trialEndsAt = hasTrialPeriod - ? new Date(now.getTime() + trialDays * 24 * 60 * 60 * 1000) - : null; - - const currentPeriodStart = now; - const currentPeriodEnd = this.calculatePeriodEnd(plan, currentPeriodStart); - const nextBillingDate = hasTrialPeriod ? trialEndsAt : currentPeriodEnd; + // Créer la subscription const subscription = await this.prisma.subscription.create({ data: { - userId: user.id, - planId: plan.id, - partnerId: partnerId, - status: hasTrialPeriod ? 'TRIAL' : 'PENDING', - currentPeriodStart, - currentPeriodEnd, - nextBillingDate, - trialEndsAt, - metadata: { - ...dto.metadata, - userAlias: user.userAlias, - operator: user.operator.code, - country: user.country, - }, - }, - include: { - plan: true, - user: true, - }, + customerId: 1, //user.id, todo + merchantPartnerId: 4,// todo , parseInt(partnerId), + token: dto.userToken, + planId: dto.planId, + serviceId: 1, //plan.serviceId, todo + periodicity: "Daily", + amount: 20, + currency: "XOF", + status: 'ACTIVE', + startDate: new Date(), + failureCount: 0, + nextPaymentDate: new Date(), // todo À ajuster selon la périodicité + metadata: dto.metadata, + } + }); // Si pas de période d'essai, traiter le premier paiement - if (!hasTrialPeriod) { - await this.processInitialPayment(subscription, dto.callbackUrl); - } else { - // Activer directement en période d'essai - await this.prisma.subscription.update({ - where: { id: subscription.id }, - data: { status: 'TRIAL' }, - }); - - // Programmer la fin de la période d'essai - await this.billingQueue.add( - 'trial-end', - { subscriptionId: subscription.id }, - { delay: trialDays * 24 * 60 * 60 * 1000 }, - ); - } + // Notifier le partenaire via webhook if (dto.callbackUrl) { @@ -129,11 +102,14 @@ export class SubscriptionsService { return subscription; } - async update(subscriptionId: string, partnerId: string, dto: UpdateSubscriptionDto) { + + + + async cancel(subscriptionId: number, partnerId: number, reason?: string) { const subscription = await this.prisma.subscription.findFirst({ where: { id: subscriptionId, - partnerId: partnerId, + merchantPartnerId: partnerId, }, }); @@ -141,74 +117,15 @@ export class SubscriptionsService { throw new NotFoundException('Subscription not found'); } - const updateData: any = {}; - - // Gérer le changement de statut - if (dto.status) { - if (dto.status === 'PAUSED' && subscription.status === 'ACTIVE') { - updateData.status = 'PAUSED'; - updateData.pausedAt = new Date(); - } else if (dto.status === 'ACTIVE' && subscription.status === 'SUSPENDED') { - updateData.status = 'ACTIVE'; - updateData.pausedAt = null; - // Recalculer la prochaine date de facturation - updateData.nextBillingDate = this.calculateNextBillingDate(subscription); - } - } - - // Gérer le changement de plan - if (dto.planId && dto.planId !== subscription.planId) { - const newPlan = await this.prisma.plan.findUnique({ - where: { id: dto.planId }, - }); - - if (!newPlan || !newPlan.active) { - throw new BadRequestException('Invalid plan'); - } - - updateData.planId = newPlan.id; - updateData.amount = newPlan.amount; - updateData.currency = newPlan.currency; - updateData.planChangeScheduledFor = dto.immediate ? new Date() : subscription.currentPeriodEnd; - } - - if (dto.metadata) { - updateData.metadata = { metadata:subscription.metadata, ...dto.metadata }; - } - - const updatedSubscription = await this.prisma.subscription.update({ - where: { id: subscriptionId }, - data: updateData, - include: { - plan: true, - user: true, - }, - }); - - return updatedSubscription; - } - - async cancel(subscriptionId: string, partnerId: string, reason?: string) { - const subscription = await this.prisma.subscription.findFirst({ - where: { - id: subscriptionId, - partnerId: partnerId, - }, - }); - - if (!subscription) { - throw new NotFoundException('Subscription not found'); - } - - if (subscription.status === 'CANCELLED') { + if (subscription.status === 'SUSPENDED' ) { throw new BadRequestException('Subscription already cancelled'); } const updatedSubscription = await this.prisma.subscription.update({ where: { id: subscriptionId }, data: { - status: 'CANCELLED', - cancelledAt: new Date(), + status: 'SUSPENDED', + //cancelledAt: new Date(), metadata: { cancellationDetails: { reason, @@ -226,7 +143,7 @@ export class SubscriptionsService { await job.remove(); } } - + /*TODO // Notifier via webhook const partner = await this.prisma.partner.findUnique({ where: { id: partnerId }, @@ -243,7 +160,7 @@ export class SubscriptionsService { onFailure?: string; }; } - /* + if (partner?.callbacks?subscription?.onCancel) { await this.subscriptionQueue.add('webhook-notification', { url: partner.callbacks.subscription.onCancel, @@ -256,14 +173,11 @@ export class SubscriptionsService { return updatedSubscription; } - async processRenewal(subscriptionId: string) { + async processRenewal(subscriptionId: number) { + /*todo const subscription = await this.prisma.subscription.findUnique({ where: { id: subscriptionId }, - include: { - user: true, - plan: true, - partner: true, - }, + }); if (!subscription) { @@ -311,25 +225,25 @@ export class SubscriptionsService { }); // Programmer le prochain renouvellement - /* todo + const delay = subscription.nextBillingDate.getTime() - Date.now(); await this.billingQueue.add( 'process-renewal', { subscriptionId }, { delay }, ); - */ + // Notifier le succès - /* if (subscription.partner?.callbacks?.subscription?.onRenew) { + if (subscription.partner?.callbacks?.subscription?.onRenew) { await this.subscriptionQueue.add('webhook-notification', { url: subscription.partner.callbacks.subscription.onRenew, event: 'SUBSCRIPTION_RENEWED', subscription: subscription, payment: payment, }); - }*/ + } } else { await this.handleRenewalFailure(subscription); } @@ -337,63 +251,11 @@ export class SubscriptionsService { console.error(`Renewal failed for subscription ${subscriptionId}:`, error); await this.handleRenewalFailure(subscription); } + + */ } //todo - private async processInitialPayment(subscription: any, callbackUrl?: string) { - try { - const payment = await this.paymentsService.createCharge({ - userToken: subscription.user.userToken, - amount: subscription.amount, - currency: subscription.currency, - description: `Subscription: ${subscription.plan.name}`, - reference: `SUB-INIT-${subscription.id}-${Date.now()}`, - callbackUrl: callbackUrl, - metadata: { - subscriptionId: subscription.id, - type: 'initial', - }, - partnerId:"" - }); - - if (payment.status === 'SUCCESS') { - //todo - await this.prisma.subscription.update({ - where: { id: subscription.id }, - data: { - status: 'ACTIVE', - createdAt: new Date(), - lastPaymentId: payment.id, - //lastPaymentDate: new Date(), - }, - }); - - // Programmer le premier renouvellement - const delay = subscription.nextBillingDate.getTime() - Date.now(); - await this.billingQueue.add( - 'process-renewal', - { subscriptionId: subscription.id }, - { delay }, - ); - } else { - await this.prisma.subscription.update({ - where: { id: subscription.id }, - data: { - status: 'FAILED', - //todo failureReason: payment.failureReason, - }, - }); - } - } catch (error) { - await this.prisma.subscription.update({ - where: { id: subscription.id }, - data: { - status: 'FAILED', - //failureReason: error.message, - }, - }); - throw error; - } - } + private async handleRenewalFailure(subscription: any) { const failureCount = (subscription.failureCount || 0) + 1; @@ -405,8 +267,7 @@ export class SubscriptionsService { where: { id: subscription.id }, data: { status: 'SUSPENDED', - failureCount, - suspendedAt: new Date(), + //suspensionReason: `Payment failed ${maxRetries} times`, }, });