subscription management

This commit is contained in:
Mamadou Khoussa [028918 DSI/DAC/DIF/DS] 2025-11-14 01:43:27 +00:00
parent f7820ddccf
commit 767201ec06
32 changed files with 771 additions and 1147 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

272
prisma/schema copy.prisma Normal file
View File

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

View File

@ -11,42 +11,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])
}

View File

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

View File

@ -208,7 +208,6 @@ export class OrangeAdapter {
orangeResponse: OrangeVerifyResponse,
request: OtpChallengeRequestDto
): OtpVerifResponseDto {
console.log('mapFromOrangeVerifyResponse',orangeResponse.challenge.result)
const response: OtpVerifResponseDto = {
merchantId: request.merchantId,

View File

@ -43,57 +43,7 @@ export class OperatorsController {
description: 'List of operators',
type: [OperatorResponseDto],
})
async listOperators(
@Query('country') country?: string,
@Query('active') active?: boolean,
) {
return this.operatorsService.listOperators({ country, active });
}
@Get('supported-countries')
@ApiOperation({ summary: 'Get list of supported countries' })
@ApiResponse({
status: 200,
description: 'List of supported countries with operators',
})
async getSupportedCountries() {
return this.operatorsService.getSupportedCountries();
}
@Get(':operatorCode/config')
@ApiOperation({ summary: 'Get operator configuration' })
@ApiResponse({
status: 200,
description: 'Operator configuration',
type: OperatorConfigDto,
})
async getOperatorConfig(@Param('operatorCode') operatorCode: string) {
return this.operatorsService.getOperatorConfig(operatorCode);
}
@Get(':operatorCode/status')
@ApiOperation({ summary: 'Check operator service status' })
@ApiResponse({
status: 200,
description: 'Operator service status',
})
async checkOperatorStatus(@Param('operatorCode') operatorCode: string) {
return this.operatorsService.checkOperatorStatus(operatorCode);
}
@Post(':operatorCode/test-connection')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Test connection to operator' })
@ApiResponse({
status: 200,
description: 'Connection test result',
})
async testConnection(
@Param('operatorCode') operatorCode: string,
@Body() testDto: TestConnectionDto,
) {
return this.operatorsService.testConnection(operatorCode, testDto);
}
@Get(':operatorCode/statistics')
@ApiOperation({ summary: 'Get operator statistics' })
@ -141,42 +91,9 @@ export class OperatorsController {
return this.operatorsService.detectOperatorByMsisdn(msisdn);
}
@Get(':operatorCode/pricing')
@ApiOperation({ summary: 'Get operator pricing information' })
@ApiResponse({
status: 200,
description: 'Operator pricing details',
})
async getOperatorPricing(@Param('operatorCode') operatorCode: string) {
return this.operatorsService.getOperatorPricing(operatorCode);
}
@Get(':operatorCode/capabilities')
@ApiOperation({ summary: 'Get operator capabilities' })
@ApiResponse({
status: 200,
description: 'Operator capabilities and features',
})
async getOperatorCapabilities(@Param('operatorCode') operatorCode: string) {
return this.operatorsService.getOperatorCapabilities(operatorCode);
}
@Put(':operatorCode/toggle-status')
@ApiOperation({ summary: 'Enable/Disable operator (Admin only)' })
@ApiResponse({
status: 200,
description: 'Operator status updated',
})
async toggleOperatorStatus(
@Request() req,
@Param('operatorCode') operatorCode: string,
@Body() body: { active: boolean; reason?: string },
) {
// Add admin check here
return this.operatorsService.toggleOperatorStatus(
operatorCode,
body.active,
body.reason,
);
}
}

View File

@ -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 = {

View File

@ -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)),

View File

@ -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()

View File

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

View File

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

View File

@ -7,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);

View File

@ -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 {}

View File

@ -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`,
},
});