first commit
This commit is contained in:
parent
300a5205df
commit
c479637d04
222
package-lock.json
generated
222
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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])
|
||||
}
|
||||
@ -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(),
|
||||
|
||||
17
src/common/decorators/api-header.decorator.ts
Normal file
17
src/common/decorators/api-header.decorator.ts
Normal file
@ -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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
7
src/common/decorators/current-user.decorator.ts
Normal file
7
src/common/decorators/current-user.decorator.ts
Normal file
@ -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;
|
||||
});
|
||||
4
src/common/decorators/public.decorator.ts
Normal file
4
src/common/decorators/public.decorator.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const IS_PUBLIC_KEY = 'isPublic';
|
||||
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true)
|
||||
4
src/common/decorators/roles.decorator.ts
Normal file
4
src/common/decorators/roles.decorator.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const ROLES_KEY = 'roles';
|
||||
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
|
||||
38
src/common/filters/http-exception.filter.ts
Normal file
38
src/common/filters/http-exception.filter.ts
Normal file
@ -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<Response>();
|
||||
const request = ctx.getRequest<Request>();
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
38
src/common/filters/prisma-exception.filter.ts
Normal file
38
src/common/filters/prisma-exception.filter.ts
Normal file
@ -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<Response>();
|
||||
const request = ctx.getRequest<Request>();
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
27
src/common/guards/api-key.guard.ts
Normal file
27
src/common/guards/api-key.guard.ts
Normal file
@ -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<boolean> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
24
src/common/guards/jwt-auth.guard.ts
Normal file
24
src/common/guards/jwt-auth.guard.ts
Normal file
@ -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<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
|
||||
if (isPublic) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return super.canActivate(context);
|
||||
}
|
||||
}
|
||||
22
src/common/guards/roles.guard.ts
Normal file
22
src/common/guards/roles.guard.ts
Normal file
@ -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<string[]>(ROLES_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
|
||||
if (!requiredRoles) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const { user } = context.switchToHttp().getRequest();
|
||||
return requiredRoles.some((role) => user.roles?.includes(role));
|
||||
}
|
||||
}
|
||||
25
src/common/interceptors/logging.interceptor.ts
Normal file
25
src/common/interceptors/logging.interceptor.ts
Normal file
@ -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<any> {
|
||||
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`,
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
26
src/common/interceptors/transform.interceptor.ts
Normal file
26
src/common/interceptors/transform.interceptor.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
|
||||
import { Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
|
||||
export interface Response<T> {
|
||||
success: boolean;
|
||||
data: T;
|
||||
timestamp: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TransformInterceptor<T> implements NestInterceptor<T, Response<T>> {
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<Response<T>> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
|
||||
return next.handle().pipe(
|
||||
map(data => ({
|
||||
success: true,
|
||||
data,
|
||||
timestamp: new Date().toISOString(),
|
||||
path: request.url,
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
33
src/common/pipes/validation.pipe.ts
Normal file
33
src/common/pipes/validation.pipe.ts
Normal file
@ -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<any> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
},
|
||||
}));
|
||||
|
||||
7
src/config/database.config.ts
Normal file
7
src/config/database.config.ts
Normal file
@ -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
|
||||
}));
|
||||
@ -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<string, string>;
|
||||
transformers: {
|
||||
request: string;
|
||||
response: string;
|
||||
};
|
||||
}
|
||||
import { registerAs } from '@nestjs/config';
|
||||
|
||||
export const operatorsConfig = (): Record<string, OperatorConfig> => ({
|
||||
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<string, OperatorConfig> => ({
|
||||
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<string, OperatorConfig> => ({
|
||||
response: 'MTNResponseTransformer',
|
||||
},
|
||||
},
|
||||
});
|
||||
}));
|
||||
|
||||
29
src/modules/auth/strategies/api-key.strategy.ts
Normal file
29
src/modules/auth/strategies/api-key.strategy.ts
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
91
src/modules/notifications/dto/notification.dto.ts
Normal file
91
src/modules/notifications/dto/notification.dto.ts
Normal file
@ -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<string, any>;
|
||||
}
|
||||
|
||||
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<string, any>;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
batchId: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
37
src/modules/notifications/notifications.controller.ts
Normal file
37
src/modules/notifications/notifications.controller.ts
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
40
src/modules/notifications/notifications.module.ts
Normal file
40
src/modules/notifications/notifications.module.ts
Normal file
@ -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 {}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
290
src/modules/notifications/services/notifications.service.ts
Normal file
290
src/modules/notifications/services/notifications.service.ts
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
95
src/modules/notifications/services/sms.service.ts
Normal file
95
src/modules/notifications/services/sms.service.ts
Normal file
@ -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<string, any>;
|
||||
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',
|
||||
};
|
||||
}
|
||||
}
|
||||
126
src/modules/notifications/services/webhook.service.ts
Normal file
126
src/modules/notifications/services/webhook.service.ts
Normal file
@ -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}`);
|
||||
}
|
||||
}
|
||||
122
src/modules/subscriptions/dto/plan.dto.ts
Normal file
122
src/modules/subscriptions/dto/plan.dto.ts
Normal file
@ -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<string, any>;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
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<string, any>;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
48
src/modules/subscriptions/dto/subscription.dto.ts
Normal file
48
src/modules/subscriptions/dto/subscription.dto.ts
Normal file
@ -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<string, any>;
|
||||
}
|
||||
|
||||
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<string, any>;
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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`);
|
||||
}
|
||||
}
|
||||
197
src/modules/subscriptions/services/billing.service.ts
Normal file
197
src/modules/subscriptions/services/billing.service.ts
Normal file
@ -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<string> {
|
||||
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')}`;
|
||||
}
|
||||
}
|
||||
337
src/modules/subscriptions/services/plan.service.ts
Normal file
337
src/modules/subscriptions/services/plan.service.ts
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
80
src/modules/subscriptions/subscriptions.controller.ts
Normal file
80
src/modules/subscriptions/subscriptions.controller.ts
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
35
src/modules/subscriptions/subscriptions.module.ts
Normal file
35
src/modules/subscriptions/subscriptions.module.ts
Normal file
@ -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 {}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
38
src/shared/utils/crypto.util.ts
Normal file
38
src/shared/utils/crypto.util.ts
Normal file
@ -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<string> {
|
||||
return bcrypt.hash(password, 10);
|
||||
}
|
||||
|
||||
static async comparePassword(password: string, hash: string): Promise<boolean> {
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
49
src/shared/utils/pagination.util.ts
Normal file
49
src/shared/utils/pagination.util.ts
Normal file
@ -0,0 +1,49 @@
|
||||
export interface PaginationParams {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
sortBy?: string;
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export interface PaginatedResult<T> {
|
||||
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<T>(
|
||||
data: T[],
|
||||
total: number,
|
||||
params: PaginationParams,
|
||||
): PaginatedResult<T> {
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user