diff --git a/package-lock.json b/package-lock.json index 41abbc0..9a57750 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,10 +15,13 @@ "@nestjs/config": "^4.0.2", "@nestjs/core": "^11.0.1", "@nestjs/event-emitter": "^3.0.1", + "@nestjs/jwt": "^11.0.1", + "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.0.1", "@nestjs/schedule": "^6.0.1", "@nestjs/swagger": "^11.2.1", "@prisma/client": "^6.17.1", + "bcrypt": "^6.0.0", "cache-manager-redis-store": "^3.0.1", "class-validator": "^0.14.2", "reflect-metadata": "^0.2.2", @@ -2573,6 +2576,19 @@ "@nestjs/core": "^10.0.0 || ^11.0.0" } }, + "node_modules/@nestjs/jwt": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-11.0.1.tgz", + "integrity": "sha512-HXSsc7SAnCnjA98TsZqrE7trGtHDnYXWp4Ffy6LwSmck1QvbGYdMzBquXofX5l6tIRpeY4Qidl2Ti2CVG77Pdw==", + "license": "MIT", + "dependencies": { + "@types/jsonwebtoken": "9.0.10", + "jsonwebtoken": "9.0.2" + }, + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0" + } + }, "node_modules/@nestjs/mapped-types": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-2.1.0.tgz", @@ -2593,6 +2609,16 @@ } } }, + "node_modules/@nestjs/passport": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/@nestjs/passport/-/passport-11.0.5.tgz", + "integrity": "sha512-ulQX6mbjlws92PIM15Naes4F4p2JoxGnIJuUsdXQPT+Oo2sqQmENEZXM7eYuimocfHnKlcfZOuyzbA33LwUlOQ==", + "license": "MIT", + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "passport": "^0.5.0 || ^0.6.0 || ^0.7.0" + } + }, "node_modules/@nestjs/platform-express": { "version": "11.1.7", "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.7.tgz", @@ -3320,6 +3346,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, "node_modules/@types/luxon": { "version": "3.7.1", "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.1.tgz", @@ -3340,11 +3376,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.18.12", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.12.tgz", "integrity": "sha512-BICHQ67iqxQGFSzfCFTT7MRQ5XcBjG5aeKh5Ok38UBbPe5fxTyE+aHFxwVrGyr8GNlqFMLKD1D3P2K/1ks8tog==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -4560,6 +4601,20 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.3.0", + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", @@ -4710,6 +4765,12 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -5585,6 +5646,15 @@ "dev": true, "license": "MIT" }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -8103,6 +8173,49 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "license": "MIT", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz", + "integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "license": "MIT", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keyv": { "version": "5.5.3", "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.5.3.tgz", @@ -8212,6 +8325,12 @@ "license": "MIT", "peer": true }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, "node_modules/lodash.isarguments": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", @@ -8219,6 +8338,36 @@ "license": "MIT", "peer": true }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -8233,6 +8382,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", @@ -8653,6 +8808,15 @@ "dev": true, "license": "MIT" }, + "node_modules/node-addon-api": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz", + "integrity": "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, "node_modules/node-emoji": { "version": "1.11.0", "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-1.11.0.tgz", @@ -8670,6 +8834,17 @@ "devOptional": true, "license": "MIT" }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/node-gyp-build-optional-packages": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", @@ -8963,6 +9138,34 @@ "node": ">= 0.8" } }, + "node_modules/passport": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", + "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "passport-strategy": "1.x.x", + "pause": "0.0.1", + "utils-merge": "^1.0.1" + }, + "engines": { + "node": ">= 0.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, + "node_modules/passport-strategy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", + "integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==", + "peer": true, + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -9047,6 +9250,12 @@ "devOptional": true, "license": "MIT" }, + "node_modules/pause": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", + "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==", + "peer": true + }, "node_modules/perfect-debounce": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", @@ -10821,7 +11030,6 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, "license": "MIT" }, "node_modules/universalify": { @@ -10925,6 +11133,16 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/uuid": { "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", diff --git a/package.json b/package.json index 9c88ad8..2f340e1 100644 --- a/package.json +++ b/package.json @@ -26,10 +26,13 @@ "@nestjs/config": "^4.0.2", "@nestjs/core": "^11.0.1", "@nestjs/event-emitter": "^3.0.1", + "@nestjs/jwt": "^11.0.1", + "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.0.1", "@nestjs/schedule": "^6.0.1", "@nestjs/swagger": "^11.2.1", "@prisma/client": "^6.17.1", + "bcrypt": "^6.0.0", "cache-manager-redis-store": "^3.0.1", "class-validator": "^0.14.2", "reflect-metadata": "^0.2.2", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index bb3f5c7..27bbfcc 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1,9 +1,6 @@ // This is your Prisma schema file, // learn more about it in the docs: https://pris.ly/d/prisma-schema -// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions? -// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init - generator client { provider = "prisma-client-js" output = "../generated/prisma" @@ -39,8 +36,6 @@ enum SubscriptionStatus { FAILED } - - model Operator { id String @id @default(cuid()) code OperatorCode @@ -70,21 +65,62 @@ model User { partner Partner @relation(fields: [partnerId], references: [id]) subscriptions Subscription[] payments Payment[] + invoices Invoice[] // Added relation } model Plan { - id String @id @default(cuid()) + id String @id @default(cuid()) + partnerId String + code String name String 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 metadata Json? - active Boolean @default(true) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - + 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 { @@ -110,6 +146,7 @@ model Subscription { plan Plan @relation(fields: [planId], references: [id]) partner Partner @relation(fields: [partnerId], references: [id]) payments Payment[] + invoices Invoice[] // Added relation } model Payment { @@ -133,6 +170,7 @@ model Payment { partner Partner @relation(fields: [partnerId], references: [id]) subscription Subscription? @relation(fields: [subscriptionId], references: [id]) refunds Refund[] + invoice Invoice? // Added relation } model Refund { @@ -180,6 +218,8 @@ model Partner { subscriptions Subscription[] payments Payment[] authSessions AuthSession[] + plans Plan[] // Added relation + invoices Invoice[] // Added relation } model AuthSession { @@ -198,4 +238,29 @@ model AuthSession { 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]) } \ No newline at end of file diff --git a/src/app.module.ts b/src/app.module.ts index d2307e6..412c7ef 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -6,8 +6,12 @@ import { EventEmitterModule } from '@nestjs/event-emitter'; import { CacheModule } from '@nestjs/cache-manager'; import * as redisStore from 'cache-manager-redis-store'; +// Import des configurations import appConfig from './config/app.config'; import operatorsConfig from './config/operators.config'; +import databaseConfig from './config/database.config'; + +// Import des modules import { PrismaService } from './shared/services/prisma.service'; import { AuthModule } from './modules/auth/auth.module'; import { PartnersModule } from './modules/partners/partners.module'; @@ -20,23 +24,29 @@ import { NotificationsModule } from './modules/notifications/notifications.modul imports: [ ConfigModule.forRoot({ isGlobal: true, - load: [appConfig, operatorsConfig], + load: [appConfig, operatorsConfig, databaseConfig], + envFilePath: ['.env.local', '.env'], }), BullModule.forRootAsync({ imports: [ConfigModule], useFactory: (configService: ConfigService) => ({ redis: { - host: configService.get('REDIS_HOST'), - port: configService.get('REDIS_PORT'), + host: configService.get('app.redis.host'), + port: configService.get('app.redis.port'), }, }), inject: [ConfigService], }), - CacheModule.register({ + CacheModule.registerAsync({ + imports: [ConfigModule], + useFactory: (configService: ConfigService) => ({ + store: redisStore, + host: configService.get('app.redis.host'), + port: configService.get('app.redis.port'), + ttl: 600, // 10 minutes default + }), + inject: [ConfigService], isGlobal: true, - store: redisStore, - host: process.env.REDIS_HOST || 'localhost', - port: process.env.REDIS_PORT || 6379, }), ScheduleModule.forRoot(), EventEmitterModule.forRoot(), diff --git a/src/common/decorators/api-header.decorator.ts b/src/common/decorators/api-header.decorator.ts new file mode 100644 index 0000000..1f5572f --- /dev/null +++ b/src/common/decorators/api-header.decorator.ts @@ -0,0 +1,17 @@ +import { applyDecorators } from '@nestjs/common'; +import { ApiHeader } from '@nestjs/swagger'; + +export function ApiHeaders() { + return applyDecorators( + ApiHeader({ + name: 'X-API-Key', + description: 'API Key for authentication', + required: false, + }), + ApiHeader({ + name: 'X-Partner-Id', + description: 'Partner identifier', + required: false, + }), + ); +} \ No newline at end of file diff --git a/src/common/decorators/current-user.decorator.ts b/src/common/decorators/current-user.decorator.ts new file mode 100644 index 0000000..d257a2e --- /dev/null +++ b/src/common/decorators/current-user.decorator.ts @@ -0,0 +1,7 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; + +export const CurrentUser = createParamDecorator( + (data: unknown, ctx: ExecutionContext) => { + const request = ctx.switchToHttp().getRequest(); + return request.user; +}); diff --git a/src/common/decorators/public.decorator.ts b/src/common/decorators/public.decorator.ts new file mode 100644 index 0000000..d0941f2 --- /dev/null +++ b/src/common/decorators/public.decorator.ts @@ -0,0 +1,4 @@ +import { SetMetadata } from '@nestjs/common'; + +export const IS_PUBLIC_KEY = 'isPublic'; +export const Public = () => SetMetadata(IS_PUBLIC_KEY, true) \ No newline at end of file diff --git a/src/common/decorators/roles.decorator.ts b/src/common/decorators/roles.decorator.ts new file mode 100644 index 0000000..e038e16 --- /dev/null +++ b/src/common/decorators/roles.decorator.ts @@ -0,0 +1,4 @@ +import { SetMetadata } from '@nestjs/common'; + +export const ROLES_KEY = 'roles'; +export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles); diff --git a/src/common/filters/http-exception.filter.ts b/src/common/filters/http-exception.filter.ts new file mode 100644 index 0000000..885d771 --- /dev/null +++ b/src/common/filters/http-exception.filter.ts @@ -0,0 +1,38 @@ +import { + ExceptionFilter, + Catch, + ArgumentsHost, + HttpException, + HttpStatus, + Logger, +} from '@nestjs/common'; +import { Request, Response } from 'express'; + +@Catch(HttpException) +export class HttpExceptionFilter implements ExceptionFilter { + private readonly logger = new Logger(HttpExceptionFilter.name); + + catch(exception: HttpException, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + const status = exception.getStatus(); + const exceptionResponse = exception.getResponse(); + + const error = + typeof response === 'string' + ? { message: exceptionResponse } + : (exceptionResponse as object); + + this.logger.error( + `HTTP Status: ${status} Error Message: ${JSON.stringify(error)}`, + ); + + response.status(status).json({ + success: false, + timestamp: new Date().toISOString(), + path: request.url, + error, + }); + } +} \ No newline at end of file diff --git a/src/common/filters/prisma-exception.filter.ts b/src/common/filters/prisma-exception.filter.ts new file mode 100644 index 0000000..b16eab9 --- /dev/null +++ b/src/common/filters/prisma-exception.filter.ts @@ -0,0 +1,38 @@ +import { ExceptionFilter, Catch, ArgumentsHost, HttpStatus } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { Response } from 'express'; + +@Catch(Prisma.PrismaClientKnownRequestError) +export class PrismaExceptionFilter implements ExceptionFilter { + catch(exception: Prisma.PrismaClientKnownRequestError, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + + let status = HttpStatus.INTERNAL_SERVER_ERROR; + let message = 'Internal server error'; + + switch (exception.code) { + case 'P2002': + status = HttpStatus.CONFLICT; + message = 'Duplicate field value'; + break; + case 'P2025': + status = HttpStatus.NOT_FOUND; + message = 'Record not found'; + break; + case 'P2003': + status = HttpStatus.BAD_REQUEST; + message = 'Foreign key constraint failed'; + break; + } + + response.status(status).json({ + success: false, + statusCode: status, + message, + timestamp: new Date().toISOString(), + path: request.url, + }); + } +} \ No newline at end of file diff --git a/src/common/guards/api-key.guard.ts b/src/common/guards/api-key.guard.ts new file mode 100644 index 0000000..2b29a4b --- /dev/null +++ b/src/common/guards/api-key.guard.ts @@ -0,0 +1,27 @@ +import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common'; +import { PrismaService } from '../../shared/services/prisma.service'; + +@Injectable() +export class ApiKeyGuard implements CanActivate { + constructor(private readonly prisma: PrismaService) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const apiKey = request.headers['x-api-key']; + + if (!apiKey) { + throw new UnauthorizedException('API key is required'); + } + + const partner = await this.prisma.partner.findUnique({ + where: { apiKey }, + }); + + if (!partner || partner.status !== 'ACTIVE') { + throw new UnauthorizedException('Invalid or inactive API key'); + } + + request.partner = partner; + return true; + } +} \ No newline at end of file diff --git a/src/common/guards/jwt-auth.guard.ts b/src/common/guards/jwt-auth.guard.ts new file mode 100644 index 0000000..64dfb27 --- /dev/null +++ b/src/common/guards/jwt-auth.guard.ts @@ -0,0 +1,24 @@ +import { Injectable, ExecutionContext } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { Reflector } from '@nestjs/core'; +import { IS_PUBLIC_KEY } from '../decorators/public.decorator'; + +@Injectable() +export class JwtAuthGuard extends AuthGuard('jwt') { + constructor(private reflector: Reflector) { + super(); + } + + canActivate(context: ExecutionContext) { + const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + + if (isPublic) { + return true; + } + + return super.canActivate(context); + } +} \ No newline at end of file diff --git a/src/common/guards/roles.guard.ts b/src/common/guards/roles.guard.ts new file mode 100644 index 0000000..2182c3d --- /dev/null +++ b/src/common/guards/roles.guard.ts @@ -0,0 +1,22 @@ +import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { ROLES_KEY } from '../decorators/roles.decorator'; + +@Injectable() +export class RolesGuard implements CanActivate { + constructor(private reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const requiredRoles = this.reflector.getAllAndOverride(ROLES_KEY, [ + context.getHandler(), + context.getClass(), + ]); + + if (!requiredRoles) { + return true; + } + + const { user } = context.switchToHttp().getRequest(); + return requiredRoles.some((role) => user.roles?.includes(role)); + } +} \ No newline at end of file diff --git a/src/common/interceptors/logging.interceptor.ts b/src/common/interceptors/logging.interceptor.ts new file mode 100644 index 0000000..b5ce9c1 --- /dev/null +++ b/src/common/interceptors/logging.interceptor.ts @@ -0,0 +1,25 @@ +import { Injectable, NestInterceptor, ExecutionContext, CallHandler, Logger } from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { tap } from 'rxjs/operators'; + +@Injectable() +export class LoggingInterceptor implements NestInterceptor { + private readonly logger = new Logger(LoggingInterceptor.name); + + intercept(context: ExecutionContext, next: CallHandler): Observable { + const request = context.switchToHttp().getRequest(); + const method = request.method; + const url = request.url; + const now = Date.now(); + + return next.handle().pipe( + tap(() => { + const response = context.switchToHttp().getResponse(); + const delay = Date.now() - now; + this.logger.log( + `${method} ${url} ${response.statusCode} - ${delay}ms`, + ); + }), + ); + } +} \ No newline at end of file diff --git a/src/common/interceptors/transform.interceptor.ts b/src/common/interceptors/transform.interceptor.ts new file mode 100644 index 0000000..5b13b7e --- /dev/null +++ b/src/common/interceptors/transform.interceptor.ts @@ -0,0 +1,26 @@ +import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +export interface Response { + success: boolean; + data: T; + timestamp: string; + path: string; +} + +@Injectable() +export class TransformInterceptor implements NestInterceptor> { + intercept(context: ExecutionContext, next: CallHandler): Observable> { + const request = context.switchToHttp().getRequest(); + + return next.handle().pipe( + map(data => ({ + success: true, + data, + timestamp: new Date().toISOString(), + path: request.url, + })), + ); + } +} \ No newline at end of file diff --git a/src/common/pipes/validation.pipe.ts b/src/common/pipes/validation.pipe.ts new file mode 100644 index 0000000..c913dfc --- /dev/null +++ b/src/common/pipes/validation.pipe.ts @@ -0,0 +1,33 @@ +import { PipeTransform, Injectable, ArgumentMetadata, BadRequestException } from '@nestjs/common'; +import { validate } from 'class-validator'; +import { plainToClass } from 'class-transformer'; + +@Injectable() +export class ValidationPipe implements PipeTransform { + async transform(value: any, { metatype }: ArgumentMetadata) { + if (!metatype || !this.toValidate(metatype)) { + return value; + } + + const object = plainToClass(metatype, value); + const errors = await validate(object); + + if (errors.length > 0) { + const errorMessages = errors.map(error => ({ + field: error.property, + errors: Object.values(error.constraints || {}), + })); + throw new BadRequestException({ + message: 'Validation failed', + errors: errorMessages, + }); + } + + return value; + } + + private toValidate(metatype: Function): boolean { + const types: Function[] = [String, Boolean, Number, Array, Object]; + return !types.includes(metatype); + } +} \ No newline at end of file diff --git a/src/config/app.config.ts b/src/config/app.config.ts index 40ac6d7..7ef8656 100644 --- a/src/config/app.config.ts +++ b/src/config/app.config.ts @@ -6,4 +6,11 @@ export default registerAs('app', () => ({ apiPrefix: process.env.API_PREFIX || 'v2', jwtSecret: process.env.JWT_SECRET || 'your-secret-key', jwtExpiresIn: process.env.JWT_EXPIRES_IN || '7d', + cors: { + origins: process.env.CORS_ORIGINS?.split(',') || ['*'], + }, + redis: { + host: process.env.REDIS_HOST || 'localhost', + port: parseInt(process.env.REDIS_PORT, 10) || 6379, + }, })); diff --git a/src/config/database.config.ts b/src/config/database.config.ts new file mode 100644 index 0000000..1d20daa --- /dev/null +++ b/src/config/database.config.ts @@ -0,0 +1,7 @@ +import { registerAs } from '@nestjs/config'; + +export default registerAs('database', () => ({ + url: process.env.DATABASE_URL, + autoLoadModels: true, + synchronize: false, // Ne jamais utiliser true en production +})); diff --git a/src/config/operators.config.ts b/src/config/operators.config.ts index dffa6d0..628e358 100644 --- a/src/config/operators.config.ts +++ b/src/config/operators.config.ts @@ -1,34 +1,6 @@ -export interface OperatorConfig { - name: string; - baseUrl: string; - authType: 'OTP' | 'REDIRECT' | 'SMS_MO' | 'USSD'; - endpoints: { - auth: { - initialize: string; - validate: string; - }; - payment: { - charge: string; - refund: string; - status: string; - }; - subscription?: { - create: string; - cancel: string; - status: string; - }; - sms: { - send: string; - }; - }; - headers: Record; - transformers: { - request: string; - response: string; - }; -} +import { registerAs } from '@nestjs/config'; -export const operatorsConfig = (): Record => ({ +export default registerAs('operators', () => ({ ORANGE_CIV: { name: 'Orange Côte d Ivoire', baseUrl: process.env.ORANGE_CIV_BASE_URL || 'https://api.bizao.com', @@ -56,6 +28,33 @@ export const operatorsConfig = (): Record => ({ response: 'OrangeResponseTransformer', }, }, + ORANGE_SEN: { + name: 'Orange Sénégal', + baseUrl: process.env.ORANGE_SEN_BASE_URL || 'https://api.bizao.com', + authType: 'OTP', + endpoints: { + auth: { + initialize: '/challenge/v1/challenges', + validate: '/challenge/v1/challenges/{challengeId}', + }, + payment: { + charge: '/payment/v1/acr%3AOrangeAPIToken/transactions/amount', + refund: '/payment/v1/refund', + status: '/payment/v1/transactions/{transactionId}', + }, + sms: { + send: '/smsmessaging/v1/outbound/tel%3A%2B{sender}/requests', + }, + }, + headers: { + 'X-OAPI-Application-Id': 'BIZAO', + 'X-Orange-MCO': 'OSN', + }, + transformers: { + request: 'OrangeRequestTransformer', + response: 'OrangeResponseTransformer', + }, + }, MTN_CMR: { name: 'MTN Cameroon', baseUrl: process.env.MTN_CMR_BASE_URL || 'https://api.mtn.cm', @@ -87,4 +86,4 @@ export const operatorsConfig = (): Record => ({ response: 'MTNResponseTransformer', }, }, -}); +})); diff --git a/src/modules/auth/strategies/api-key.strategy.ts b/src/modules/auth/strategies/api-key.strategy.ts new file mode 100644 index 0000000..1fdb18f --- /dev/null +++ b/src/modules/auth/strategies/api-key.strategy.ts @@ -0,0 +1,29 @@ +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { HeaderAPIKeyStrategy } from 'passport-headerapikey'; +import { PrismaService } from '../../../shared/services/prisma.service'; + +@Injectable() +export class ApiKeyStrategy extends PassportStrategy(HeaderAPIKeyStrategy, 'api-key') { + constructor(private readonly prisma: PrismaService) { + super( + { header: 'X-API-Key', prefix: '' }, + true, + async (apiKey, done) => { + return this.validate(apiKey, done); + }, + ); + } + + async validate(apiKey: string, done: any) { + const partner = await this.prisma.partner.findUnique({ + where: { apiKey }, + }); + + if (!partner || partner.status !== 'ACTIVE') { + return done(new UnauthorizedException(), false); + } + + return done(null, partner); + } +} \ No newline at end of file diff --git a/src/modules/notifications/dto/notification.dto.ts b/src/modules/notifications/dto/notification.dto.ts new file mode 100644 index 0000000..b04e534 --- /dev/null +++ b/src/modules/notifications/dto/notification.dto.ts @@ -0,0 +1,91 @@ +import { IsString, IsEnum, IsOptional, IsArray, IsDateString } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class SendNotificationDto { + @ApiProperty({ enum: ['PAYMENT', 'SUBSCRIPTION', 'ALERT', 'MARKETING'] }) + @IsEnum(['PAYMENT', 'SUBSCRIPTION', 'ALERT', 'MARKETING']) + type: string; + + @ApiProperty({ enum: ['SMS', 'EMAIL', 'WEBHOOK'] }) + @IsEnum(['SMS', 'EMAIL', 'WEBHOOK']) + channel: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + userToken?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + recipient?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + subject?: string; + + @ApiProperty() + @IsString() + content: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + templateId?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsDateString() + scheduledFor?: string; + + @ApiProperty({ required: false }) + @IsOptional() + metadata?: Record; +} + +export class BulkNotificationDto { + @ApiProperty({ enum: ['PAYMENT', 'SUBSCRIPTION', 'ALERT', 'MARKETING'] }) + @IsEnum(['PAYMENT', 'SUBSCRIPTION', 'ALERT', 'MARKETING']) + type: string; + + @ApiProperty({ enum: ['SMS', 'EMAIL'] }) + @IsEnum(['SMS', 'EMAIL']) + channel: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsArray() + userIds?: string[]; + + @ApiProperty({ required: false }) + @IsOptional() + @IsArray() + segments?: string[]; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + subject?: string; + + @ApiProperty() + @IsString() + content: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + templateId?: string; + + @ApiProperty({ required: false }) + @IsOptional() + variables?: Record; + + @ApiProperty() + @IsString() + batchId: string; + + @ApiProperty({ required: false }) + @IsOptional() + metadata?: Record; +} \ No newline at end of file diff --git a/src/modules/notifications/notifications.controller.ts b/src/modules/notifications/notifications.controller.ts new file mode 100644 index 0000000..a4a9539 --- /dev/null +++ b/src/modules/notifications/notifications.controller.ts @@ -0,0 +1,37 @@ +import { Controller, Post, Get, Body, Param, Query, UseGuards, Request } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { NotificationsService } from './services/notifications.service'; +import { SendNotificationDto, BulkNotificationDto } from './dto/notification.dto'; +import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; + +@ApiTags('notifications') +@Controller('notifications') +@UseGuards(JwtAuthGuard) +@ApiBearerAuth() +export class NotificationsController { + constructor(private readonly notificationsService: NotificationsService) {} + + @Post('send') + @ApiOperation({ summary: 'Send a notification' }) + async send(@Request() req, @Body() dto: SendNotificationDto) { + return this.notificationsService.send(req.user.partnerId, dto); + } + + @Post('bulk') + @ApiOperation({ summary: 'Send bulk notifications' }) + async sendBulk(@Request() req, @Body() dto: BulkNotificationDto) { + return this.notificationsService.sendBulk(req.user.partnerId, dto); + } + + @Get(':id/status') + @ApiOperation({ summary: 'Get notification status' }) + async getStatus(@Request() req, @Param('id') id: string) { + return this.notificationsService.getStatus(id, req.user.partnerId); + } + + @Get('batch/:batchId/status') + @ApiOperation({ summary: 'Get batch notification status' }) + async getBatchStatus(@Request() req, @Param('batchId') batchId: string) { + return this.notificationsService.getBatchStatus(batchId, req.user.partnerId); + } +} diff --git a/src/modules/notifications/notifications.module.ts b/src/modules/notifications/notifications.module.ts new file mode 100644 index 0000000..4a52a9e --- /dev/null +++ b/src/modules/notifications/notifications.module.ts @@ -0,0 +1,40 @@ +import { Module } from '@nestjs/common'; +import { BullModule } from '@nestjs/bull'; +import { HttpModule } from '@nestjs/axios'; +import { NotificationsController } from './notifications.controller'; +import { NotificationsService } from './notifications.service'; +import { SmsService } from './services/sms.service'; +import { EmailService } from './services/email.service'; +import { WebhookService } from './services/webhook.service'; +import { NotificationProcessor } from './processors/notification.processor'; +import { NotificationTemplateService } from './services/template.service'; +import { PrismaService } from '../../shared/services/prisma.service'; +import { OperatorsModule } from '../operators/operators.module'; + +@Module({ + imports: [ + BullModule.registerQueue({ + name: 'notifications', + }), + BullModule.registerQueue({ + name: 'webhooks', + }), + HttpModule.register({ + timeout: 10000, + maxRedirects: 2, + }), + OperatorsModule, + ], + controllers: [NotificationsController], + providers: [ + NotificationsService, + SmsService, + EmailService, + WebhookService, + NotificationProcessor, + NotificationTemplateService, + PrismaService, + ], + exports: [NotificationsService, SmsService, WebhookService], +}) +export class NotificationsModule {} \ No newline at end of file diff --git a/src/modules/notifications/processors/notification.processor.ts b/src/modules/notifications/processors/notification.processor.ts new file mode 100644 index 0000000..1f55e25 --- /dev/null +++ b/src/modules/notifications/processors/notification.processor.ts @@ -0,0 +1,55 @@ +import { Process, Processor } from '@nestjs/bull'; +import { Job } from 'bull'; +import { NotificationsService } from '../notifications.service'; +import { WebhookService } from '../services/webhook.service'; + +@Processor('notifications') +export class NotificationProcessor { + constructor( + private readonly notificationsService: NotificationsService, + ) {} + + @Process('send-notification') + async handleSendNotification(job: Job) { + const { notificationId } = job.data; + + try { + await this.notificationsService.processNotification(notificationId); + return { success: true, notificationId }; + } catch (error) { + console.error(`Failed to send notification ${notificationId}:`, error); + throw error; + } + } + + @Process('bulk-send') + async handleBulkSend(job: Job) { + const { notifications } = job.data; + const results = []; + + for (const notificationId of notifications) { + try { + await this.notificationsService.processNotification(notificationId); + results.push({ notificationId, success: true }); + } catch (error) { + results.push({ notificationId, success: false, error: error.message }); + } + } + + return results; + } +} + +@Processor('webhooks') +export class WebhookProcessor { + constructor( + private readonly webhookService: WebhookService, + ) {} + + @Process('send-webhook') + async handleSendWebhook(job: Job) { + const { webhookId, attempt } = job.data; + + return await this.webhookService.processWebhook(webhookId, attempt); + } +} \ No newline at end of file diff --git a/src/modules/notifications/services/notifications.service.ts b/src/modules/notifications/services/notifications.service.ts new file mode 100644 index 0000000..891f0d9 --- /dev/null +++ b/src/modules/notifications/services/notifications.service.ts @@ -0,0 +1,290 @@ +import { Injectable, BadRequestException } from '@nestjs/common'; +import { InjectQueue } from '@nestjs/bull'; +import { Queue } from 'bull'; +import { PrismaService } from '../../shared/services/prisma.service'; +import { SmsService } from './services/sms.service'; +import { EmailService } from './services/email.service'; +import { WebhookService } from './services/webhook.service'; +import { NotificationTemplateService } from './services/template.service'; +import { SendNotificationDto, BulkNotificationDto } from './dto/notification.dto'; + +@Injectable() +export class NotificationsService { + constructor( + private readonly prisma: PrismaService, + private readonly smsService: SmsService, + private readonly emailService: EmailService, + private readonly webhookService: WebhookService, + private readonly templateService: NotificationTemplateService, + @InjectQueue('notifications') private notificationQueue: Queue, + ) {} + + async send(partnerId: string, dto: SendNotificationDto) { + // Valider l'utilisateur si fourni + let user = null; + if (dto.userToken) { + user = await this.prisma.user.findFirst({ + where: { + userToken: dto.userToken, + partnerId: partnerId, + }, + include: { operator: true }, + }); + + if (!user) { + throw new BadRequestException('Invalid user token'); + } + } + + // Créer l'enregistrement de notification + const notification = await this.prisma.notification.create({ + data: { + partnerId: partnerId, + userId: user?.id, + type: dto.type, + channel: dto.channel, + recipient: dto.recipient || user?.msisdn, + subject: dto.subject, + content: dto.content, + templateId: dto.templateId, + status: 'PENDING', + metadata: dto.metadata, + scheduledFor: dto.scheduledFor, + }, + }); + + // Si programmée pour plus tard, ajouter à la queue avec délai + if (dto.scheduledFor && new Date(dto.scheduledFor) > new Date()) { + const delay = new Date(dto.scheduledFor).getTime() - Date.now(); + await this.notificationQueue.add( + 'send-notification', + { notificationId: notification.id }, + { delay }, + ); + } else { + // Envoyer immédiatement + await this.processNotification(notification.id); + } + + return notification; + } + + async sendBulk(partnerId: string, dto: BulkNotificationDto) { + const notifications = []; + + // Récupérer les destinataires selon les critères + const recipients = await this.getRecipients(partnerId, dto); + + for (const recipient of recipients) { + const notification = await this.prisma.notification.create({ + data: { + partnerId: partnerId, + userId: recipient.userId, + type: dto.type, + channel: dto.channel, + recipient: recipient.contact, + subject: dto.subject, + content: await this.templateService.render(dto.templateId, { + ...recipient.data, + ...dto.variables, + }), + templateId: dto.templateId, + status: 'PENDING', + batchId: dto.batchId, + metadata: dto.metadata, + }, + }); + + notifications.push(notification); + + // Ajouter à la queue avec rate limiting + await this.notificationQueue.add( + 'send-notification', + { notificationId: notification.id }, + { + delay: notifications.length * 100, // 100ms entre chaque envoi + attempts: 3, + }, + ); + } + + return { + batchId: dto.batchId, + totalRecipients: notifications.length, + status: 'PROCESSING', + }; + } + + async processNotification(notificationId: string) { + const notification = await this.prisma.notification.findUnique({ + where: { id: notificationId }, + include: { + user: true, + partner: true, + }, + }); + + if (!notification) { + throw new BadRequestException('Notification not found'); + } + + try { + let result; + + switch (notification.channel) { + case 'SMS': + result = await this.smsService.send({ + to: notification.recipient, + message: notification.content, + userToken: notification.user?.userToken, + userAlias: notification.user?.userAlias, + from: notification.metadata?.from, + }); + break; + + case 'EMAIL': + result = await this.emailService.send({ + to: notification.recipient, + subject: notification.subject, + content: notification.content, + template: notification.templateId, + }); + break; + + case 'WEBHOOK': + result = await this.webhookService.send({ + url: notification.recipient, + event: notification.type, + payload: { + subject: notification.subject, + content: notification.content, + metadata: notification.metadata, + }, + }); + break; + + default: + throw new BadRequestException(`Unsupported channel: ${notification.channel}`); + } + + // Mettre à jour le statut + await this.prisma.notification.update({ + where: { id: notificationId }, + data: { + status: 'SENT', + sentAt: new Date(), + response: result, + }, + }); + + return result; + } catch (error) { + await this.prisma.notification.update({ + where: { id: notificationId }, + data: { + status: 'FAILED', + failureReason: error.message, + failedAt: new Date(), + }, + }); + throw error; + } + } + + async getStatus(notificationId: string, partnerId: string) { + const notification = await this.prisma.notification.findFirst({ + where: { + id: notificationId, + partnerId: partnerId, + }, + }); + + if (!notification) { + throw new BadRequestException('Notification not found'); + } + + return notification; + } + + async getBatchStatus(batchId: string, partnerId: string) { + const notifications = await this.prisma.notification.findMany({ + where: { + batchId: batchId, + partnerId: partnerId, + }, + select: { + status: true, + }, + }); + + const statusCount = notifications.reduce((acc, n) => { + acc[n.status] = (acc[n.status] || 0) + 1; + return acc; + }, {}); + + return { + batchId, + total: notifications.length, + statusBreakdown: statusCount, + }; + } + + private async getRecipients(partnerId: string, dto: BulkNotificationDto) { + const recipients = []; + + if (dto.userIds && dto.userIds.length > 0) { + const users = await this.prisma.user.findMany({ + where: { + id: { in: dto.userIds }, + partnerId: partnerId, + }, + }); + + for (const user of users) { + recipients.push({ + userId: user.id, + contact: dto.channel === 'SMS' ? user.msisdn : user.email, + data: { name: user.name, msisdn: user.msisdn }, + }); + } + } + + if (dto.segments) { + // Logique pour récupérer les utilisateurs par segments + const segmentUsers = await this.getSegmentUsers(partnerId, dto.segments); + recipients.push(...segmentUsers); + } + + return recipients; + } + + private async getSegmentUsers(partnerId: string, segments: string[]) { + const users = []; + + for (const segment of segments) { + switch (segment) { + case 'ACTIVE_SUBSCRIBERS': + const activeUsers = await this.prisma.user.findMany({ + where: { + partnerId: partnerId, + subscriptions: { + some: { + status: 'ACTIVE', + }, + }, + }, + }); + users.push(...activeUsers.map(u => ({ + userId: u.id, + contact: u.msisdn, + data: { name: u.name, msisdn: u.msisdn }, + }))); + break; + + // Ajouter d'autres segments + } + } + + return users; + } +} \ No newline at end of file diff --git a/src/modules/notifications/services/sms.service.ts b/src/modules/notifications/services/sms.service.ts new file mode 100644 index 0000000..ebe4c19 --- /dev/null +++ b/src/modules/notifications/services/sms.service.ts @@ -0,0 +1,95 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { OperatorsService } from '../../operators/operators.service'; +import { PrismaService } from '../../../shared/services/prisma.service'; + +@Injectable() +export class SmsService { + constructor( + private readonly operatorsService: OperatorsService, + private readonly prisma: PrismaService, + private readonly configService: ConfigService, + ) {} + + async send(params: { + to: string; + message: string; + userToken?: string; + userAlias?: string; + from?: string; + }) { + // Si on a un userToken, utiliser l'opérateur de l'utilisateur + if (params.userToken) { + const user = await this.prisma.user.findUnique({ + where: { userToken: params.userToken }, + include: { operator: true }, + }); + + if (user) { + const adapter = this.operatorsService.getAdapter( + user.operator.code, + user.country, + ); + + return await adapter.sendSms({ + to: params.to, + message: params.message, + userToken: params.userToken, + userAlias: params.userAlias, + from: params.from, + country: user.country, + }); + } + } + + // Sinon, détecter l'opérateur par le numéro + const operator = this.detectOperatorByNumber(params.to); + const adapter = this.operatorsService.getAdapter(operator.code, operator.country); + + return await adapter.sendSms({ + to: params.to, + message: params.message, + from: params.from, + country: operator.country, + }); + } + + async sendOtp(msisdn: string, code: string, template?: string) { + const message = template + ? template.replace('{code}', code) + : `Your verification code is: ${code}`; + + return this.send({ + to: msisdn, + message: message, + }); + } + + async sendTransactional(params: { + to: string; + template: string; + variables: Record; + userToken?: string; + }) { + // Remplacer les variables dans le template + let message = params.template; + for (const [key, value] of Object.entries(params.variables)) { + message = message.replace(new RegExp(`{${key}}`, 'g'), value); + } + + return this.send({ + to: params.to, + message: message, + userToken: params.userToken, + }); + } + + private detectOperatorByNumber(msisdn: string) { + // Logique de détection basée sur le préfixe + // Pour simplifier, on retourne Orange CI par défaut + return { + code: 'ORANGE', + country: 'CI', + }; + } +} \ No newline at end of file diff --git a/src/modules/notifications/services/webhook.service.ts b/src/modules/notifications/services/webhook.service.ts new file mode 100644 index 0000000..c8d7cca --- /dev/null +++ b/src/modules/notifications/services/webhook.service.ts @@ -0,0 +1,126 @@ +import { Injectable } from '@nestjs/common'; +import { HttpService } from '@nestjs/axios'; +import { InjectQueue } from '@nestjs/bull'; +import { Queue } from 'bull'; +import { firstValueFrom } from 'rxjs'; +import { PrismaService } from '../../../shared/services/prisma.service'; +import * as crypto from 'crypto'; + +@Injectable() +export class WebhookService { + constructor( + private readonly httpService: HttpService, + private readonly prisma: PrismaService, + @InjectQueue('webhooks') private webhookQueue: Queue, + ) {} + + async send(params: { + url: string; + event: string; + payload: any; + partnerId?: string; + retries?: number; + }) { + const webhook = await this.prisma.webhook.create({ + data: { + url: params.url, + event: params.event, + payload: params.payload, + status: 'PENDING', + partnerId: params.partnerId, + }, + }); + + // Ajouter à la queue + await this.webhookQueue.add( + 'send-webhook', + { + webhookId: webhook.id, + attempt: 1, + }, + { + attempts: params.retries || 3, + backoff: { + type: 'exponential', + delay: 2000, + }, + }, + ); + + return webhook; + } + + async processWebhook(webhookId: string, attempt: number) { + const webhook = await this.prisma.webhook.findUnique({ + where: { id: webhookId }, + include: { partner: true }, + }); + + if (!webhook) { + throw new Error('Webhook not found'); + } + + try { + const signature = this.generateSignature( + webhook.payload, + webhook.partner?.secretKey, + ); + + const response = await firstValueFrom( + this.httpService.post(webhook.url, webhook.payload, { + headers: { + 'Content-Type': 'application/json', + 'X-Webhook-Event': webhook.event, + 'X-Webhook-Signature': signature, + 'X-Webhook-Timestamp': new Date().toISOString(), + 'X-Webhook-Attempt': attempt.toString(), + }, + timeout: 10000, + }), + ); + + await this.prisma.webhook.update({ + where: { id: webhookId }, + data: { + status: 'SUCCESS', + response: response.data, + responseCode: response.status, + deliveredAt: new Date(), + attempts: attempt, + }, + }); + + return response.data; + } catch (error) { + await this.prisma.webhook.update({ + where: { id: webhookId }, + data: { + status: attempt >= 3 ? 'FAILED' : 'RETRYING', + lastError: error.message, + attempts: attempt, + lastAttempt: new Date(), + }, + }); + + if (attempt >= 3) { + // Notifier l'échec définitif + await this.notifyWebhookFailure(webhook); + } + + throw error; + } + } + + private generateSignature(payload: any, secret: string): string { + if (!secret) return ''; + + const hmac = crypto.createHmac('sha256', secret); + hmac.update(JSON.stringify(payload)); + return hmac.digest('hex'); + } + + private async notifyWebhookFailure(webhook: any) { + // Envoyer un email ou une notification au partenaire + console.error(`Webhook failed after max retries: ${webhook.id}`); + } +} \ No newline at end of file diff --git a/src/modules/subscriptions/dto/plan.dto.ts b/src/modules/subscriptions/dto/plan.dto.ts new file mode 100644 index 0000000..0def4a1 --- /dev/null +++ b/src/modules/subscriptions/dto/plan.dto.ts @@ -0,0 +1,122 @@ +import { + IsString, + IsNumber, + IsEnum, + IsOptional, + IsArray, + IsBoolean, + Min, + MaxLength +} from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class CreatePlanDto { + @ApiProperty() + @IsString() + @MaxLength(50) + code: string; + + @ApiProperty() + @IsString() + @MaxLength(100) + name: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + @MaxLength(500) + description?: string; + + @ApiProperty() + @IsNumber() + @Min(0) + amount: number; + + @ApiProperty() + @IsString() + currency: string; + + @ApiProperty({ enum: ['DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY'] }) + @IsEnum(['DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY']) + interval: string; + + @ApiProperty({ required: false, default: 1 }) + @IsOptional() + @IsNumber() + @Min(1) + intervalCount?: number; + + @ApiProperty({ required: false, default: 0 }) + @IsOptional() + @IsNumber() + @Min(0) + trialDays?: number; + + @ApiProperty({ required: false }) + @IsOptional() + @IsArray() + features?: string[]; + + @ApiProperty({ required: false }) + @IsOptional() + limits?: Record; + + @ApiProperty({ required: false }) + @IsOptional() + metadata?: Record; +} + +export class UpdatePlanDto { + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + @MaxLength(100) + name?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + @MaxLength(500) + description?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsNumber() + @Min(0) + amount?: number; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + currency?: string; + + @ApiProperty({ required: false, enum: ['DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY'] }) + @IsOptional() + @IsEnum(['DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY']) + interval?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsNumber() + @Min(1) + intervalCount?: number; + + @ApiProperty({ required: false }) + @IsOptional() + @IsNumber() + @Min(0) + trialDays?: number; + + @ApiProperty({ required: false }) + @IsOptional() + @IsArray() + features?: string[]; + + @ApiProperty({ required: false }) + @IsOptional() + limits?: Record; + + @ApiProperty({ required: false }) + @IsOptional() + metadata?: Record; +} \ No newline at end of file diff --git a/src/modules/subscriptions/dto/subscription.dto.ts b/src/modules/subscriptions/dto/subscription.dto.ts new file mode 100644 index 0000000..c1c3d6d --- /dev/null +++ b/src/modules/subscriptions/dto/subscription.dto.ts @@ -0,0 +1,48 @@ +import { IsString, IsOptional, IsNumber, IsEnum, IsBoolean, Min } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class CreateSubscriptionDto { + @ApiProperty() + @IsString() + userToken: string; + + @ApiProperty() + @IsString() + planId: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsNumber() + @Min(0) + trialDays?: number; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + callbackUrl?: string; + + @ApiProperty({ required: false }) + @IsOptional() + metadata?: Record; +} + +export class UpdateSubscriptionDto { + @ApiProperty({ required: false, enum: ['ACTIVE', 'PAUSED'] }) + @IsOptional() + @IsEnum(['ACTIVE', 'PAUSED']) + status?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + planId?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsBoolean() + immediate?: boolean; + + @ApiProperty({ required: false }) + @IsOptional() + metadata?: Record; +} \ No newline at end of file diff --git a/src/modules/subscriptions/processors/subscription.processor.ts b/src/modules/subscriptions/processors/subscription.processor.ts new file mode 100644 index 0000000..d28a490 --- /dev/null +++ b/src/modules/subscriptions/processors/subscription.processor.ts @@ -0,0 +1,73 @@ +import { Process, Processor } from '@nestjs/bull'; +import { Job } from 'bull'; +import { SubscriptionsService } from '../subscriptions.service'; +import { HttpService } from '@nestjs/axios'; +import { firstValueFrom } from 'rxjs'; + +@Processor('subscriptions') +export class SubscriptionProcessor { + constructor( + private readonly subscriptionsService: SubscriptionsService, + private readonly httpService: HttpService, + ) {} + + @Process('webhook-notification') + async handleWebhookNotification(job: Job) { + const { url, event, subscription, payment } = job.data; + + try { + const payload = { + event, + subscription, + payment, + timestamp: new Date().toISOString(), + }; + + const response = await firstValueFrom( + this.httpService.post(url, payload, { + headers: { + 'Content-Type': 'application/json', + 'X-Webhook-Event': event, + }, + }), + ); + + return { + success: true, + status: response.status, + }; + } catch (error) { + console.error(`Webhook notification failed for ${event}:`, error.message); + throw error; + } + } +} + +@Processor('billing') +export class BillingProcessor { + constructor( + private readonly subscriptionsService: SubscriptionsService, + ) {} + + @Process('process-renewal') + async handleRenewal(job: Job) { + const { subscriptionId } = job.data; + await this.subscriptionsService.processRenewal(subscriptionId); + } + + @Process('trial-end') + async handleTrialEnd(job: Job) { + const { subscriptionId } = job.data; + + // Convertir de TRIAL à ACTIVE et traiter le premier paiement + await this.subscriptionsService.processRenewal(subscriptionId); + } + + @Process('retry-renewal') + async handleRetryRenewal(job: Job) { + const { subscriptionId, attempt } = job.data; + + console.log(`Retrying renewal for subscription ${subscriptionId}, attempt ${attempt}`); + await this.subscriptionsService.processRenewal(subscriptionId); + } +} \ No newline at end of file diff --git a/src/modules/subscriptions/schedulers/subscription.scheduler.ts b/src/modules/subscriptions/schedulers/subscription.scheduler.ts new file mode 100644 index 0000000..2c1aa91 --- /dev/null +++ b/src/modules/subscriptions/schedulers/subscription.scheduler.ts @@ -0,0 +1,82 @@ +import { Injectable } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { InjectQueue } from '@nestjs/bull'; +import { Queue } from 'bull'; +import { PrismaService } from '../../../shared/services/prisma.service'; + +@Injectable() +export class SubscriptionScheduler { + constructor( + private readonly prisma: PrismaService, + @InjectQueue('billing') private billingQueue: Queue, + ) {} + + @Cron(CronExpression.EVERY_HOUR) + async checkSubscriptionsForRenewal() { + const now = new Date(); + + // Récupérer les subscriptions à renouveler + const subscriptions = await this.prisma.subscription.findMany({ + where: { + status: 'ACTIVE', + nextBillingDate: { + lte: now, + }, + }, + }); + + for (const subscription of subscriptions) { + // Ajouter à la queue de facturation + await this.billingQueue.add('process-renewal', { + subscriptionId: subscription.id, + }); + } + + console.log(`Scheduled ${subscriptions.length} subscriptions for renewal`); + } + + @Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT) + async checkTrialExpirations() { + const now = new Date(); + + // Récupérer les subscriptions dont la période d'essai se termine + const expiringTrials = await this.prisma.subscription.findMany({ + where: { + status: 'TRIAL', + trialEndsAt: { + lte: now, + }, + }, + }); + + for (const subscription of expiringTrials) { + await this.billingQueue.add('trial-end', { + subscriptionId: subscription.id, + }); + } + + console.log(`Found ${expiringTrials.length} expiring trials`); + } + + @Cron(CronExpression.EVERY_DAY_AT_1AM) + async cleanupExpiredSubscriptions() { + const thirtyDaysAgo = new Date(); + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); + + // Marquer comme expirées les subscriptions suspendues depuis plus de 30 jours + const result = await this.prisma.subscription.updateMany({ + where: { + status: 'SUSPENDED', + suspendedAt: { + lte: thirtyDaysAgo, + }, + }, + data: { + status: 'EXPIRED', + expiredAt: new Date(), + }, + }); + + console.log(`Marked ${result.count} subscriptions as expired`); + } +} \ No newline at end of file diff --git a/src/modules/subscriptions/services/billing.service.ts b/src/modules/subscriptions/services/billing.service.ts new file mode 100644 index 0000000..510578a --- /dev/null +++ b/src/modules/subscriptions/services/billing.service.ts @@ -0,0 +1,197 @@ +import { Injectable, BadRequestException } 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'; + +@Injectable() +export class BillingService { + constructor( + private readonly prisma: PrismaService, + private readonly paymentsService: PaymentsService, + @InjectQueue('billing') private billingQueue: Queue, + ) {} + + async processBilling(subscriptionId: string) { + const subscription = await this.prisma.subscription.findUnique({ + where: { id: subscriptionId }, + include: { + user: true, + plan: true, + partner: true, + }, + }); + + if (!subscription) { + throw new Error(`Subscription ${subscriptionId} not found`); + } + + // Créer une facture + const invoice = await this.createInvoice(subscription); + + try { + // Traiter le paiement + const payment = await this.paymentsService.createCharge({ + userToken: subscription.user.userToken, + amount: subscription.plan.amount, + currency: subscription.plan.currency, + description: `Invoice #${invoice.number}`, + reference: `INV-${invoice.id}`, + metadata: { + subscriptionId: subscription.id, + invoiceId: invoice.id, + billingPeriod: { + start: subscription.currentPeriodStart, + end: subscription.currentPeriodEnd, + }, + }, + }); + + // Mettre à jour la facture + await this.prisma.invoice.update({ + where: { id: invoice.id }, + data: { + status: payment.status === 'SUCCESS' ? 'PAID' : 'FAILED', + paymentId: payment.id, + paidAt: payment.status === 'SUCCESS' ? new Date() : null, + }, + }); + + return { invoice, payment }; + } catch (error) { + // Marquer la facture comme échouée + await this.prisma.invoice.update({ + where: { id: invoice.id }, + data: { + status: 'FAILED', + failureReason: error.message, + }, + }); + + throw error; + } + } + + async createInvoice(subscription: any) { + const invoiceNumber = await this.generateInvoiceNumber(subscription.partnerId); + + const invoice = await this.prisma.invoice.create({ + data: { + number: invoiceNumber, + subscriptionId: subscription.id, + userId: subscription.userId, + partnerId: subscription.partnerId, + amount: subscription.plan.amount, + currency: subscription.plan.currency, + status: 'PENDING', + billingPeriodStart: subscription.currentPeriodStart, + billingPeriodEnd: subscription.currentPeriodEnd, + dueDate: new Date(), + items: [ + { + description: subscription.plan.name, + quantity: 1, + unitPrice: subscription.plan.amount, + total: subscription.plan.amount, + }, + ], + }, + }); + + return invoice; + } + + async retryFailedBillings(partnerId?: string) { + const where: any = { + status: 'FAILED', + attempts: { lt: 3 }, + }; + + if (partnerId) { + where.partnerId = partnerId; + } + + const failedInvoices = await this.prisma.invoice.findMany({ + where, + include: { + subscription: true, + }, + }); + + for (const invoice of failedInvoices) { + await this.billingQueue.add( + 'retry-billing', + { + subscriptionId: invoice.subscriptionId, + invoiceId: invoice.id, + attempt: invoice.attempts + 1, + }, + { + delay: Math.pow(2, invoice.attempts) * 60 * 60 * 1000, // Exponential backoff + }, + ); + } + + return { + scheduled: failedInvoices.length, + message: `Scheduled ${failedInvoices.length} invoices for retry`, + }; + } + + async getInvoices(subscriptionId: string) { + const invoices = await this.prisma.invoice.findMany({ + where: { subscriptionId }, + orderBy: { createdAt: 'desc' }, + }); + + return invoices; + } + + async getInvoice(invoiceId: string, partnerId: string) { + const invoice = await this.prisma.invoice.findFirst({ + where: { + id: invoiceId, + partnerId: partnerId, + }, + include: { + subscription: { + include: { + plan: true, + user: true, + }, + }, + payment: true, + }, + }); + + if (!invoice) { + throw new NotFoundException('Invoice not found'); + } + + return invoice; + } + + private async generateInvoiceNumber(partnerId: string): Promise { + const date = new Date(); + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + + const lastInvoice = await this.prisma.invoice.findFirst({ + where: { + partnerId, + number: { + startsWith: `INV-${year}${month}`, + }, + }, + orderBy: { number: 'desc' }, + }); + + let sequence = 1; + if (lastInvoice) { + const lastSequence = parseInt(lastInvoice.number.split('-')[2] || '0'); + sequence = lastSequence + 1; + } + + return `INV-${year}${month}-${String(sequence).padStart(4, '0')}`; + } +} \ No newline at end of file diff --git a/src/modules/subscriptions/services/plan.service.ts b/src/modules/subscriptions/services/plan.service.ts new file mode 100644 index 0000000..cda973c --- /dev/null +++ b/src/modules/subscriptions/services/plan.service.ts @@ -0,0 +1,337 @@ +import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../../../shared/services/prisma.service'; +import { CreatePlanDto, UpdatePlanDto } from '../dto/plan.dto'; +import { Prisma } from '@prisma/client'; + +@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: { ...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, + expiredAt: true, + }, + }); + + if (subscriptions.length === 0) return 0; + + const lifetimes = subscriptions.map(sub => { + const endDate = sub.cancelledAt || sub.expiredAt || new Date(); + return endDate.getTime() - sub.createdAt.getTime(); + }); + + const avgLifetime = lifetimes.reduce((a, b) => a + b, 0) / lifetimes.length; + return Math.floor(avgLifetime / (1000 * 60 * 60 * 24)); // Retourner en jours + } +} \ No newline at end of file diff --git a/src/modules/subscriptions/subscriptions.controller.ts b/src/modules/subscriptions/subscriptions.controller.ts new file mode 100644 index 0000000..741583b --- /dev/null +++ b/src/modules/subscriptions/subscriptions.controller.ts @@ -0,0 +1,80 @@ +import { + Controller, + Post, + Get, + Patch, + Delete, + Body, + Param, + Query, + UseGuards, + Request, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { SubscriptionsService } from './subscriptions.service'; +import { CreateSubscriptionDto, UpdateSubscriptionDto } from './dto/subscription.dto'; +import { JwtAuthGuard } from 'src/common/guards/jwt-auth.guard'; + +@ApiTags('subscriptions') +@Controller('subscriptions') +@UseGuards(JwtAuthGuard) +@ApiBearerAuth() +export class SubscriptionsController { + 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); + } + + @Get() + @ApiOperation({ summary: 'List subscriptions' }) + async list( + @Request() req, + @Query('status') status?: string, + @Query('userId') userId?: string, + @Query('page') page = 1, + @Query('limit') limit = 20, + ) { + return this.subscriptionsService.list({ + partnerId: req.user.partnerId, + status, + userId, + page, + limit, + }); + } + + @Get(':id') + @ApiOperation({ summary: 'Get subscription details' }) + async get(@Request() req, @Param('id') id: string) { + return this.subscriptionsService.get(id, req.user.partnerId); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update subscription' }) + async update( + @Request() req, + @Param('id') id: string, + @Body() dto: UpdateSubscriptionDto, + ) { + return this.subscriptionsService.update(id, req.user.partnerId, dto); + } + + @Delete(':id') + @ApiOperation({ summary: 'Cancel subscription' }) + async cancel( + @Request() req, + @Param('id') id: string, + @Body('reason') reason?: string, + ) { + return this.subscriptionsService.cancel(id, req.user.partnerId, reason); + } + + @Get(':id/invoices') + @ApiOperation({ summary: 'Get subscription invoices' }) + async getInvoices(@Request() req, @Param('id') id: string) { + return this.subscriptionsService.getInvoices(id, req.user.partnerId); + } +} \ No newline at end of file diff --git a/src/modules/subscriptions/subscriptions.module.ts b/src/modules/subscriptions/subscriptions.module.ts new file mode 100644 index 0000000..ad1f132 --- /dev/null +++ b/src/modules/subscriptions/subscriptions.module.ts @@ -0,0 +1,35 @@ +import { Module } from '@nestjs/common'; +import { BullModule } from '@nestjs/bull'; +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 { PrismaService } from '../../shared/services/prisma.service'; +import { PaymentsModule } from '../payments/payments.module'; +import { NotificationsModule } from '../notifications/notifications.module'; + +@Module({ + imports: [ + BullModule.registerQueue({ + name: 'subscriptions', + }), + BullModule.registerQueue({ + name: 'billing', + }), + PaymentsModule, + NotificationsModule, + ], + controllers: [SubscriptionsController], + providers: [ + SubscriptionsService, + SubscriptionScheduler, + SubscriptionProcessor, + PlanService, + BillingService, + PrismaService, + ], + exports: [SubscriptionsService, PlanService], +}) +export class SubscriptionsModule {} diff --git a/src/modules/subscriptions/subscriptions.service.ts b/src/modules/subscriptions/subscriptions.service.ts index 67d09ae..6847b57 100644 --- a/src/modules/subscriptions/subscriptions.service.ts +++ b/src/modules/subscriptions/subscriptions.service.ts @@ -1,7 +1,9 @@ -import { Injectable } from '@nestjs/common'; -import { Cron, CronExpression } from '@nestjs/schedule'; +import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common'; +import { InjectQueue } from '@nestjs/bull'; +import { Queue } 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'; @Injectable() @@ -9,206 +11,435 @@ export class SubscriptionsService { constructor( private readonly prisma: PrismaService, private readonly paymentsService: PaymentsService, + @InjectQueue('subscriptions') private subscriptionQueue: Queue, + @InjectQueue('billing') private billingQueue: Queue, ) {} - async createSubscription(dto: CreateSubscriptionDto) { + async create(partnerId: string, dto: CreateSubscriptionDto) { // Vérifier l'utilisateur - const user = await this.prisma.user.findUnique({ - where: { userToken: dto.userToken }, + const user = await this.prisma.user.findFirst({ + where: { + userToken: dto.userToken, + partnerId: partnerId, + }, + include: { operator: true }, }); if (!user) { - throw new BadRequestException('Invalid user token'); + throw new BadRequestException('Invalid user token for this partner'); } - // Récupérer le plan + // Vérifier le plan const plan = await this.prisma.plan.findUnique({ where: { id: dto.planId }, }); - if (!plan) { - throw new BadRequestException('Invalid plan'); + 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, + status: { in: ['ACTIVE', 'TRIAL'] }, + }, + }); + + if (existingSubscription) { + 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, - status: SubscriptionStatus.PENDING, - currentPeriodStart: new Date(), - currentPeriodEnd: this.calculatePeriodEnd(plan), - nextBillingDate: this.calculateNextBillingDate(plan), - metadata: dto.metadata, + partnerId: partnerId, + status: hasTrialPeriod ? 'TRIAL' : 'PENDING', + currentPeriodStart, + currentPeriodEnd, + nextBillingDate, + trialEndsAt, + amount: plan.amount, + currency: plan.currency, + metadata: { + ...dto.metadata, + userAlias: user.userAlias, + operator: user.operator.code, + country: user.country, + }, + }, + include: { + plan: true, + user: true, }, }); - // Traiter le premier paiement - if (!dto.trialPeriod) { - try { - const payment = await this.paymentsService.createCharge({ - userToken: user.userToken, - amount: plan.amount, - currency: plan.currency, - description: `Subscription to ${plan.name}`, - reference: `SUB-${subscription.id}-${Date.now()}`, - metadata: { - subscriptionId: subscription.id, - planId: plan.id, - }, - }); - - if (payment.status === 'SUCCESS') { - await this.prisma.subscription.update({ - where: { id: subscription.id }, - data: { - status: SubscriptionStatus.ACTIVE, - lastPaymentId: payment.id, - }, - }); - } - } catch (error) { - await this.prisma.subscription.update({ - where: { id: subscription.id }, - data: { status: SubscriptionStatus.FAILED }, - }); - throw error; - } + // Si pas de période d'essai, traiter le premier paiement + if (!hasTrialPeriod) { + await this.processInitialPayment(subscription, dto.callbackUrl); } else { - // Activer en période d'essai + // Activer directement en période d'essai await this.prisma.subscription.update({ where: { id: subscription.id }, - data: { - status: SubscriptionStatus.TRIAL, - trialEndsAt: this.calculateTrialEnd(dto.trialPeriod), - }, + 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) { + await this.subscriptionQueue.add('webhook-notification', { + url: dto.callbackUrl, + event: 'SUBSCRIPTION_CREATED', + subscription: subscription, }); } return subscription; } - @Cron(CronExpression.EVERY_HOUR) - async processRecurringPayments() { - // Récupérer les subscriptions à renouveler - const subscriptions = await this.prisma.subscription.findMany({ - where: { - status: { in: [SubscriptionStatus.ACTIVE, SubscriptionStatus.TRIAL] }, - nextBillingDate: { - lte: new Date(), + async update(subscriptionId: string, partnerId: string, dto: UpdateSubscriptionDto) { + const subscription = await this.prisma.subscription.findFirst({ + where: { + id: subscriptionId, + partnerId: partnerId, + }, + }); + + if (!subscription) { + 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 === 'PAUSED') { + 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 = { ...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') { + throw new BadRequestException('Subscription already cancelled'); + } + + const updatedSubscription = await this.prisma.subscription.update({ + where: { id: subscriptionId }, + data: { + status: 'CANCELLED', + cancelledAt: new Date(), + cancellationReason: reason, + metadata: { + ...subscription.metadata, + cancellationDetails: { + reason, + cancelledBy: 'partner', + timestamp: new Date(), + }, }, }, + }); + + // Annuler les jobs de facturation programmés + const jobs = await this.billingQueue.getJobs(['delayed', 'waiting']); + for (const job of jobs) { + if (job.data.subscriptionId === subscriptionId) { + await job.remove(); + } + } + + // Notifier via webhook + const partner = await this.prisma.partner.findUnique({ + where: { id: partnerId }, + }); + + if (partner?.callbacks?.subscription?.onCancel) { + await this.subscriptionQueue.add('webhook-notification', { + url: partner.callbacks.subscription.onCancel, + event: 'SUBSCRIPTION_CANCELLED', + subscription: updatedSubscription, + }); + } + + return updatedSubscription; + } + + async processRenewal(subscriptionId: string) { + const subscription = await this.prisma.subscription.findUnique({ + where: { id: subscriptionId }, include: { user: true, plan: true, + partner: true, }, }); - for (const subscription of subscriptions) { - try { - // Essayer de facturer - const payment = await this.paymentsService.createCharge({ - userToken: subscription.user.userToken, - amount: subscription.plan.amount, - currency: subscription.plan.currency, - description: `Renewal: ${subscription.plan.name}`, - reference: `REN-${subscription.id}-${Date.now()}`, - metadata: { - subscriptionId: subscription.id, - renewal: true, + if (!subscription) { + throw new NotFoundException('Subscription not found'); + } + + if (subscription.status !== 'ACTIVE') { + console.log(`Skipping renewal for non-active subscription ${subscriptionId}`); + return; + } + + try { + // Créer le paiement de renouvellement + const payment = await this.paymentsService.createCharge({ + userToken: subscription.user.userToken, + amount: subscription.amount, + currency: subscription.currency, + description: `Renewal: ${subscription.plan.name}`, + reference: `REN-${subscription.id}-${Date.now()}`, + metadata: { + subscriptionId: subscription.id, + type: 'renewal', + period: { + start: subscription.currentPeriodEnd, + end: this.calculatePeriodEnd(subscription.plan, subscription.currentPeriodEnd), + }, + }, + }); + + if (payment.status === 'SUCCESS') { + // Mettre à jour la subscription + await this.prisma.subscription.update({ + where: { id: subscriptionId }, + data: { + currentPeriodStart: subscription.currentPeriodEnd, + currentPeriodEnd: this.calculatePeriodEnd(subscription.plan, subscription.currentPeriodEnd), + nextBillingDate: this.calculatePeriodEnd(subscription.plan, subscription.currentPeriodEnd), + lastPaymentId: payment.id, + lastPaymentDate: new Date(), + renewalCount: { increment: 1 }, + failureCount: 0, // Reset failure count on success }, }); - if (payment.status === 'SUCCESS') { - // Mettre à jour la subscription - await this.prisma.subscription.update({ - where: { id: subscription.id }, - data: { - currentPeriodStart: new Date(), - currentPeriodEnd: this.calculatePeriodEnd(subscription.plan), - nextBillingDate: this.calculateNextBillingDate(subscription.plan), - lastPaymentId: payment.id, - renewalCount: { increment: 1 }, - }, + // Programmer le prochain renouvellement + 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) { + await this.subscriptionQueue.add('webhook-notification', { + url: subscription.partner.callbacks.subscription.onRenew, + event: 'SUBSCRIPTION_RENEWED', + subscription: subscription, + payment: payment, }); - } else { - // Gérer l'échec - await this.handlePaymentFailure(subscription.id); } - } catch (error) { - console.error( - `Failed to renew subscription ${subscription.id}:`, - error, - ); - await this.handlePaymentFailure(subscription.id); + } else { + await this.handleRenewalFailure(subscription); } + } catch (error) { + console.error(`Renewal failed for subscription ${subscriptionId}:`, error); + await this.handleRenewalFailure(subscription); } } - private calculatePeriodEnd(plan: any): Date { - const now = new Date(); - switch (plan.interval) { - case 'DAILY': - return new Date(now.getTime() + 24 * 60 * 60 * 1000); - case 'WEEKLY': - return new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); - case 'MONTHLY': - return new Date(now.setMonth(now.getMonth() + 1)); - case 'YEARLY': - return new Date(now.setFullYear(now.getFullYear() + 1)); - default: - return now; - } - } + 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', + }, + }); - private calculateNextBillingDate(plan: any): Date { - return this.calculatePeriodEnd(plan); - } + if (payment.status === 'SUCCESS') { + await this.prisma.subscription.update({ + where: { id: subscription.id }, + data: { + status: 'ACTIVE', + activatedAt: new Date(), + lastPaymentId: payment.id, + lastPaymentDate: new Date(), + }, + }); - private calculateTrialEnd(trialPeriod: any): Date { - const now = new Date(); - switch (trialPeriod.unit) { - case 'DAYS': - return new Date( - now.getTime() + trialPeriod.duration * 24 * 60 * 60 * 1000, + // Programmer le premier renouvellement + const delay = subscription.nextBillingDate.getTime() - Date.now(); + await this.billingQueue.add( + 'process-renewal', + { subscriptionId: subscription.id }, + { delay }, ); - case 'WEEKS': - return new Date( - now.getTime() + trialPeriod.duration * 7 * 24 * 60 * 60 * 1000, - ); - case 'MONTHS': - return new Date(now.setMonth(now.getMonth() + trialPeriod.duration)); - default: - return now; - } - } - - private async handlePaymentFailure(subscriptionId: string) { - const subscription = await this.prisma.subscription.findUnique({ - where: { id: subscriptionId }, - }); - - const failureCount = (subscription.failureCount || 0) + 1; - - if (failureCount >= 3) { - // Suspendre après 3 échecs + } else { + await this.prisma.subscription.update({ + where: { id: subscription.id }, + data: { + status: 'FAILED', + failureReason: payment.failureReason, + }, + }); + } + } catch (error) { await this.prisma.subscription.update({ - where: { id: subscriptionId }, + where: { id: subscription.id }, data: { - status: SubscriptionStatus.SUSPENDED, + status: 'FAILED', + failureReason: error.message, + }, + }); + throw error; + } + } + + private async handleRenewalFailure(subscription: any) { + const failureCount = (subscription.failureCount || 0) + 1; + const maxRetries = 3; + + if (failureCount >= maxRetries) { + // Suspendre après le nombre max d'échecs + await this.prisma.subscription.update({ + where: { id: subscription.id }, + data: { + status: 'SUSPENDED', failureCount, suspendedAt: new Date(), + suspensionReason: `Payment failed ${maxRetries} times`, }, }); + + // Notifier la suspension + if (subscription.partner?.callbacks?.subscription?.onExpire) { + await this.subscriptionQueue.add('webhook-notification', { + url: subscription.partner.callbacks.subscription.onExpire, + event: 'SUBSCRIPTION_SUSPENDED', + subscription: subscription, + }); + } } else { - // Incrémenter le compteur d'échecs + // Incrémenter le compteur et reprogrammer await this.prisma.subscription.update({ - where: { id: subscriptionId }, + where: { id: subscription.id }, data: { failureCount, - nextBillingDate: new Date(Date.now() + 24 * 60 * 60 * 1000), // Réessayer demain }, }); + + // Reprogrammer une tentative dans 24h + await this.billingQueue.add( + 'retry-renewal', + { + subscriptionId: subscription.id, + attempt: failureCount + 1, + }, + { delay: 24 * 60 * 60 * 1000 }, + ); } } -} + + private calculatePeriodEnd(plan: any, startDate: Date): Date { + const date = new Date(startDate); + + switch (plan.interval) { + case 'DAILY': + date.setDate(date.getDate() + 1); + break; + case 'WEEKLY': + date.setDate(date.getDate() + 7); + break; + case 'MONTHLY': + date.setMonth(date.getMonth() + 1); + break; + case 'YEARLY': + date.setFullYear(date.getFullYear() + 1); + break; + } + + return date; + } + + private calculateNextBillingDate(subscription: any): Date { + const now = new Date(); + const pauseDuration = subscription.pausedAt + ? now.getTime() - subscription.pausedAt.getTime() + : 0; + + return new Date(subscription.nextBillingDate.getTime() + pauseDuration); + } +} \ No newline at end of file diff --git a/src/shared/utils/crypto.util.ts b/src/shared/utils/crypto.util.ts new file mode 100644 index 0000000..389a6af --- /dev/null +++ b/src/shared/utils/crypto.util.ts @@ -0,0 +1,38 @@ +import * as crypto from 'crypto'; +import * as bcrypt from 'bcrypt'; + +export class CryptoUtil { + static generateRandomString(length: number = 32): string { + return crypto.randomBytes(length).toString('hex'); + } + + static generateApiKey(): string { + return `pk_${this.generateRandomString(32)}`; + } + + static generateSecretKey(): string { + return `sk_${this.generateRandomString(32)}`; + } + + static async hashPassword(password: string): Promise { + return bcrypt.hash(password, 10); + } + + static async comparePassword(password: string, hash: string): Promise { + return bcrypt.compare(password, hash); + } + + static generateSignature(payload: any, secret: string): string { + const hmac = crypto.createHmac('sha256', secret); + hmac.update(JSON.stringify(payload)); + return hmac.digest('hex'); + } + + static verifySignature(payload: any, signature: string, secret: string): boolean { + const expectedSignature = this.generateSignature(payload, secret); + return crypto.timingSafeEqual( + Buffer.from(signature), + Buffer.from(expectedSignature), + ); + } +} \ No newline at end of file diff --git a/src/shared/utils/pagination.util.ts b/src/shared/utils/pagination.util.ts new file mode 100644 index 0000000..9c6df2a --- /dev/null +++ b/src/shared/utils/pagination.util.ts @@ -0,0 +1,49 @@ +export interface PaginationParams { + page?: number; + limit?: number; + sortBy?: string; + sortOrder?: 'asc' | 'desc'; +} + +export interface PaginatedResult { + data: T[]; + meta: { + total: number; + page: number; + limit: number; + totalPages: number; + hasPrevious: boolean; + hasNext: boolean; + }; +} + +export class PaginationUtil { + static getPaginationParams(params: PaginationParams) { + const page = Math.max(1, params.page || 1); + const limit = Math.min(100, Math.max(1, params.limit || 20)); + const skip = (page - 1) * limit; + + return { page, limit, skip }; + } + + static createPaginatedResult( + data: T[], + total: number, + params: PaginationParams, + ): PaginatedResult { + const { page, limit } = this.getPaginationParams(params); + const totalPages = Math.ceil(total / limit); + + return { + data, + meta: { + total, + page, + limit, + totalPages, + hasPrevious: page > 1, + hasNext: page < totalPages, + }, + }; + } +} \ No newline at end of file