first commit

This commit is contained in:
Mamadou Khoussa [028918 DSI/DAC/DIF/DS] 2025-10-21 23:47:31 +00:00
parent 300a5205df
commit c479637d04
38 changed files with 2820 additions and 191 deletions

222
package-lock.json generated
View File

@ -15,10 +15,13 @@
"@nestjs/config": "^4.0.2", "@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.0.1", "@nestjs/core": "^11.0.1",
"@nestjs/event-emitter": "^3.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/platform-express": "^11.0.1",
"@nestjs/schedule": "^6.0.1", "@nestjs/schedule": "^6.0.1",
"@nestjs/swagger": "^11.2.1", "@nestjs/swagger": "^11.2.1",
"@prisma/client": "^6.17.1", "@prisma/client": "^6.17.1",
"bcrypt": "^6.0.0",
"cache-manager-redis-store": "^3.0.1", "cache-manager-redis-store": "^3.0.1",
"class-validator": "^0.14.2", "class-validator": "^0.14.2",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
@ -2573,6 +2576,19 @@
"@nestjs/core": "^10.0.0 || ^11.0.0" "@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": { "node_modules/@nestjs/mapped-types": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-2.1.0.tgz", "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": { "node_modules/@nestjs/platform-express": {
"version": "11.1.7", "version": "11.1.7",
"resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.7.tgz", "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.7.tgz",
@ -3320,6 +3346,16 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@types/luxon": {
"version": "3.7.1", "version": "3.7.1",
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.1.tgz", "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.1.tgz",
@ -3340,11 +3376,16 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@types/node": {
"version": "22.18.12", "version": "22.18.12",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.12.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.12.tgz",
"integrity": "sha512-BICHQ67iqxQGFSzfCFTT7MRQ5XcBjG5aeKh5Ok38UBbPe5fxTyE+aHFxwVrGyr8GNlqFMLKD1D3P2K/1ks8tog==", "integrity": "sha512-BICHQ67iqxQGFSzfCFTT7MRQ5XcBjG5aeKh5Ok38UBbPe5fxTyE+aHFxwVrGyr8GNlqFMLKD1D3P2K/1ks8tog==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
@ -4560,6 +4601,20 @@
"baseline-browser-mapping": "dist/cli.js" "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": { "node_modules/bl": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
@ -4710,6 +4765,12 @@
"ieee754": "^1.1.13" "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": { "node_modules/buffer-from": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
@ -5585,6 +5646,15 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/ee-first": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@ -8103,6 +8173,49 @@
"graceful-fs": "^4.1.6" "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": { "node_modules/keyv": {
"version": "5.5.3", "version": "5.5.3",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-5.5.3.tgz", "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.5.3.tgz",
@ -8212,6 +8325,12 @@
"license": "MIT", "license": "MIT",
"peer": true "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": { "node_modules/lodash.isarguments": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
@ -8219,6 +8338,36 @@
"license": "MIT", "license": "MIT",
"peer": true "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": { "node_modules/lodash.memoize": {
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
@ -8233,6 +8382,12 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/log-symbols": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz",
@ -8653,6 +8808,15 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/node-emoji": {
"version": "1.11.0", "version": "1.11.0",
"resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-1.11.0.tgz", "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-1.11.0.tgz",
@ -8670,6 +8834,17 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "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": { "node_modules/node-gyp-build-optional-packages": {
"version": "5.2.2", "version": "5.2.2",
"resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", "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": ">= 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": { "node_modules/path-exists": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@ -9047,6 +9250,12 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "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": { "node_modules/perfect-debounce": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
@ -10821,7 +11030,6 @@
"version": "6.21.0", "version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/universalify": { "node_modules/universalify": {
@ -10925,6 +11133,16 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT" "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": { "node_modules/uuid": {
"version": "8.3.2", "version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",

View File

@ -26,10 +26,13 @@
"@nestjs/config": "^4.0.2", "@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.0.1", "@nestjs/core": "^11.0.1",
"@nestjs/event-emitter": "^3.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/platform-express": "^11.0.1",
"@nestjs/schedule": "^6.0.1", "@nestjs/schedule": "^6.0.1",
"@nestjs/swagger": "^11.2.1", "@nestjs/swagger": "^11.2.1",
"@prisma/client": "^6.17.1", "@prisma/client": "^6.17.1",
"bcrypt": "^6.0.0",
"cache-manager-redis-store": "^3.0.1", "cache-manager-redis-store": "^3.0.1",
"class-validator": "^0.14.2", "class-validator": "^0.14.2",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",

View File

@ -1,9 +1,6 @@
// This is your Prisma schema file, // This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema // 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 { generator client {
provider = "prisma-client-js" provider = "prisma-client-js"
output = "../generated/prisma" output = "../generated/prisma"
@ -39,8 +36,6 @@ enum SubscriptionStatus {
FAILED FAILED
} }
model Operator { model Operator {
id String @id @default(cuid()) id String @id @default(cuid())
code OperatorCode code OperatorCode
@ -70,21 +65,62 @@ model User {
partner Partner @relation(fields: [partnerId], references: [id]) partner Partner @relation(fields: [partnerId], references: [id])
subscriptions Subscription[] subscriptions Subscription[]
payments Payment[] payments Payment[]
invoices Invoice[] // Added relation
} }
model Plan { model Plan {
id String @id @default(cuid()) id String @id @default(cuid())
partnerId String
code String
name String name String
description String? description String?
amount Float amount Float
currency String currency String
interval String // DAILY, WEEKLY, MONTHLY, YEARLY interval String // DAILY, WEEKLY, MONTHLY, YEARLY
intervalCount Int @default(1)
trialDays Int @default(0)
features Json? // Array of features
limits Json? // Object with usage limits
metadata Json? metadata Json?
active Boolean @default(true) active Boolean @default(true)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
partner Partner @relation(fields: [partnerId], references: [id])
subscriptions Subscription[] 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 { model Subscription {
@ -110,6 +146,7 @@ model Subscription {
plan Plan @relation(fields: [planId], references: [id]) plan Plan @relation(fields: [planId], references: [id])
partner Partner @relation(fields: [partnerId], references: [id]) partner Partner @relation(fields: [partnerId], references: [id])
payments Payment[] payments Payment[]
invoices Invoice[] // Added relation
} }
model Payment { model Payment {
@ -133,6 +170,7 @@ model Payment {
partner Partner @relation(fields: [partnerId], references: [id]) partner Partner @relation(fields: [partnerId], references: [id])
subscription Subscription? @relation(fields: [subscriptionId], references: [id]) subscription Subscription? @relation(fields: [subscriptionId], references: [id])
refunds Refund[] refunds Refund[]
invoice Invoice? // Added relation
} }
model Refund { model Refund {
@ -180,6 +218,8 @@ model Partner {
subscriptions Subscription[] subscriptions Subscription[]
payments Payment[] payments Payment[]
authSessions AuthSession[] authSessions AuthSession[]
plans Plan[] // Added relation
invoices Invoice[] // Added relation
} }
model AuthSession { model AuthSession {
@ -199,3 +239,28 @@ model AuthSession {
partner Partner @relation(fields: [partnerId], references: [id]) partner Partner @relation(fields: [partnerId], references: [id])
} }
model Notification {
id String @id @default(cuid())
partnerId String
userId String?
type String // PAYMENT, SUBSCRIPTION, ALERT, MARKETING
channel String // SMS, EMAIL, WEBHOOK
recipient String
subject String?
content String
templateId String?
status String // PENDING, SENT, FAILED
batchId String?
scheduledFor DateTime?
sentAt DateTime?
failedAt DateTime?
failureReason String?
response Json?
metadata Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
partner Partner @relation(fields: [partnerId], references: [id])
user User? @relation(fields: [userId], references: [id])
}

View File

@ -6,8 +6,12 @@ import { EventEmitterModule } from '@nestjs/event-emitter';
import { CacheModule } from '@nestjs/cache-manager'; import { CacheModule } from '@nestjs/cache-manager';
import * as redisStore from 'cache-manager-redis-store'; import * as redisStore from 'cache-manager-redis-store';
// Import des configurations
import appConfig from './config/app.config'; import appConfig from './config/app.config';
import operatorsConfig from './config/operators.config'; import operatorsConfig from './config/operators.config';
import databaseConfig from './config/database.config';
// Import des modules
import { PrismaService } from './shared/services/prisma.service'; import { PrismaService } from './shared/services/prisma.service';
import { AuthModule } from './modules/auth/auth.module'; import { AuthModule } from './modules/auth/auth.module';
import { PartnersModule } from './modules/partners/partners.module'; import { PartnersModule } from './modules/partners/partners.module';
@ -20,23 +24,29 @@ import { NotificationsModule } from './modules/notifications/notifications.modul
imports: [ imports: [
ConfigModule.forRoot({ ConfigModule.forRoot({
isGlobal: true, isGlobal: true,
load: [appConfig, operatorsConfig], load: [appConfig, operatorsConfig, databaseConfig],
envFilePath: ['.env.local', '.env'],
}), }),
BullModule.forRootAsync({ BullModule.forRootAsync({
imports: [ConfigModule], imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({ useFactory: (configService: ConfigService) => ({
redis: { redis: {
host: configService.get('REDIS_HOST'), host: configService.get('app.redis.host'),
port: configService.get('REDIS_PORT'), port: configService.get('app.redis.port'),
}, },
}), }),
inject: [ConfigService], 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, isGlobal: true,
store: redisStore,
host: process.env.REDIS_HOST || 'localhost',
port: process.env.REDIS_PORT || 6379,
}), }),
ScheduleModule.forRoot(), ScheduleModule.forRoot(),
EventEmitterModule.forRoot(), EventEmitterModule.forRoot(),

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

View 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;
});

View File

@ -0,0 +1,4 @@
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true)

View File

@ -0,0 +1,4 @@
import { SetMetadata } from '@nestjs/common';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);

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

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

View 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;
}
}

View 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);
}
}

View 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));
}
}

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

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

View 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);
}
}

View File

@ -6,4 +6,11 @@ export default registerAs('app', () => ({
apiPrefix: process.env.API_PREFIX || 'v2', apiPrefix: process.env.API_PREFIX || 'v2',
jwtSecret: process.env.JWT_SECRET || 'your-secret-key', jwtSecret: process.env.JWT_SECRET || 'your-secret-key',
jwtExpiresIn: process.env.JWT_EXPIRES_IN || '7d', 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,
},
})); }));

View 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
}));

View File

@ -1,34 +1,6 @@
export interface OperatorConfig { import { registerAs } from '@nestjs/config';
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;
};
}
export const operatorsConfig = (): Record<string, OperatorConfig> => ({ export default registerAs('operators', () => ({
ORANGE_CIV: { ORANGE_CIV: {
name: 'Orange Côte d Ivoire', name: 'Orange Côte d Ivoire',
baseUrl: process.env.ORANGE_CIV_BASE_URL || 'https://api.bizao.com', baseUrl: process.env.ORANGE_CIV_BASE_URL || 'https://api.bizao.com',
@ -56,6 +28,33 @@ export const operatorsConfig = (): Record<string, OperatorConfig> => ({
response: 'OrangeResponseTransformer', 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: { MTN_CMR: {
name: 'MTN Cameroon', name: 'MTN Cameroon',
baseUrl: process.env.MTN_CMR_BASE_URL || 'https://api.mtn.cm', baseUrl: process.env.MTN_CMR_BASE_URL || 'https://api.mtn.cm',
@ -87,4 +86,4 @@ export const operatorsConfig = (): Record<string, OperatorConfig> => ({
response: 'MTNResponseTransformer', response: 'MTNResponseTransformer',
}, },
}, },
}); }));

View 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);
}
}

View 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>;
}

View 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);
}
}

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

View File

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

View 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;
}
}

View 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',
};
}
}

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

View 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>;
}

View 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>;
}

View File

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

View File

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

View 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')}`;
}
}

View 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
}
}

View 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);
}
}

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

View File

@ -1,7 +1,9 @@
import { Injectable } from '@nestjs/common'; import { Injectable, BadRequestException, NotFoundException } 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'; import { PrismaService } from '../../shared/services/prisma.service';
import { PaymentsService } from '../payments/payments.service'; import { PaymentsService } from '../payments/payments.service';
import { CreateSubscriptionDto, UpdateSubscriptionDto } from './dto/subscription.dto';
import { SubscriptionStatus } from '@prisma/client'; import { SubscriptionStatus } from '@prisma/client';
@Injectable() @Injectable()
@ -9,206 +11,435 @@ export class SubscriptionsService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly paymentsService: PaymentsService, private readonly paymentsService: PaymentsService,
@InjectQueue('subscriptions') private subscriptionQueue: Queue,
@InjectQueue('billing') private billingQueue: Queue,
) {} ) {}
async createSubscription(dto: CreateSubscriptionDto) { async create(partnerId: string, dto: CreateSubscriptionDto) {
// Vérifier l'utilisateur // Vérifier l'utilisateur
const user = await this.prisma.user.findUnique({ const user = await this.prisma.user.findFirst({
where: { userToken: dto.userToken }, where: {
userToken: dto.userToken,
partnerId: partnerId,
},
include: { operator: true },
}); });
if (!user) { 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({ const plan = await this.prisma.plan.findUnique({
where: { id: dto.planId }, where: { id: dto.planId },
}); });
if (!plan) { if (!plan || !plan.active) {
throw new BadRequestException('Invalid plan'); 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 // Créer la subscription
const subscription = await this.prisma.subscription.create({ const subscription = await this.prisma.subscription.create({
data: { data: {
userId: user.id, userId: user.id,
planId: plan.id, planId: plan.id,
status: SubscriptionStatus.PENDING, partnerId: partnerId,
currentPeriodStart: new Date(), status: hasTrialPeriod ? 'TRIAL' : 'PENDING',
currentPeriodEnd: this.calculatePeriodEnd(plan), currentPeriodStart,
nextBillingDate: this.calculateNextBillingDate(plan), currentPeriodEnd,
metadata: dto.metadata, 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 // Si pas de période d'essai, traiter le premier paiement
if (!dto.trialPeriod) { if (!hasTrialPeriod) {
try { await this.processInitialPayment(subscription, dto.callbackUrl);
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;
}
} else { } else {
// Activer en période d'essai // Activer directement en période d'essai
await this.prisma.subscription.update({ await this.prisma.subscription.update({
where: { id: subscription.id }, where: { id: subscription.id },
data: { data: { status: 'TRIAL' },
status: SubscriptionStatus.TRIAL, });
trialEndsAt: this.calculateTrialEnd(dto.trialPeriod),
}, // 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; return subscription;
} }
@Cron(CronExpression.EVERY_HOUR) async update(subscriptionId: string, partnerId: string, dto: UpdateSubscriptionDto) {
async processRecurringPayments() { const subscription = await this.prisma.subscription.findFirst({
// Récupérer les subscriptions à renouveler
const subscriptions = await this.prisma.subscription.findMany({
where: { where: {
status: { in: [SubscriptionStatus.ACTIVE, SubscriptionStatus.TRIAL] }, id: subscriptionId,
nextBillingDate: { partnerId: partnerId,
lte: new Date(), },
});
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: { include: {
user: true, user: true,
plan: true, plan: true,
partner: true,
}, },
}); });
for (const subscription of subscriptions) { if (!subscription) {
try { throw new NotFoundException('Subscription not found');
// Essayer de facturer }
const payment = await this.paymentsService.createCharge({
userToken: subscription.user.userToken, if (subscription.status !== 'ACTIVE') {
amount: subscription.plan.amount, console.log(`Skipping renewal for non-active subscription ${subscriptionId}`);
currency: subscription.plan.currency, return;
description: `Renewal: ${subscription.plan.name}`, }
reference: `REN-${subscription.id}-${Date.now()}`,
metadata: { try {
subscriptionId: subscription.id, // Créer le paiement de renouvellement
renewal: true, 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') { // Programmer le prochain renouvellement
// Mettre à jour la subscription const delay = subscription.nextBillingDate.getTime() - Date.now();
await this.prisma.subscription.update({ await this.billingQueue.add(
where: { id: subscription.id }, 'process-renewal',
data: { { subscriptionId },
currentPeriodStart: new Date(), { delay },
currentPeriodEnd: this.calculatePeriodEnd(subscription.plan), );
nextBillingDate: this.calculateNextBillingDate(subscription.plan),
lastPaymentId: payment.id, // Notifier le succès
renewalCount: { increment: 1 }, 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) { } else {
console.error( await this.handleRenewalFailure(subscription);
`Failed to renew subscription ${subscription.id}:`,
error,
);
await this.handlePaymentFailure(subscription.id);
} }
} catch (error) {
console.error(`Renewal failed for subscription ${subscriptionId}:`, error);
await this.handleRenewalFailure(subscription);
} }
} }
private calculatePeriodEnd(plan: any): Date { private async processInitialPayment(subscription: any, callbackUrl?: string) {
const now = new Date(); try {
switch (plan.interval) { const payment = await this.paymentsService.createCharge({
case 'DAILY': userToken: subscription.user.userToken,
return new Date(now.getTime() + 24 * 60 * 60 * 1000); amount: subscription.amount,
case 'WEEKLY': currency: subscription.currency,
return new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); description: `Subscription: ${subscription.plan.name}`,
case 'MONTHLY': reference: `SUB-INIT-${subscription.id}-${Date.now()}`,
return new Date(now.setMonth(now.getMonth() + 1)); callbackUrl: callbackUrl,
case 'YEARLY': metadata: {
return new Date(now.setFullYear(now.getFullYear() + 1)); subscriptionId: subscription.id,
default: type: 'initial',
return now; },
} });
}
private calculateNextBillingDate(plan: any): Date { if (payment.status === 'SUCCESS') {
return this.calculatePeriodEnd(plan); 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 { // Programmer le premier renouvellement
const now = new Date(); const delay = subscription.nextBillingDate.getTime() - Date.now();
switch (trialPeriod.unit) { await this.billingQueue.add(
case 'DAYS': 'process-renewal',
return new Date( { subscriptionId: subscription.id },
now.getTime() + trialPeriod.duration * 24 * 60 * 60 * 1000, { delay },
); );
case 'WEEKS': } else {
return new Date( await this.prisma.subscription.update({
now.getTime() + trialPeriod.duration * 7 * 24 * 60 * 60 * 1000, where: { id: subscription.id },
); data: {
case 'MONTHS': status: 'FAILED',
return new Date(now.setMonth(now.getMonth() + trialPeriod.duration)); failureReason: payment.failureReason,
default: },
return now; });
} }
} } catch (error) {
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
await this.prisma.subscription.update({ await this.prisma.subscription.update({
where: { id: subscriptionId }, where: { id: subscription.id },
data: { 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, failureCount,
suspendedAt: new Date(), 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 { } else {
// Incrémenter le compteur d'échecs // Incrémenter le compteur et reprogrammer
await this.prisma.subscription.update({ await this.prisma.subscription.update({
where: { id: subscriptionId }, where: { id: subscription.id },
data: { data: {
failureCount, 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);
}
} }

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

View 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,
},
};
}
}