first commit

This commit is contained in:
Mamadou Khoussa [028918 DSI/DAC/DIF/DS] 2025-10-22 00:14:41 +00:00
parent ad91d5c150
commit bde4f90235
20 changed files with 832 additions and 122 deletions

View File

@ -2,6 +2,7 @@
import eslint from '@eslint/js'; import eslint from '@eslint/js';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'; import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import globals from 'globals'; import globals from 'globals';
import { off } from 'process';
import tseslint from 'typescript-eslint'; import tseslint from 'typescript-eslint';
export default tseslint.config( export default tseslint.config(
@ -27,6 +28,8 @@ export default tseslint.config(
{ {
rules: { rules: {
'@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unsafe-assignment':'off',
'eslint-disable-next-line @typescript-eslint/no-unsafe-call':'off',
'@typescript-eslint/no-floating-promises': 'warn', '@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn', '@typescript-eslint/no-unsafe-argument': 'warn',
'prettier/prettier': ['error', { 'prettier/prettier': ['error', {

67
package-lock.json generated
View File

@ -9,6 +9,7 @@
"version": "0.0.1", "version": "0.0.1",
"license": "UNLICENSED", "license": "UNLICENSED",
"dependencies": { "dependencies": {
"@nestjs/axios": "^4.0.1",
"@nestjs/bull": "^11.0.4", "@nestjs/bull": "^11.0.4",
"@nestjs/cache-manager": "^3.0.1", "@nestjs/cache-manager": "^3.0.1",
"@nestjs/common": "^11.0.1", "@nestjs/common": "^11.0.1",
@ -23,6 +24,7 @@
"@prisma/client": "^6.17.1", "@prisma/client": "^6.17.1",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"cache-manager-redis-store": "^3.0.1", "cache-manager-redis-store": "^3.0.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.2", "class-validator": "^0.14.2",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1" "rxjs": "^7.8.1"
@ -2192,6 +2194,17 @@
"@tybys/wasm-util": "^0.10.0" "@tybys/wasm-util": "^0.10.0"
} }
}, },
"node_modules/@nestjs/axios": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@nestjs/axios/-/axios-4.0.1.tgz",
"integrity": "sha512-68pFJgu+/AZbWkGu65Z3r55bTsCPlgyKaV4BSG8yUAD72q1PPuyVRgUwFv6BxdnibTUHlyxm06FmYWNC+bjN7A==",
"license": "MIT",
"peerDependencies": {
"@nestjs/common": "^10.0.0 || ^11.0.0",
"axios": "^1.3.1",
"rxjs": "^7.0.0"
}
},
"node_modules/@nestjs/bull": { "node_modules/@nestjs/bull": {
"version": "11.0.4", "version": "11.0.4",
"resolved": "https://registry.npmjs.org/@nestjs/bull/-/bull-11.0.4.tgz", "resolved": "https://registry.npmjs.org/@nestjs/bull/-/bull-11.0.4.tgz",
@ -4461,9 +4474,20 @@
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/axios": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
"integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
"license": "MIT",
"peer": true,
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/babel-jest": { "node_modules/babel-jest": {
"version": "30.2.0", "version": "30.2.0",
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.2.0.tgz", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.2.0.tgz",
@ -5031,6 +5055,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/class-transformer": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz",
"integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==",
"license": "MIT"
},
"node_modules/class-validator": { "node_modules/class-validator": {
"version": "0.14.2", "version": "0.14.2",
"resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.2.tgz", "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.2.tgz",
@ -5211,7 +5241,6 @@
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"delayed-stream": "~1.0.0" "delayed-stream": "~1.0.0"
@ -5524,7 +5553,6 @@
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.4.0" "node": ">=0.4.0"
@ -5783,7 +5811,6 @@
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"es-errors": "^1.3.0", "es-errors": "^1.3.0",
@ -6428,6 +6455,27 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/foreground-child": { "node_modules/foreground-child": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
@ -6477,7 +6525,6 @@
"version": "4.0.4", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"asynckit": "^0.4.0", "asynckit": "^0.4.0",
@ -6494,7 +6541,6 @@
"version": "1.52.0", "version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 0.6" "node": ">= 0.6"
@ -6504,7 +6550,6 @@
"version": "2.1.35", "version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"mime-db": "1.52.0" "mime-db": "1.52.0"
@ -6879,7 +6924,6 @@
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"has-symbols": "^1.0.3" "has-symbols": "^1.0.3"
@ -9490,6 +9534,13 @@
"node": ">= 0.10" "node": ">= 0.10"
} }
}, },
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT",
"peer": true
},
"node_modules/punycode": { "node_modules/punycode": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",

View File

@ -20,6 +20,7 @@
"test:e2e": "jest --config ./test/jest-e2e.json" "test:e2e": "jest --config ./test/jest-e2e.json"
}, },
"dependencies": { "dependencies": {
"@nestjs/axios": "^4.0.1",
"@nestjs/bull": "^11.0.4", "@nestjs/bull": "^11.0.4",
"@nestjs/cache-manager": "^3.0.1", "@nestjs/cache-manager": "^3.0.1",
"@nestjs/common": "^11.0.1", "@nestjs/common": "^11.0.1",
@ -34,6 +35,7 @@
"@prisma/client": "^6.17.1", "@prisma/client": "^6.17.1",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"cache-manager-redis-store": "^3.0.1", "cache-manager-redis-store": "^3.0.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.2", "class-validator": "^0.14.2",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1" "rxjs": "^7.8.1"

View File

@ -2,4 +2,7 @@
npx prisma migrate dev --name init npx prisma migrate dev --name init
# Générer le client Prisma # Générer le client Prisma
npx prisma generate npx prisma generate
# Format
npx prisma format

View File

@ -0,0 +1,110 @@
/*
Warnings:
- A unique constraint covering the columns `[partnerId,code]` on the table `Plan` will be added. If there are existing duplicate values, this will fail.
- Added the required column `code` to the `Plan` table without a default value. This is not possible if the table is not empty.
- Added the required column `partnerId` to the `Plan` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "Plan" ADD COLUMN "code" TEXT NOT NULL,
ADD COLUMN "features" JSONB,
ADD COLUMN "intervalCount" INTEGER NOT NULL DEFAULT 1,
ADD COLUMN "limits" JSONB,
ADD COLUMN "partnerId" TEXT NOT NULL,
ADD COLUMN "trialDays" INTEGER NOT NULL DEFAULT 0;
-- AlterTable
ALTER TABLE "Webhook" ADD COLUMN "partnerId" TEXT;
-- CreateTable
CREATE TABLE "Invoice" (
"id" TEXT NOT NULL,
"number" TEXT NOT NULL,
"subscriptionId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"partnerId" TEXT NOT NULL,
"paymentId" TEXT,
"amount" DOUBLE PRECISION NOT NULL,
"currency" TEXT NOT NULL,
"status" TEXT NOT NULL,
"billingPeriodStart" TIMESTAMP(3) NOT NULL,
"billingPeriodEnd" TIMESTAMP(3) NOT NULL,
"dueDate" TIMESTAMP(3) NOT NULL,
"paidAt" TIMESTAMP(3),
"items" JSONB NOT NULL,
"attempts" INTEGER NOT NULL DEFAULT 0,
"failureReason" TEXT,
"metadata" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Invoice_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Notification" (
"id" TEXT NOT NULL,
"partnerId" TEXT NOT NULL,
"userId" TEXT,
"type" TEXT NOT NULL,
"channel" TEXT NOT NULL,
"recipient" TEXT NOT NULL,
"subject" TEXT,
"content" TEXT NOT NULL,
"templateId" TEXT,
"status" TEXT NOT NULL,
"batchId" TEXT,
"scheduledFor" TIMESTAMP(3),
"sentAt" TIMESTAMP(3),
"failedAt" TIMESTAMP(3),
"failureReason" TEXT,
"response" JSONB,
"metadata" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Notification_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Invoice_number_key" ON "Invoice"("number");
-- CreateIndex
CREATE UNIQUE INDEX "Invoice_paymentId_key" ON "Invoice"("paymentId");
-- CreateIndex
CREATE INDEX "Invoice_subscriptionId_idx" ON "Invoice"("subscriptionId");
-- CreateIndex
CREATE INDEX "Invoice_partnerId_status_idx" ON "Invoice"("partnerId", "status");
-- CreateIndex
CREATE INDEX "Plan_partnerId_active_idx" ON "Plan"("partnerId", "active");
-- CreateIndex
CREATE UNIQUE INDEX "Plan_partnerId_code_key" ON "Plan"("partnerId", "code");
-- AddForeignKey
ALTER TABLE "Plan" ADD CONSTRAINT "Plan_partnerId_fkey" FOREIGN KEY ("partnerId") REFERENCES "Partner"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Invoice" ADD CONSTRAINT "Invoice_subscriptionId_fkey" FOREIGN KEY ("subscriptionId") REFERENCES "Subscription"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Invoice" ADD CONSTRAINT "Invoice_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Invoice" ADD CONSTRAINT "Invoice_partnerId_fkey" FOREIGN KEY ("partnerId") REFERENCES "Partner"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Invoice" ADD CONSTRAINT "Invoice_paymentId_fkey" FOREIGN KEY ("paymentId") REFERENCES "Payment"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Webhook" ADD CONSTRAINT "Webhook_partnerId_fkey" FOREIGN KEY ("partnerId") REFERENCES "Partner"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Notification" ADD CONSTRAINT "Notification_partnerId_fkey" FOREIGN KEY ("partnerId") REFERENCES "Partner"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Notification" ADD CONSTRAINT "Notification_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -45,22 +45,22 @@ model Operator {
active Boolean @default(true) active Boolean @default(true)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
users User[] users User[]
} }
model User { model User {
id String @id @default(cuid()) id String @id @default(cuid())
msisdn String @unique msisdn String @unique
userToken String @unique userToken String @unique
userAlias String userAlias String
operatorId String operatorId String
partnerId String partnerId String
country String country String
metadata Json? metadata Json?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
operator Operator @relation(fields: [operatorId], references: [id]) operator Operator @relation(fields: [operatorId], references: [id])
partner Partner @relation(fields: [partnerId], references: [id]) partner Partner @relation(fields: [partnerId], references: [id])
subscriptions Subscription[] subscriptions Subscription[]
@ -77,11 +77,11 @@ model Plan {
description String? description String?
amount Float amount Float
currency String currency String
interval String // DAILY, WEEKLY, MONTHLY, YEARLY interval String // DAILY, WEEKLY, MONTHLY, YEARLY
intervalCount Int @default(1) intervalCount Int @default(1)
trialDays Int @default(0) trialDays Int @default(0)
features Json? // Array of features features Json? // Array of features
limits Json? // Object with usage limits limits Json? // Object with usage limits
metadata Json? metadata Json?
active Boolean @default(true) active Boolean @default(true)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@ -103,12 +103,12 @@ model Invoice {
paymentId String? @unique paymentId String? @unique
amount Float amount Float
currency String currency String
status String // PENDING, PAID, FAILED, CANCELLED status String // PENDING, PAID, FAILED, CANCELLED
billingPeriodStart DateTime billingPeriodStart DateTime
billingPeriodEnd DateTime billingPeriodEnd DateTime
dueDate DateTime dueDate DateTime
paidAt DateTime? paidAt DateTime?
items Json // Array of line items items Json // Array of line items
attempts Int @default(0) attempts Int @default(0)
failureReason String? failureReason String?
metadata Json? metadata Json?
@ -125,29 +125,29 @@ model Invoice {
} }
model Subscription { model Subscription {
id String @id @default(cuid()) id String @id @default(cuid())
userId String userId String
planId String planId String
partnerId String partnerId String
status SubscriptionStatus status SubscriptionStatus
currentPeriodStart DateTime currentPeriodStart DateTime
currentPeriodEnd DateTime currentPeriodEnd DateTime
nextBillingDate DateTime? nextBillingDate DateTime?
trialEndsAt DateTime? trialEndsAt DateTime?
cancelledAt DateTime? cancelledAt DateTime?
suspendedAt DateTime? suspendedAt DateTime?
failureCount Int @default(0) failureCount Int @default(0)
renewalCount Int @default(0) renewalCount Int @default(0)
lastPaymentId String? lastPaymentId String?
metadata Json? metadata Json?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id]) user User @relation(fields: [userId], references: [id])
plan Plan @relation(fields: [planId], references: [id]) plan Plan @relation(fields: [planId], references: [id])
partner Partner @relation(fields: [partnerId], references: [id]) partner Partner @relation(fields: [partnerId], references: [id])
payments Payment[] payments Payment[]
invoices Invoice[] invoices Invoice[]
} }
model Payment { model Payment {
@ -166,7 +166,7 @@ model Payment {
completedAt DateTime? completedAt DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id]) user User @relation(fields: [userId], references: [id])
partner Partner @relation(fields: [partnerId], references: [id]) partner Partner @relation(fields: [partnerId], references: [id])
subscription Subscription? @relation(fields: [subscriptionId], references: [id]) subscription Subscription? @relation(fields: [subscriptionId], references: [id])
@ -182,42 +182,42 @@ model Refund {
status String status String
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
payment Payment @relation(fields: [paymentId], references: [id]) payment Payment @relation(fields: [paymentId], references: [id])
} }
model Webhook { model Webhook {
id String @id @default(cuid()) id String @id @default(cuid())
partnerId String? // Made optional for system webhooks partnerId String? // Made optional for system webhooks
url String url String
event String event String
payload Json payload Json
response Json? response Json?
status String status String
attempts Int @default(0) attempts Int @default(0)
lastAttempt DateTime? lastAttempt DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
partner Partner? @relation(fields: [partnerId], references: [id]) partner Partner? @relation(fields: [partnerId], references: [id])
} }
model Partner { model Partner {
id String @id @default(cuid()) id String @id @default(cuid())
name String name String
email String @unique email String @unique
passwordHash String passwordHash String
apiKey String @unique apiKey String @unique
secretKey String secretKey String
status String @default("PENDING") status String @default("PENDING")
companyInfo Json? companyInfo Json?
callbacks Json? callbacks Json?
country String country String
metadata Json? metadata Json?
keysRotatedAt DateTime? keysRotatedAt DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
users User[] users User[]
subscriptions Subscription[] subscriptions Subscription[]
payments Payment[] payments Payment[]
@ -225,7 +225,7 @@ model Partner {
plans Plan[] plans Plan[]
invoices Invoice[] invoices Invoice[]
notifications Notification[] // Added relation notifications Notification[] // Added relation
webhooks Webhook[] // Added relation webhooks Webhook[] // Added relation
} }
model AuthSession { model AuthSession {
@ -242,7 +242,7 @@ model AuthSession {
expiresAt DateTime expiresAt DateTime
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
partner Partner @relation(fields: [partnerId], references: [id]) partner Partner @relation(fields: [partnerId], references: [id])
} }
@ -250,13 +250,13 @@ model Notification {
id String @id @default(cuid()) id String @id @default(cuid())
partnerId String partnerId String
userId String? userId String?
type String // PAYMENT, SUBSCRIPTION, ALERT, MARKETING type String // PAYMENT, SUBSCRIPTION, ALERT, MARKETING
channel String // SMS, EMAIL, WEBHOOK channel String // SMS, EMAIL, WEBHOOK
recipient String recipient String
subject String? subject String?
content String content String
templateId String? templateId String?
status String // PENDING, SENT, FAILED status String // PENDING, SENT, FAILED
batchId String? batchId String?
scheduledFor DateTime? scheduledFor DateTime?
sentAt DateTime? sentAt DateTime?
@ -266,7 +266,7 @@ model Notification {
metadata Json? metadata Json?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
partner Partner @relation(fields: [partnerId], references: [id]) partner Partner @relation(fields: [partnerId], references: [id])
user User? @relation(fields: [userId], references: [id]) user User? @relation(fields: [userId], references: [id])
} }

View File

@ -0,0 +1,3 @@
export class MTNAdapter{
}

View File

@ -0,0 +1,5 @@
//todo
export class OperatorsController{
}

View File

@ -0,0 +1,7 @@
//todo tomaj
export class OperatorsService{
getAdapter(code: any, country: any) :any{
throw new Error('Method not implemented.');
}
}

View File

@ -0,0 +1,4 @@
export class MTNTransformer{
}

View File

@ -0,0 +1,3 @@
export class OrangeTransformer{
}

View File

@ -1,35 +0,0 @@
import { IsString, IsNumber, IsOptional, IsUrl, Min } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class ChargeDto {
@ApiProperty()
@IsString()
userToken: string;
@ApiProperty()
@IsNumber()
@Min(0)
amount: number;
@ApiProperty()
@IsString()
currency: string;
@ApiProperty()
@IsString()
description: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
reference?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsUrl()
callbackUrl?: string;
@ApiProperty({ required: false })
@IsOptional()
metadata?: Record<string, any>;
}

View File

@ -0,0 +1,161 @@
import {
IsString,
IsNumber,
IsOptional,
IsUrl,
Min,
IsEnum,
IsDateString,
} from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
export class ChargeDto {
@ApiProperty({ description: 'User token from authentication' })
@IsString()
userToken: string;
@ApiProperty({ description: 'Amount to charge' })
@IsNumber()
@Min(0)
amount: number;
@ApiProperty({ description: 'Currency code (XOF, XAF, USD, etc.)' })
@IsString()
currency: string;
@ApiProperty({ description: 'Payment description' })
@IsString()
description: string;
@ApiProperty({ required: false, description: 'Unique payment reference' })
@IsOptional()
@IsString()
reference?: string;
@ApiProperty({ required: false, description: 'Subscription ID if recurring' })
@IsOptional()
@IsString()
subscriptionId?: string;
@ApiProperty({ required: false, description: 'Callback URL for notifications' })
@IsOptional()
@IsUrl()
callbackUrl?: string;
@ApiProperty({ required: false, description: 'Additional metadata' })
@IsOptional()
metadata?: Record<string, any>;
@ApiProperty({ required: false, description: 'partnerId ' })
@IsOptional()
partnerId: string;
}
export class RefundDto {
@ApiProperty({ required: false, description: 'Amount to refund (partial refund)' })
@IsOptional()
@IsNumber()
@Min(0)
amount?: number;
@ApiProperty({ description: 'Reason for refund' })
@IsString()
reason: string;
@ApiProperty({ required: false, description: 'Additional metadata' })
@IsOptional()
metadata?: Record<string, any>;
}
export class PaymentQueryDto {
@ApiProperty({ required: false, enum: ['PENDING', 'SUCCESS', 'FAILED', 'REFUNDED'] })
@IsOptional()
@IsEnum(['PENDING', 'SUCCESS', 'FAILED', 'REFUNDED'])
status?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
userId?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
subscriptionId?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsDateString()
startDate?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsDateString()
endDate?: string;
@ApiProperty({ required: false, default: 1 })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
page?: number = 1;
@ApiProperty({ required: false, default: 20 })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
limit?: number = 20;
}
export class PaymentResponseDto {
@ApiProperty()
id: string;
@ApiProperty()
userId: string;
@ApiProperty()
amount: number;
@ApiProperty()
currency: string;
@ApiProperty()
description: string;
@ApiProperty()
reference: string;
@ApiProperty({ required: false })
operatorReference?: string;
@ApiProperty({ enum: ['PENDING', 'SUCCESS', 'FAILED', 'REFUNDED'] })
status: string;
@ApiProperty({ required: false })
failureReason?: string;
@ApiProperty({ required: false })
completedAt?: Date;
@ApiProperty()
createdAt: Date;
@ApiProperty()
updatedAt: Date;
}
export class PaymentListResponseDto {
@ApiProperty({ type: [PaymentResponseDto] })
data: PaymentResponseDto[];
@ApiProperty()
meta: {
total: number;
page: number;
limit: number;
totalPages: number;
};
}

View File

@ -0,0 +1,204 @@
import {
Controller,
Post,
Get,
Body,
Param,
Query,
UseGuards,
Request,
HttpCode,
HttpStatus,
BadRequestException,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiBearerAuth,
ApiResponse,
ApiQuery,
} from '@nestjs/swagger';
import { PaymentsService } from './payments.service';
import {
ChargeDto,
RefundDto,
PaymentQueryDto,
PaymentResponseDto,
PaymentListResponseDto,
} from './dto/payment.dto';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { ApiKeyGuard } from '../../common/guards/api-key.guard';
@ApiTags('payments')
@Controller('payments')
export class PaymentsController {
constructor(private readonly paymentsService: PaymentsService) {}
@Post('charge')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Create a new charge' })
@ApiResponse({
status: 201,
description: 'Payment created successfully',
type: PaymentResponseDto,
})
@ApiResponse({ status: 400, description: 'Bad request' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
async createCharge(@Request() req, @Body() chargeDto: ChargeDto) {
return this.paymentsService.createCharge({
...chargeDto,
partnerId: req.user.partnerId,
});
}
@Post(':paymentId/refund')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Refund a payment' })
@ApiResponse({
status: 200,
description: 'Refund processed successfully',
})
@ApiResponse({ status: 404, description: 'Payment not found' })
async refundPayment(
@Request() req,
@Param('paymentId') paymentId: string,
@Body() refundDto: RefundDto,
) {
return this.paymentsService.refundPayment(
paymentId,
req.user.partnerId,
refundDto,
);
}
@Get(':paymentId')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Get payment details' })
@ApiResponse({
status: 200,
description: 'Payment details retrieved',
type: PaymentResponseDto,
})
@ApiResponse({ status: 404, description: 'Payment not found' })
async getPayment(@Request() req, @Param('paymentId') paymentId: string) {
return this.paymentsService.getPayment(paymentId, req.user.partnerId);
}
@Get()
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'List payments' })
@ApiQuery({ name: 'status', required: false, enum: ['PENDING', 'SUCCESS', 'FAILED', 'REFUNDED'] })
@ApiQuery({ name: 'userId', required: false })
@ApiQuery({ name: 'subscriptionId', required: false })
@ApiQuery({ name: 'startDate', required: false, type: Date })
@ApiQuery({ name: 'endDate', required: false, type: Date })
@ApiQuery({ name: 'page', required: false, type: Number, default: 1 })
@ApiQuery({ name: 'limit', required: false, type: Number, default: 20 })
@ApiResponse({
status: 200,
description: 'List of payments',
type: PaymentListResponseDto,
})
async listPayments(@Request() req, @Query() query: PaymentQueryDto) {
return this.paymentsService.listPayments({
partnerId: req.user.partnerId,
...query,
});
}
@Get('reference/:reference')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Get payment by reference' })
@ApiResponse({
status: 200,
description: 'Payment details retrieved',
type: PaymentResponseDto,
})
async getPaymentByReference(
@Request() req,
@Param('reference') reference: string,
) {
return this.paymentsService.getPaymentByReference(
reference,
req.user.partnerId,
);
}
@Post(':paymentId/retry')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Retry a failed payment' })
@ApiResponse({
status: 200,
description: 'Payment retry initiated',
})
@ApiResponse({ status: 400, description: 'Payment cannot be retried' })
async retryPayment(
@Request() req,
@Param('paymentId') paymentId: string,
) {
return this.paymentsService.retryPayment(paymentId, req.user.partnerId);
}
@Get('statistics/summary')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Get payment statistics' })
@ApiQuery({ name: 'period', required: false, enum: ['daily', 'weekly', 'monthly', 'yearly'] })
@ApiQuery({ name: 'startDate', required: false, type: Date })
@ApiQuery({ name: 'endDate', required: false, type: Date })
async getStatistics(
@Request() req,
@Query('period') period?: string,
@Query('startDate') startDate?: string,
@Query('endDate') endDate?: string,
) {
return this.paymentsService.getStatistics({
partnerId: req.user.partnerId,
period: period || 'monthly',
startDate: startDate ? new Date(startDate) : undefined,
endDate: endDate ? new Date(endDate) : undefined,
});
}
@Post('validate')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Validate payment before processing' })
async validatePayment(@Request() req, @Body() chargeDto: ChargeDto) {
return this.paymentsService.validatePayment({
...chargeDto,
partnerId: req.user.partnerId,
});
}
// Webhook endpoints
@Post('webhook/callback')
@UseGuards(ApiKeyGuard)
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Webhook callback for payment updates' })
async handleWebhook(@Request() req, @Body() payload: any) {
const signature = req.headers['x-webhook-signature'];
const event = req.headers['x-webhook-event'];
if (!signature || !event) {
throw new BadRequestException('Missing webhook headers');
}
return this.paymentsService.handleWebhook({
partnerId: req.partner.id,
event,
payload,
signature,
});
}
}

View File

@ -2,11 +2,30 @@ import { Injectable, BadRequestException } from '@nestjs/common';
import { OperatorsService } from '../operators/operators.service'; import { OperatorsService } from '../operators/operators.service';
import { PrismaService } from '../../shared/services/prisma.service'; import { PrismaService } from '../../shared/services/prisma.service';
import { EventEmitter2 } from '@nestjs/event-emitter'; import { EventEmitter2 } from '@nestjs/event-emitter';
import { ChargeDto } from './dto/charge.dto'; import { ChargeDto } from './dto/payment.dto';
import { PaymentStatus } from '@prisma/client'; import { RefundDto } from './dto/payment.dto';
import { PaymentStatus } from 'generated/prisma';
@Injectable() @Injectable()
export class PaymentsService { export class PaymentsService {
handleWebhook(arg0: { partnerId: any; event: any; payload: any; signature: any; }) {
throw new Error('Method not implemented.');
}
getPaymentByReference(reference: string, partnerId: any) {
throw new Error('Method not implemented.');
}
getPayment(paymentId: string, partnerId: any) {
throw new Error('Method not implemented.');
}
refundPayment(paymentId: string, partnerId: any, refundDto: RefundDto) {
throw new Error('Method not implemented.');
}
retryPayment(paymentId: any, attempt: any) {
throw new Error('Method not implemented.');
}
processPayment(paymentId: any) :any{
throw new Error('Method not implemented.');
}
constructor( constructor(
private readonly operatorsService: OperatorsService, private readonly operatorsService: OperatorsService,
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
@ -102,4 +121,166 @@ export class PaymentsService {
// Implémenter la notification webhook // Implémenter la notification webhook
// Utiliser Bull Queue pour gérer les retries // Utiliser Bull Queue pour gérer les retries
} }
// Ajouter ces méthodes dans PaymentsService
async listPayments(filters: any) {
const where: any = {
partnerId: filters.partnerId,
};
if (filters.status) {
where.status = filters.status;
}
if (filters.userId) {
where.userId = filters.userId;
}
if (filters.subscriptionId) {
where.subscriptionId = filters.subscriptionId;
}
if (filters.startDate || filters.endDate) {
where.createdAt = {};
if (filters.startDate) {
where.createdAt.gte = new Date(filters.startDate);
}
if (filters.endDate) {
where.createdAt.lte = new Date(filters.endDate);
}
}
const page = filters.page || 1;
const limit = filters.limit || 20;
const skip = (page - 1) * limit;
const [payments, total] = await Promise.all([
this.prisma.payment.findMany({
where,
skip,
take: limit,
orderBy: { createdAt: 'desc' },
include: {
user: {
select: {
id: true,
msisdn: true,
},
},
subscription: {
select: {
id: true,
planId: true,
},
},
},
}),
this.prisma.payment.count({ where }),
]);
return {
data: payments,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}
async getStatistics(params: {
partnerId: string;
period: string;
startDate?: Date;
endDate?: Date;
}) {
const { partnerId, period, startDate, endDate } = params;
const where: any = { partnerId };
if (startDate || endDate) {
where.createdAt = {};
if (startDate) where.createdAt.gte = startDate;
if (endDate) where.createdAt.lte = endDate;
}
const [
totalPayments,
successfulPayments,
failedPayments,
totalRevenue,
avgPaymentAmount,
] = await Promise.all([
this.prisma.payment.count({ where }),
this.prisma.payment.count({ where: { ...where, status: 'SUCCESS' } }),
this.prisma.payment.count({ where: { ...where, status: 'FAILED' } }),
this.prisma.payment.aggregate({
where: { ...where, status: 'SUCCESS' },
_sum: { amount: true },
}),
this.prisma.payment.aggregate({
where: { ...where, status: 'SUCCESS' },
_avg: { amount: true },
}),
]);
const successRate = totalPayments > 0
? (successfulPayments / totalPayments) * 100
: 0;
return {
totalPayments,
successfulPayments,
failedPayments,
successRate: Math.round(successRate * 100) / 100,
totalRevenue: totalRevenue._sum.amount || 0,
avgPaymentAmount: avgPaymentAmount._avg.amount || 0,
period,
startDate,
endDate,
};
}
async validatePayment(params: any) {
// Valider le user token
const user = await this.prisma.user.findUnique({
where: { userToken: params.userToken },
});
if (!user) {
return {
valid: false,
error: 'Invalid user token',
};
}
// Vérifier les limites
const todayPayments = await this.prisma.payment.count({
where: {
userId: user.id,
status: 'SUCCESS',
createdAt: {
gte: new Date(new Date().setHours(0, 0, 0, 0)),
},
},
});
if (todayPayments >= 10) {
return {
valid: false,
error: 'Daily payment limit reached',
};
}
return {
valid: true,
user: {
id: user.id,
msisdn: user.msisdn,
country: user.country,
},
};
}
} }

View File

@ -1,5 +1,5 @@
import { Process, Processor } from '@nestjs/bull'; import { Process, Processor } from '@nestjs/bull';
import { Job } from 'bull'; import bull from 'bull';
import { PaymentsService } from '../payments.service'; import { PaymentsService } from '../payments.service';
import { WebhookService } from '../services/webhook.service'; import { WebhookService } from '../services/webhook.service';
@ -11,7 +11,7 @@ export class PaymentProcessor {
) {} ) {}
@Process('process-payment') @Process('process-payment')
async handlePayment(job: Job) { async handlePayment(job: bull.Job) {
const { paymentId } = job.data; const { paymentId } = job.data;
try { try {
@ -36,7 +36,7 @@ export class PaymentProcessor {
} }
@Process('retry-payment') @Process('retry-payment')
async handleRetry(job: Job) { async handleRetry(job: bull.Job) {
const { paymentId, attempt } = job.data; const { paymentId, attempt } = job.data;
try { try {

View File

@ -0,0 +1,8 @@
//todo
export class WebhookService{
send(arg0: { url: any; event: string; payload: any; }) {
throw new Error('Method not implemented.');
}
}

View File

@ -1,15 +1,15 @@
import { Injectable, BadRequestException } from '@nestjs/common'; import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../../shared/services/prisma.service'; import { PrismaService } from '../../../shared/services/prisma.service';
import { PaymentsService } from '../../payments/payments.service'; import { PaymentsService } from '../../payments/payments.service';
import { InjectQueue } from '@nestjs/bull'; import { InjectQueue } from '@nestjs/bull';
import { Queue } from 'bull'; import bull from 'bull';
@Injectable() @Injectable()
export class BillingService { export class BillingService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly paymentsService: PaymentsService, private readonly paymentsService: PaymentsService,
@InjectQueue('billing') private billingQueue: Queue, @InjectQueue('billing') private billingQueue: bull.Queue,
) {} ) {}
async processBilling(subscriptionId: string) { async processBilling(subscriptionId: string) {

View File

@ -1,22 +1,24 @@
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common'; import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
import { InjectQueue } from '@nestjs/bull'; import { InjectQueue } from '@nestjs/bull';
import { Queue } from 'bull'; import bull from 'bull';
import { PrismaService } from '../../shared/services/prisma.service'; import { PrismaService } from '../../shared/services/prisma.service';
import { PaymentsService } from '../payments/payments.service'; import { PaymentsService } from '../payments/payments.service';
import { CreateSubscriptionDto, UpdateSubscriptionDto } from './dto/subscription.dto'; import { CreateSubscriptionDto, UpdateSubscriptionDto } from './dto/subscription.dto';
import { SubscriptionStatus } from '@prisma/client'; //import { SubscriptionStatus } from '@prisma/client';
//import { SubscriptionStatus, Prisma } from '@prisma/client';
@Injectable() @Injectable()
export class SubscriptionsService { export class SubscriptionsService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly paymentsService: PaymentsService, private readonly paymentsService: PaymentsService,
@InjectQueue('subscriptions') private subscriptionQueue: Queue, @InjectQueue('subscriptions') private subscriptionQueue: bull.Queue,
@InjectQueue('billing') private billingQueue: Queue, @InjectQueue('billing') private billingQueue: bull.Queue,
) {} ) {}
async create(partnerId: string, dto: CreateSubscriptionDto) { async create(partnerId: string, dto: CreateSubscriptionDto) {
// Vérifier l'utilisateur // Vérifier l'utilisateur
const user = await this.prisma.user.findFirst({ const user = await this.prisma.user.findFirst({
where: { where: {
userToken: dto.userToken, userToken: dto.userToken,

View File

@ -2,11 +2,9 @@ import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
@Injectable() @Injectable()
export class PrismaService export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
extends PrismaClient
implements OnModuleInit, OnModuleDestroy
{
constructor() { constructor() {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
super({ super({
log: ['query', 'info', 'warn', 'error'], log: ['query', 'info', 'warn', 'error'],
}); });