From bde4f9023592775454d8fdf07321b1e6d7ab9b8d Mon Sep 17 00:00:00 2001 From: "Mamadou Khoussa [028918 DSI/DAC/DIF/DS]" Date: Wed, 22 Oct 2025 00:14:41 +0000 Subject: [PATCH] first commit --- eslint.config.mjs | 3 + package-lock.json | 67 +++++- package.json | 2 + prisma.md | 5 +- .../migration.sql | 110 ++++++++++ prisma/schema.prisma | 124 +++++------ src/modules/operators/adapters/mtn.adapter.ts | 3 + src/modules/operators/operators.controller.ts | 5 + src/modules/operators/operators.service.ts | 7 + .../operators/transformers/mtn.transformer.ts | 4 + .../transformers/orange.transformer.ts | 3 + src/modules/payments/dto/charge.dto.ts | 35 --- src/modules/payments/dto/payment.dto.ts | 161 ++++++++++++++ src/modules/payments/payments.controller.ts | 204 ++++++++++++++++++ src/modules/payments/payments.service.ts | 185 +++++++++++++++- .../payments/processors/payment.processor.ts | 6 +- .../payments/services/webhook.service.ts | 8 + .../subscriptions/services/billing.service.ts | 6 +- .../subscriptions/subscriptions.service.ts | 10 +- src/shared/services/prisma.service.ts | 6 +- 20 files changed, 832 insertions(+), 122 deletions(-) create mode 100644 prisma/migrations/20251021234845_add_missing_relations/migration.sql create mode 100644 src/modules/operators/adapters/mtn.adapter.ts create mode 100644 src/modules/operators/operators.controller.ts create mode 100644 src/modules/operators/operators.service.ts create mode 100644 src/modules/operators/transformers/mtn.transformer.ts create mode 100644 src/modules/operators/transformers/orange.transformer.ts delete mode 100644 src/modules/payments/dto/charge.dto.ts create mode 100644 src/modules/payments/dto/payment.dto.ts create mode 100644 src/modules/payments/services/webhook.service.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index 02d56dd..8bb661b 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -2,6 +2,7 @@ import eslint from '@eslint/js'; import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'; import globals from 'globals'; +import { off } from 'process'; import tseslint from 'typescript-eslint'; export default tseslint.config( @@ -27,6 +28,8 @@ export default tseslint.config( { rules: { '@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-unsafe-argument': 'warn', 'prettier/prettier': ['error', { diff --git a/package-lock.json b/package-lock.json index 9a57750..7bc3bbb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.1", "license": "UNLICENSED", "dependencies": { + "@nestjs/axios": "^4.0.1", "@nestjs/bull": "^11.0.4", "@nestjs/cache-manager": "^3.0.1", "@nestjs/common": "^11.0.1", @@ -23,6 +24,7 @@ "@prisma/client": "^6.17.1", "bcrypt": "^6.0.0", "cache-manager-redis-store": "^3.0.1", + "class-transformer": "^0.5.1", "class-validator": "^0.14.2", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1" @@ -2192,6 +2194,17 @@ "@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": { "version": "11.0.4", "resolved": "https://registry.npmjs.org/@nestjs/bull/-/bull-11.0.4.tgz", @@ -4461,9 +4474,20 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true, "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": { "version": "30.2.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.2.0.tgz", @@ -5031,6 +5055,12 @@ "dev": true, "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": { "version": "0.14.2", "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.2.tgz", @@ -5211,7 +5241,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" @@ -5524,7 +5553,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.4.0" @@ -5783,7 +5811,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -6428,6 +6455,27 @@ "dev": true, "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": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -6477,7 +6525,6 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", - "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -6494,7 +6541,6 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -6504,7 +6550,6 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, "license": "MIT", "dependencies": { "mime-db": "1.52.0" @@ -6879,7 +6924,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -9490,6 +9534,13 @@ "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": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", diff --git a/package.json b/package.json index 2f340e1..d5c4926 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "test:e2e": "jest --config ./test/jest-e2e.json" }, "dependencies": { + "@nestjs/axios": "^4.0.1", "@nestjs/bull": "^11.0.4", "@nestjs/cache-manager": "^3.0.1", "@nestjs/common": "^11.0.1", @@ -34,6 +35,7 @@ "@prisma/client": "^6.17.1", "bcrypt": "^6.0.0", "cache-manager-redis-store": "^3.0.1", + "class-transformer": "^0.5.1", "class-validator": "^0.14.2", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1" diff --git a/prisma.md b/prisma.md index dc6a353..530d42c 100644 --- a/prisma.md +++ b/prisma.md @@ -2,4 +2,7 @@ npx prisma migrate dev --name init # Générer le client Prisma -npx prisma generate \ No newline at end of file +npx prisma generate + +# Format + npx prisma format \ No newline at end of file diff --git a/prisma/migrations/20251021234845_add_missing_relations/migration.sql b/prisma/migrations/20251021234845_add_missing_relations/migration.sql new file mode 100644 index 0000000..6dfd718 --- /dev/null +++ b/prisma/migrations/20251021234845_add_missing_relations/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a3744df..70d7363 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -45,22 +45,22 @@ model Operator { active Boolean @default(true) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - - users User[] + + 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 - + 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[] @@ -77,11 +77,11 @@ model Plan { description String? amount Float currency String - interval String // DAILY, WEEKLY, MONTHLY, YEARLY + 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 + features Json? // Array of features + limits Json? // Object with usage limits metadata Json? active Boolean @default(true) createdAt DateTime @default(now()) @@ -103,12 +103,12 @@ model Invoice { paymentId String? @unique amount Float currency String - status String // PENDING, PAID, FAILED, CANCELLED + status String // PENDING, PAID, FAILED, CANCELLED billingPeriodStart DateTime billingPeriodEnd DateTime dueDate DateTime paidAt DateTime? - items Json // Array of line items + items Json // Array of line items attempts Int @default(0) failureReason String? metadata Json? @@ -125,29 +125,29 @@ model Invoice { } 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[] + 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 { @@ -166,7 +166,7 @@ model Payment { 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]) @@ -182,42 +182,42 @@ model Refund { 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 + 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) + attempts Int @default(0) lastAttempt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt partner Partner? @relation(fields: [partnerId], references: [id]) } model Partner { - id String @id @default(cuid()) + id String @id @default(cuid()) name String - email String @unique + email String @unique passwordHash String - apiKey String @unique + apiKey String @unique secretKey String - status String @default("PENDING") + status String @default("PENDING") companyInfo Json? callbacks Json? country String metadata Json? keysRotatedAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + users User[] subscriptions Subscription[] payments Payment[] @@ -225,7 +225,7 @@ model Partner { plans Plan[] invoices Invoice[] notifications Notification[] // Added relation - webhooks Webhook[] // Added relation + webhooks Webhook[] // Added relation } model AuthSession { @@ -242,7 +242,7 @@ model AuthSession { expiresAt DateTime createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - + partner Partner @relation(fields: [partnerId], references: [id]) } @@ -250,13 +250,13 @@ model Notification { id String @id @default(cuid()) partnerId String userId String? - type String // PAYMENT, SUBSCRIPTION, ALERT, MARKETING - channel String // SMS, EMAIL, WEBHOOK + type String // PAYMENT, SUBSCRIPTION, ALERT, MARKETING + channel String // SMS, EMAIL, WEBHOOK recipient String subject String? content String templateId String? - status String // PENDING, SENT, FAILED + status String // PENDING, SENT, FAILED batchId String? scheduledFor DateTime? sentAt DateTime? @@ -266,7 +266,7 @@ model Notification { metadata Json? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - + partner Partner @relation(fields: [partnerId], references: [id]) user User? @relation(fields: [userId], references: [id]) -} \ No newline at end of file +} diff --git a/src/modules/operators/adapters/mtn.adapter.ts b/src/modules/operators/adapters/mtn.adapter.ts new file mode 100644 index 0000000..3ea1bc5 --- /dev/null +++ b/src/modules/operators/adapters/mtn.adapter.ts @@ -0,0 +1,3 @@ +export class MTNAdapter{ + +} \ No newline at end of file diff --git a/src/modules/operators/operators.controller.ts b/src/modules/operators/operators.controller.ts new file mode 100644 index 0000000..5d082f0 --- /dev/null +++ b/src/modules/operators/operators.controller.ts @@ -0,0 +1,5 @@ +//todo + +export class OperatorsController{ + +} \ No newline at end of file diff --git a/src/modules/operators/operators.service.ts b/src/modules/operators/operators.service.ts new file mode 100644 index 0000000..fae4b36 --- /dev/null +++ b/src/modules/operators/operators.service.ts @@ -0,0 +1,7 @@ +//todo tomaj +export class OperatorsService{ + getAdapter(code: any, country: any) :any{ + throw new Error('Method not implemented.'); + } + +} \ No newline at end of file diff --git a/src/modules/operators/transformers/mtn.transformer.ts b/src/modules/operators/transformers/mtn.transformer.ts new file mode 100644 index 0000000..2062827 --- /dev/null +++ b/src/modules/operators/transformers/mtn.transformer.ts @@ -0,0 +1,4 @@ + +export class MTNTransformer{ + +} \ No newline at end of file diff --git a/src/modules/operators/transformers/orange.transformer.ts b/src/modules/operators/transformers/orange.transformer.ts new file mode 100644 index 0000000..84f3c33 --- /dev/null +++ b/src/modules/operators/transformers/orange.transformer.ts @@ -0,0 +1,3 @@ +export class OrangeTransformer{ + +} \ No newline at end of file diff --git a/src/modules/payments/dto/charge.dto.ts b/src/modules/payments/dto/charge.dto.ts deleted file mode 100644 index 3364545..0000000 --- a/src/modules/payments/dto/charge.dto.ts +++ /dev/null @@ -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; -} diff --git a/src/modules/payments/dto/payment.dto.ts b/src/modules/payments/dto/payment.dto.ts new file mode 100644 index 0000000..ba55cfc --- /dev/null +++ b/src/modules/payments/dto/payment.dto.ts @@ -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; + + @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; +} + +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; + }; +} \ No newline at end of file diff --git a/src/modules/payments/payments.controller.ts b/src/modules/payments/payments.controller.ts index e69de29..93e0b09 100644 --- a/src/modules/payments/payments.controller.ts +++ b/src/modules/payments/payments.controller.ts @@ -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, + }); + } +} \ No newline at end of file diff --git a/src/modules/payments/payments.service.ts b/src/modules/payments/payments.service.ts index d9478ca..21a237a 100644 --- a/src/modules/payments/payments.service.ts +++ b/src/modules/payments/payments.service.ts @@ -2,11 +2,30 @@ import { Injectable, BadRequestException } from '@nestjs/common'; import { OperatorsService } from '../operators/operators.service'; import { PrismaService } from '../../shared/services/prisma.service'; import { EventEmitter2 } from '@nestjs/event-emitter'; -import { ChargeDto } from './dto/charge.dto'; -import { PaymentStatus } from '@prisma/client'; +import { ChargeDto } from './dto/payment.dto'; +import { RefundDto } from './dto/payment.dto'; +import { PaymentStatus } from 'generated/prisma'; @Injectable() 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( private readonly operatorsService: OperatorsService, private readonly prisma: PrismaService, @@ -102,4 +121,166 @@ export class PaymentsService { // Implémenter la notification webhook // 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, + }, + }; +} } diff --git a/src/modules/payments/processors/payment.processor.ts b/src/modules/payments/processors/payment.processor.ts index 397e055..c865e52 100644 --- a/src/modules/payments/processors/payment.processor.ts +++ b/src/modules/payments/processors/payment.processor.ts @@ -1,5 +1,5 @@ import { Process, Processor } from '@nestjs/bull'; -import { Job } from 'bull'; +import bull from 'bull'; import { PaymentsService } from '../payments.service'; import { WebhookService } from '../services/webhook.service'; @@ -11,7 +11,7 @@ export class PaymentProcessor { ) {} @Process('process-payment') - async handlePayment(job: Job) { + async handlePayment(job: bull.Job) { const { paymentId } = job.data; try { @@ -36,7 +36,7 @@ export class PaymentProcessor { } @Process('retry-payment') - async handleRetry(job: Job) { + async handleRetry(job: bull.Job) { const { paymentId, attempt } = job.data; try { diff --git a/src/modules/payments/services/webhook.service.ts b/src/modules/payments/services/webhook.service.ts new file mode 100644 index 0000000..a97d573 --- /dev/null +++ b/src/modules/payments/services/webhook.service.ts @@ -0,0 +1,8 @@ +//todo + +export class WebhookService{ + send(arg0: { url: any; event: string; payload: any; }) { + throw new Error('Method not implemented.'); + } + +} \ No newline at end of file diff --git a/src/modules/subscriptions/services/billing.service.ts b/src/modules/subscriptions/services/billing.service.ts index 510578a..dc764b9 100644 --- a/src/modules/subscriptions/services/billing.service.ts +++ b/src/modules/subscriptions/services/billing.service.ts @@ -1,15 +1,15 @@ -import { Injectable, BadRequestException } from '@nestjs/common'; +import { Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../../../shared/services/prisma.service'; import { PaymentsService } from '../../payments/payments.service'; import { InjectQueue } from '@nestjs/bull'; -import { Queue } from 'bull'; +import bull from 'bull'; @Injectable() export class BillingService { constructor( private readonly prisma: PrismaService, private readonly paymentsService: PaymentsService, - @InjectQueue('billing') private billingQueue: Queue, + @InjectQueue('billing') private billingQueue: bull.Queue, ) {} async processBilling(subscriptionId: string) { diff --git a/src/modules/subscriptions/subscriptions.service.ts b/src/modules/subscriptions/subscriptions.service.ts index 6847b57..69997b1 100644 --- a/src/modules/subscriptions/subscriptions.service.ts +++ b/src/modules/subscriptions/subscriptions.service.ts @@ -1,22 +1,24 @@ import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common'; import { InjectQueue } from '@nestjs/bull'; -import { Queue } from 'bull'; +import bull from 'bull'; import { PrismaService } from '../../shared/services/prisma.service'; import { PaymentsService } from '../payments/payments.service'; import { CreateSubscriptionDto, UpdateSubscriptionDto } from './dto/subscription.dto'; -import { SubscriptionStatus } from '@prisma/client'; +//import { SubscriptionStatus } from '@prisma/client'; +//import { SubscriptionStatus, Prisma } from '@prisma/client'; @Injectable() export class SubscriptionsService { constructor( private readonly prisma: PrismaService, private readonly paymentsService: PaymentsService, - @InjectQueue('subscriptions') private subscriptionQueue: Queue, - @InjectQueue('billing') private billingQueue: Queue, + @InjectQueue('subscriptions') private subscriptionQueue: bull.Queue, + @InjectQueue('billing') private billingQueue: bull.Queue, ) {} async create(partnerId: string, dto: CreateSubscriptionDto) { // Vérifier l'utilisateur + const user = await this.prisma.user.findFirst({ where: { userToken: dto.userToken, diff --git a/src/shared/services/prisma.service.ts b/src/shared/services/prisma.service.ts index 5541f1e..1902de8 100644 --- a/src/shared/services/prisma.service.ts +++ b/src/shared/services/prisma.service.ts @@ -2,11 +2,9 @@ import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; import { PrismaClient } from '@prisma/client'; @Injectable() -export class PrismaService - extends PrismaClient - implements OnModuleInit, OnModuleDestroy -{ +export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { constructor() { + // eslint-disable-next-line @typescript-eslint/no-unsafe-call super({ log: ['query', 'info', 'warn', 'error'], });