subscription management
This commit is contained in:
parent
f7820ddccf
commit
767201ec06
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;
|
||||
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,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)
|
||||
model Subscription {
|
||||
id Int @id @default(autoincrement())
|
||||
externalReference String?
|
||||
periodicity Periodicity
|
||||
startDate DateTime
|
||||
endDate DateTime?
|
||||
amount Float
|
||||
currency String
|
||||
token String
|
||||
status SubscriptionStatus
|
||||
nextPaymentDate DateTime
|
||||
suspendedAt DateTime?
|
||||
merchantPartnerId Int
|
||||
customerId Int
|
||||
planId Int
|
||||
serviceId Int
|
||||
failureCount Int?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
metadata Json?
|
||||
|
||||
users User[]
|
||||
@@map("subscriptions")
|
||||
}
|
||||
|
||||
model ReversementRequest {
|
||||
id Int @id @default(autoincrement())
|
||||
externalReference String?
|
||||
startDate DateTime
|
||||
endDate DateTime
|
||||
amount Float
|
||||
tax Float
|
||||
status TransactionStatus
|
||||
transactionId Int
|
||||
paymentId Int?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
metadata Json?
|
||||
|
||||
transaction Transaction @relation(fields: [transactionId], references: [id], onDelete: Cascade)
|
||||
payment Payment? @relation(fields: [paymentId], references: [id])
|
||||
|
||||
@@map("reversement_requests")
|
||||
}
|
||||
|
||||
model Payment {
|
||||
id Int @id @default(autoincrement())
|
||||
externalReference String?
|
||||
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
|
||||
}
|
||||
|
||||
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])
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -208,7 +208,6 @@ export class OrangeAdapter {
|
||||
orangeResponse: OrangeVerifyResponse,
|
||||
request: OtpChallengeRequestDto
|
||||
): OtpVerifResponseDto {
|
||||
console.log('mapFromOrangeVerifyResponse',orangeResponse.challenge.result)
|
||||
|
||||
const response: OtpVerifResponseDto = {
|
||||
merchantId: request.merchantId,
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
@ -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 = {
|
||||
|
||||
@ -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,
|
||||
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)),
|
||||
|
||||
@ -8,13 +8,12 @@ export class CreateSubscriptionDto {
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
planId: string;
|
||||
userAlias: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
|
||||
@ApiProperty()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
trialDays?: number;
|
||||
planId: number;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@ -27,15 +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()
|
||||
|
||||
@ -19,7 +19,7 @@ export class SubscriptionScheduler {
|
||||
const subscriptions = await this.prisma.subscription.findMany({
|
||||
where: {
|
||||
status: 'ACTIVE',
|
||||
nextBillingDate: {
|
||||
nextPaymentDate: {
|
||||
lte: now,
|
||||
},
|
||||
},
|
||||
@ -43,7 +43,7 @@ export class SubscriptionScheduler {
|
||||
const expiringTrials = await this.prisma.subscription.findMany({
|
||||
where: {
|
||||
status: 'TRIAL',
|
||||
trialEndsAt: {
|
||||
nextPaymentDate: {
|
||||
lte: now,
|
||||
},
|
||||
},
|
||||
|
||||
@ -1,337 +0,0 @@
|
||||
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../../shared/services/prisma.service';
|
||||
import { CreatePlanDto, UpdatePlanDto } from '../dto/plan.dto';
|
||||
import { Prisma } from 'generated/prisma';
|
||||
|
||||
@Injectable()
|
||||
export class PlanService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async create(partnerId: string, dto: CreatePlanDto) {
|
||||
// Vérifier si un plan avec le même code existe déjà
|
||||
const existingPlan = await this.prisma.plan.findFirst({
|
||||
where: {
|
||||
code: dto.code,
|
||||
partnerId: partnerId,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingPlan) {
|
||||
throw new BadRequestException('Plan with this code already exists');
|
||||
}
|
||||
|
||||
const plan = await this.prisma.plan.create({
|
||||
data: {
|
||||
partnerId: partnerId,
|
||||
code: dto.code,
|
||||
name: dto.name,
|
||||
description: dto.description,
|
||||
amount: dto.amount,
|
||||
currency: dto.currency,
|
||||
interval: dto.interval,
|
||||
intervalCount: dto.intervalCount || 1,
|
||||
trialDays: dto.trialDays || 0,
|
||||
features: dto.features || [],
|
||||
limits: dto.limits || {},
|
||||
metadata: dto.metadata || {},
|
||||
active: true,
|
||||
},
|
||||
});
|
||||
|
||||
return plan;
|
||||
}
|
||||
|
||||
async findAll(partnerId: string, filters?: {
|
||||
active?: boolean;
|
||||
interval?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}) {
|
||||
const where: Prisma.PlanWhereInput = {
|
||||
partnerId: partnerId,
|
||||
};
|
||||
|
||||
if (filters?.active !== undefined) {
|
||||
where.active = filters.active;
|
||||
}
|
||||
|
||||
if (filters?.interval) {
|
||||
where.interval = filters.interval;
|
||||
}
|
||||
|
||||
const page = filters?.page || 1;
|
||||
const limit = filters?.limit || 20;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const [plans, total] = await Promise.all([
|
||||
this.prisma.plan.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: limit,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
subscriptions: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
this.prisma.plan.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
data: plans,
|
||||
meta: {
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async findOne(planId: string, partnerId: string) {
|
||||
const plan = await this.prisma.plan.findFirst({
|
||||
where: {
|
||||
id: planId,
|
||||
partnerId: partnerId,
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
subscriptions: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!plan) {
|
||||
throw new NotFoundException('Plan not found');
|
||||
}
|
||||
|
||||
// Calculer les statistiques
|
||||
const stats = await this.getStatistics(planId);
|
||||
|
||||
return {
|
||||
...plan,
|
||||
statistics: stats,
|
||||
};
|
||||
}
|
||||
|
||||
async update(planId: string, partnerId: string, dto: UpdatePlanDto) {
|
||||
const plan = await this.prisma.plan.findFirst({
|
||||
where: {
|
||||
id: planId,
|
||||
partnerId: partnerId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!plan) {
|
||||
throw new NotFoundException('Plan not found');
|
||||
}
|
||||
|
||||
// Vérifier s'il y a des subscriptions actives
|
||||
const activeSubscriptions = await this.prisma.subscription.count({
|
||||
where: {
|
||||
planId: planId,
|
||||
status: { in: ['ACTIVE', 'TRIAL'] },
|
||||
},
|
||||
});
|
||||
|
||||
// Empêcher certaines modifications si des subscriptions actives
|
||||
if (activeSubscriptions > 0) {
|
||||
if (dto.amount !== undefined && dto.amount !== plan.amount) {
|
||||
throw new BadRequestException(
|
||||
'Cannot change amount while there are active subscriptions',
|
||||
);
|
||||
}
|
||||
if (dto.interval !== undefined && dto.interval !== plan.interval) {
|
||||
throw new BadRequestException(
|
||||
'Cannot change interval while there are active subscriptions',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const updatedPlan = await this.prisma.plan.update({
|
||||
where: { id: planId },
|
||||
data: {
|
||||
name: dto.name,
|
||||
description: dto.description,
|
||||
amount: dto.amount,
|
||||
currency: dto.currency,
|
||||
interval: dto.interval,
|
||||
intervalCount: dto.intervalCount,
|
||||
trialDays: dto.trialDays,
|
||||
features: dto.features,
|
||||
limits: dto.limits,
|
||||
metadata: dto.metadata,
|
||||
},
|
||||
});
|
||||
|
||||
return updatedPlan;
|
||||
}
|
||||
|
||||
async toggleStatus(planId: string, partnerId: string) {
|
||||
const plan = await this.prisma.plan.findFirst({
|
||||
where: {
|
||||
id: planId,
|
||||
partnerId: partnerId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!plan) {
|
||||
throw new NotFoundException('Plan not found');
|
||||
}
|
||||
|
||||
const updatedPlan = await this.prisma.plan.update({
|
||||
where: { id: planId },
|
||||
data: {
|
||||
active: !plan.active,
|
||||
},
|
||||
});
|
||||
|
||||
return updatedPlan;
|
||||
}
|
||||
|
||||
async delete(planId: string, partnerId: string) {
|
||||
const plan = await this.prisma.plan.findFirst({
|
||||
where: {
|
||||
id: planId,
|
||||
partnerId: partnerId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!plan) {
|
||||
throw new NotFoundException('Plan not found');
|
||||
}
|
||||
|
||||
// Vérifier s'il y a des subscriptions
|
||||
const subscriptionsCount = await this.prisma.subscription.count({
|
||||
where: { planId: planId },
|
||||
});
|
||||
|
||||
if (subscriptionsCount > 0) {
|
||||
throw new BadRequestException(
|
||||
'Cannot delete plan with existing subscriptions',
|
||||
);
|
||||
}
|
||||
|
||||
await this.prisma.plan.delete({
|
||||
where: { id: planId },
|
||||
});
|
||||
|
||||
return { message: 'Plan deleted successfully' };
|
||||
}
|
||||
|
||||
async duplicate(planId: string, partnerId: string, newCode: string) {
|
||||
const plan = await this.prisma.plan.findFirst({
|
||||
where: {
|
||||
id: planId,
|
||||
partnerId: partnerId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!plan) {
|
||||
throw new NotFoundException('Plan not found');
|
||||
}
|
||||
|
||||
const duplicatedPlan = await this.prisma.plan.create({
|
||||
data: {
|
||||
partnerId: partnerId,
|
||||
code: newCode,
|
||||
name: `${plan.name} (Copy)`,
|
||||
description: plan.description,
|
||||
amount: plan.amount,
|
||||
currency: plan.currency,
|
||||
interval: plan.interval,
|
||||
intervalCount: plan.intervalCount,
|
||||
trialDays: plan.trialDays,
|
||||
//features: plan.features,
|
||||
//limits: plan.limits,
|
||||
metadata: {metadata:plan.metadata, duplicatedFrom: plan.id },
|
||||
active: false, // Désactivé par défaut
|
||||
},
|
||||
});
|
||||
|
||||
return duplicatedPlan;
|
||||
}
|
||||
|
||||
private async getStatistics(planId: string) {
|
||||
const [
|
||||
totalSubscriptions,
|
||||
activeSubscriptions,
|
||||
trialSubscriptions,
|
||||
cancelledSubscriptions,
|
||||
revenue,
|
||||
avgLifetime,
|
||||
] = await Promise.all([
|
||||
this.prisma.subscription.count({
|
||||
where: { planId },
|
||||
}),
|
||||
this.prisma.subscription.count({
|
||||
where: { planId, status: 'ACTIVE' },
|
||||
}),
|
||||
this.prisma.subscription.count({
|
||||
where: { planId, status: 'TRIAL' },
|
||||
}),
|
||||
this.prisma.subscription.count({
|
||||
where: { planId, status: 'CANCELLED' },
|
||||
}),
|
||||
this.calculateRevenue(planId),
|
||||
this.calculateAverageLifetime(planId),
|
||||
]);
|
||||
|
||||
return {
|
||||
totalSubscriptions,
|
||||
activeSubscriptions,
|
||||
trialSubscriptions,
|
||||
cancelledSubscriptions,
|
||||
revenue,
|
||||
avgLifetime,
|
||||
churnRate: totalSubscriptions > 0
|
||||
? (cancelledSubscriptions / totalSubscriptions) * 100
|
||||
: 0,
|
||||
};
|
||||
}
|
||||
|
||||
private async calculateRevenue(planId: string) {
|
||||
const result = await this.prisma.payment.aggregate({
|
||||
where: {
|
||||
subscription: {
|
||||
planId: planId,
|
||||
},
|
||||
status: 'SUCCESS',
|
||||
},
|
||||
_sum: {
|
||||
amount: true,
|
||||
},
|
||||
});
|
||||
|
||||
return result._sum.amount || 0;
|
||||
}
|
||||
|
||||
private async calculateAverageLifetime(planId: string) {
|
||||
const subscriptions = await this.prisma.subscription.findMany({
|
||||
where: {
|
||||
planId,
|
||||
status: { in: ['CANCELLED', 'EXPIRED'] },
|
||||
},
|
||||
select: {
|
||||
createdAt: true,
|
||||
cancelledAt: true,
|
||||
suspendedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (subscriptions.length === 0) return 0;
|
||||
|
||||
const lifetimes = subscriptions.map(sub => {
|
||||
const endDate = sub.cancelledAt || sub.suspendedAt || new Date();
|
||||
return endDate.getTime() - sub.createdAt.getTime();
|
||||
});
|
||||
|
||||
const avgLifetime = lifetimes.reduce((a, b) => a + b, 0) / lifetimes.length;
|
||||
return Math.floor(avgLifetime / (1000 * 60 * 60 * 24)); // Retourner en jours
|
||||
}
|
||||
}
|
||||
@ -7,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);
|
||||
}
|
||||
|
||||
|
||||
@ -36,21 +45,12 @@ export class SubscriptionsController {
|
||||
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);
|
||||
|
||||
@ -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 {}
|
||||
|
||||
@ -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`,
|
||||
},
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user