first commit
This commit is contained in:
commit
cb7314e386
58
.gitignore
vendored
Normal file
58
.gitignore
vendored
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
# compiled output
|
||||||
|
/dist
|
||||||
|
/node_modules
|
||||||
|
/build
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Tests
|
||||||
|
/coverage
|
||||||
|
/.nyc_output
|
||||||
|
|
||||||
|
# IDEs and editors
|
||||||
|
/.idea
|
||||||
|
.project
|
||||||
|
.classpath
|
||||||
|
.c9/
|
||||||
|
*.launch
|
||||||
|
.settings/
|
||||||
|
*.sublime-workspace
|
||||||
|
|
||||||
|
# IDE - VSCode
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/tasks.json
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/extensions.json
|
||||||
|
|
||||||
|
# dotenv environment variable files
|
||||||
|
.env
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# temp directory
|
||||||
|
.temp
|
||||||
|
.tmp
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||||
|
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||||
|
|
||||||
|
/generated/prisma
|
||||||
4
.prettierrc
Normal file
4
.prettierrc
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all"
|
||||||
|
}
|
||||||
98
README.md
Normal file
98
README.md
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
<p align="center">
|
||||||
|
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
|
||||||
|
[circleci-url]: https://circleci.com/gh/nestjs/nest
|
||||||
|
|
||||||
|
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
|
||||||
|
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
|
||||||
|
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
|
||||||
|
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
|
||||||
|
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
|
||||||
|
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
|
||||||
|
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
|
||||||
|
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
|
||||||
|
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
|
||||||
|
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
|
||||||
|
</p>
|
||||||
|
<!--[](https://opencollective.com/nest#backer)
|
||||||
|
[](https://opencollective.com/nest#sponsor)-->
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
|
||||||
|
|
||||||
|
## Project setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Compile and run the project
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# development
|
||||||
|
$ npm run start
|
||||||
|
|
||||||
|
# watch mode
|
||||||
|
$ npm run start:dev
|
||||||
|
|
||||||
|
# production mode
|
||||||
|
$ npm run start:prod
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# unit tests
|
||||||
|
$ npm run test
|
||||||
|
|
||||||
|
# e2e tests
|
||||||
|
$ npm run test:e2e
|
||||||
|
|
||||||
|
# test coverage
|
||||||
|
$ npm run test:cov
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
|
||||||
|
|
||||||
|
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ npm install -g @nestjs/mau
|
||||||
|
$ mau deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
Check out a few resources that may come in handy when working with NestJS:
|
||||||
|
|
||||||
|
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
|
||||||
|
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
|
||||||
|
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
|
||||||
|
- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
|
||||||
|
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
|
||||||
|
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
|
||||||
|
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
|
||||||
|
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
|
||||||
|
|
||||||
|
## Stay in touch
|
||||||
|
|
||||||
|
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
|
||||||
|
- Website - [https://nestjs.com](https://nestjs.com/)
|
||||||
|
- Twitter - [@nestframework](https://twitter.com/nestframework)
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).
|
||||||
34
eslint.config.mjs
Normal file
34
eslint.config.mjs
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
// @ts-check
|
||||||
|
import eslint from '@eslint/js';
|
||||||
|
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
|
||||||
|
import globals from 'globals';
|
||||||
|
import tseslint from 'typescript-eslint';
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{
|
||||||
|
ignores: ['eslint.config.mjs'],
|
||||||
|
},
|
||||||
|
eslint.configs.recommended,
|
||||||
|
...tseslint.configs.recommendedTypeChecked,
|
||||||
|
eslintPluginPrettierRecommended,
|
||||||
|
{
|
||||||
|
languageOptions: {
|
||||||
|
globals: {
|
||||||
|
...globals.node,
|
||||||
|
...globals.jest,
|
||||||
|
},
|
||||||
|
sourceType: 'commonjs',
|
||||||
|
parserOptions: {
|
||||||
|
projectService: true,
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
|
'@typescript-eslint/no-floating-promises': 'warn',
|
||||||
|
'@typescript-eslint/no-unsafe-argument': 'warn'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
8
nest-cli.json
Normal file
8
nest-cli.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
|
"collection": "@nestjs/schematics",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"deleteOutDir": true
|
||||||
|
}
|
||||||
|
}
|
||||||
10750
package-lock.json
generated
Normal file
10750
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
73
package.json
Normal file
73
package.json
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
{
|
||||||
|
"name": "payment-hub",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "",
|
||||||
|
"author": "",
|
||||||
|
"private": true,
|
||||||
|
"license": "UNLICENSED",
|
||||||
|
"scripts": {
|
||||||
|
"build": "nest build",
|
||||||
|
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||||
|
"start": "nest start",
|
||||||
|
"start:dev": "nest start --watch",
|
||||||
|
"start:debug": "nest start --debug --watch",
|
||||||
|
"start:prod": "node dist/main",
|
||||||
|
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||||
|
"test": "jest",
|
||||||
|
"test:watch": "jest --watch",
|
||||||
|
"test:cov": "jest --coverage",
|
||||||
|
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||||
|
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nestjs/common": "^11.0.1",
|
||||||
|
"@nestjs/core": "^11.0.1",
|
||||||
|
"@nestjs/platform-express": "^11.0.1",
|
||||||
|
"@prisma/client": "^6.17.1",
|
||||||
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"rxjs": "^7.8.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/eslintrc": "^3.2.0",
|
||||||
|
"@eslint/js": "^9.18.0",
|
||||||
|
"@nestjs/cli": "^11.0.0",
|
||||||
|
"@nestjs/schematics": "^11.0.0",
|
||||||
|
"@nestjs/testing": "^11.0.1",
|
||||||
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/jest": "^30.0.0",
|
||||||
|
"@types/node": "^22.10.7",
|
||||||
|
"@types/supertest": "^6.0.2",
|
||||||
|
"eslint": "^9.18.0",
|
||||||
|
"eslint-config-prettier": "^10.0.1",
|
||||||
|
"eslint-plugin-prettier": "^5.2.2",
|
||||||
|
"globals": "^16.0.0",
|
||||||
|
"jest": "^30.0.0",
|
||||||
|
"prettier": "^3.4.2",
|
||||||
|
"prisma": "^6.17.1",
|
||||||
|
"source-map-support": "^0.5.21",
|
||||||
|
"supertest": "^7.0.0",
|
||||||
|
"ts-jest": "^29.2.5",
|
||||||
|
"ts-loader": "^9.5.2",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"tsconfig-paths": "^4.2.0",
|
||||||
|
"typescript": "^5.7.3",
|
||||||
|
"typescript-eslint": "^8.20.0"
|
||||||
|
},
|
||||||
|
"jest": {
|
||||||
|
"moduleFileExtensions": [
|
||||||
|
"js",
|
||||||
|
"json",
|
||||||
|
"ts"
|
||||||
|
],
|
||||||
|
"rootDir": "src",
|
||||||
|
"testRegex": ".*\\.spec\\.ts$",
|
||||||
|
"transform": {
|
||||||
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
|
},
|
||||||
|
"collectCoverageFrom": [
|
||||||
|
"**/*.(t|j)s"
|
||||||
|
],
|
||||||
|
"coverageDirectory": "../coverage",
|
||||||
|
"testEnvironment": "node"
|
||||||
|
}
|
||||||
|
}
|
||||||
5
prisma.md
Normal file
5
prisma.md
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
# Créer les migrations
|
||||||
|
npx prisma migrate dev --name init
|
||||||
|
|
||||||
|
# Générer le client Prisma
|
||||||
|
npx prisma generate
|
||||||
185
prisma/migrations/20251021224426_init/migration.sql
Normal file
185
prisma/migrations/20251021224426_init/migration.sql
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "OperatorCode" AS ENUM ('ORANGE', 'MTN', 'AIRTEL', 'VODACOM', 'MOOV');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "PaymentStatus" AS ENUM ('PENDING', 'SUCCESS', 'FAILED', 'REFUNDED');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "SubscriptionStatus" AS ENUM ('PENDING', 'TRIAL', 'ACTIVE', 'SUSPENDED', 'CANCELLED', 'EXPIRED', 'FAILED');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Partner" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"apiKey" TEXT NOT NULL,
|
||||||
|
"secretKey" TEXT NOT NULL,
|
||||||
|
"status" TEXT NOT NULL,
|
||||||
|
"callbacks" JSONB,
|
||||||
|
"metadata" JSONB,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Partner_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Operator" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"code" "OperatorCode" NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"country" TEXT NOT NULL,
|
||||||
|
"config" JSONB NOT NULL,
|
||||||
|
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Operator_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "User" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"msisdn" TEXT NOT NULL,
|
||||||
|
"userToken" TEXT NOT NULL,
|
||||||
|
"userAlias" TEXT NOT NULL,
|
||||||
|
"operatorId" TEXT NOT NULL,
|
||||||
|
"partnerId" TEXT NOT NULL,
|
||||||
|
"country" TEXT NOT NULL,
|
||||||
|
"metadata" JSONB,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Plan" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
"amount" DOUBLE PRECISION NOT NULL,
|
||||||
|
"currency" TEXT NOT NULL,
|
||||||
|
"interval" TEXT NOT NULL,
|
||||||
|
"metadata" JSONB,
|
||||||
|
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Plan_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Subscription" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"planId" TEXT NOT NULL,
|
||||||
|
"partnerId" TEXT NOT NULL,
|
||||||
|
"status" "SubscriptionStatus" NOT NULL,
|
||||||
|
"currentPeriodStart" TIMESTAMP(3) NOT NULL,
|
||||||
|
"currentPeriodEnd" TIMESTAMP(3) NOT NULL,
|
||||||
|
"nextBillingDate" TIMESTAMP(3),
|
||||||
|
"trialEndsAt" TIMESTAMP(3),
|
||||||
|
"cancelledAt" TIMESTAMP(3),
|
||||||
|
"suspendedAt" TIMESTAMP(3),
|
||||||
|
"failureCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"renewalCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"lastPaymentId" TEXT,
|
||||||
|
"metadata" JSONB,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Subscription_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Payment" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"partnerId" TEXT NOT NULL,
|
||||||
|
"subscriptionId" TEXT,
|
||||||
|
"amount" DOUBLE PRECISION NOT NULL,
|
||||||
|
"currency" TEXT NOT NULL,
|
||||||
|
"description" TEXT NOT NULL,
|
||||||
|
"reference" TEXT NOT NULL,
|
||||||
|
"operatorReference" TEXT,
|
||||||
|
"status" "PaymentStatus" NOT NULL,
|
||||||
|
"failureReason" TEXT,
|
||||||
|
"metadata" JSONB,
|
||||||
|
"completedAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Payment_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Refund" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"paymentId" TEXT NOT NULL,
|
||||||
|
"amount" DOUBLE PRECISION NOT NULL,
|
||||||
|
"reason" TEXT,
|
||||||
|
"status" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Refund_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Webhook" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"url" TEXT NOT NULL,
|
||||||
|
"event" TEXT NOT NULL,
|
||||||
|
"payload" JSONB NOT NULL,
|
||||||
|
"response" JSONB,
|
||||||
|
"status" TEXT NOT NULL,
|
||||||
|
"attempts" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"lastAttempt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Webhook_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Partner_email_key" ON "Partner"("email");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Partner_apiKey_key" ON "Partner"("apiKey");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "User_msisdn_key" ON "User"("msisdn");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "User_userToken_key" ON "User"("userToken");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Payment_reference_key" ON "Payment"("reference");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "User" ADD CONSTRAINT "User_operatorId_fkey" FOREIGN KEY ("operatorId") REFERENCES "Operator"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "User" ADD CONSTRAINT "User_partnerId_fkey" FOREIGN KEY ("partnerId") REFERENCES "Partner"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Subscription" ADD CONSTRAINT "Subscription_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Subscription" ADD CONSTRAINT "Subscription_planId_fkey" FOREIGN KEY ("planId") REFERENCES "Plan"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Subscription" ADD CONSTRAINT "Subscription_partnerId_fkey" FOREIGN KEY ("partnerId") REFERENCES "Partner"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Payment" ADD CONSTRAINT "Payment_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Payment" ADD CONSTRAINT "Payment_partnerId_fkey" FOREIGN KEY ("partnerId") REFERENCES "Partner"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Payment" ADD CONSTRAINT "Payment_subscriptionId_fkey" FOREIGN KEY ("subscriptionId") REFERENCES "Subscription"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Refund" ADD CONSTRAINT "Refund_paymentId_fkey" FOREIGN KEY ("paymentId") REFERENCES "Payment"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (e.g., Git)
|
||||||
|
provider = "postgresql"
|
||||||
176
prisma/schema.prisma
Normal file
176
prisma/schema.prisma
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
// 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"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum OperatorCode {
|
||||||
|
ORANGE
|
||||||
|
MTN
|
||||||
|
AIRTEL
|
||||||
|
VODACOM
|
||||||
|
MOOV
|
||||||
|
}
|
||||||
|
|
||||||
|
enum PaymentStatus {
|
||||||
|
PENDING
|
||||||
|
SUCCESS
|
||||||
|
FAILED
|
||||||
|
REFUNDED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SubscriptionStatus {
|
||||||
|
PENDING
|
||||||
|
TRIAL
|
||||||
|
ACTIVE
|
||||||
|
SUSPENDED
|
||||||
|
CANCELLED
|
||||||
|
EXPIRED
|
||||||
|
FAILED
|
||||||
|
}
|
||||||
|
|
||||||
|
model Partner {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
email String @unique
|
||||||
|
apiKey String @unique
|
||||||
|
secretKey String
|
||||||
|
status String
|
||||||
|
callbacks Json?
|
||||||
|
metadata Json?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
users User[]
|
||||||
|
subscriptions Subscription[]
|
||||||
|
payments Payment[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model Operator {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
code OperatorCode
|
||||||
|
name String
|
||||||
|
country String
|
||||||
|
config Json
|
||||||
|
active Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
users User[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
msisdn String @unique
|
||||||
|
userToken String @unique
|
||||||
|
userAlias String
|
||||||
|
operatorId String
|
||||||
|
partnerId String
|
||||||
|
country String
|
||||||
|
metadata Json?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
operator Operator @relation(fields: [operatorId], references: [id])
|
||||||
|
partner Partner @relation(fields: [partnerId], references: [id])
|
||||||
|
subscriptions Subscription[]
|
||||||
|
payments Payment[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model Plan {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
description String?
|
||||||
|
amount Float
|
||||||
|
currency String
|
||||||
|
interval String // DAILY, WEEKLY, MONTHLY, YEARLY
|
||||||
|
metadata Json?
|
||||||
|
active Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
subscriptions Subscription[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model Subscription {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
planId String
|
||||||
|
partnerId String
|
||||||
|
status SubscriptionStatus
|
||||||
|
currentPeriodStart DateTime
|
||||||
|
currentPeriodEnd DateTime
|
||||||
|
nextBillingDate DateTime?
|
||||||
|
trialEndsAt DateTime?
|
||||||
|
cancelledAt DateTime?
|
||||||
|
suspendedAt DateTime?
|
||||||
|
failureCount Int @default(0)
|
||||||
|
renewalCount Int @default(0)
|
||||||
|
lastPaymentId String?
|
||||||
|
metadata Json?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
plan Plan @relation(fields: [planId], references: [id])
|
||||||
|
partner Partner @relation(fields: [partnerId], references: [id])
|
||||||
|
payments Payment[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model Payment {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
partnerId String
|
||||||
|
subscriptionId String?
|
||||||
|
amount Float
|
||||||
|
currency String
|
||||||
|
description String
|
||||||
|
reference String @unique
|
||||||
|
operatorReference String?
|
||||||
|
status PaymentStatus
|
||||||
|
failureReason String?
|
||||||
|
metadata Json?
|
||||||
|
completedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
partner Partner @relation(fields: [partnerId], references: [id])
|
||||||
|
subscription Subscription? @relation(fields: [subscriptionId], references: [id])
|
||||||
|
refunds Refund[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model Refund {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
paymentId String
|
||||||
|
amount Float
|
||||||
|
reason String?
|
||||||
|
status String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
payment Payment @relation(fields: [paymentId], references: [id])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Webhook {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
url String
|
||||||
|
event String
|
||||||
|
payload Json
|
||||||
|
response Json?
|
||||||
|
status String
|
||||||
|
attempts Int @default(0)
|
||||||
|
lastAttempt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
22
src/app.controller.spec.ts
Normal file
22
src/app.controller.spec.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { AppController } from './app.controller';
|
||||||
|
import { AppService } from './app.service';
|
||||||
|
|
||||||
|
describe('AppController', () => {
|
||||||
|
let appController: AppController;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const app: TestingModule = await Test.createTestingModule({
|
||||||
|
controllers: [AppController],
|
||||||
|
providers: [AppService],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
appController = app.get<AppController>(AppController);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('root', () => {
|
||||||
|
it('should return "Hello World!"', () => {
|
||||||
|
expect(appController.getHello()).toBe('Hello World!');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
12
src/app.controller.ts
Normal file
12
src/app.controller.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { Controller, Get } from '@nestjs/common';
|
||||||
|
import { AppService } from './app.service';
|
||||||
|
|
||||||
|
@Controller()
|
||||||
|
export class AppController {
|
||||||
|
constructor(private readonly appService: AppService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
getHello(): string {
|
||||||
|
return this.appService.getHello();
|
||||||
|
}
|
||||||
|
}
|
||||||
53
src/app.module.ts
Normal file
53
src/app.module.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
|
import { BullModule } from '@nestjs/bull';
|
||||||
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
|
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||||
|
import { CacheModule } from '@nestjs/cache-manager';
|
||||||
|
import * as redisStore from 'cache-manager-redis-store';
|
||||||
|
|
||||||
|
import appConfig from './config/app.config';
|
||||||
|
import operatorsConfig from './config/operators.config';
|
||||||
|
import { PrismaService } from './shared/services/prisma.service';
|
||||||
|
import { AuthModule } from './modules/auth/auth.module';
|
||||||
|
import { PartnersModule } from './modules/partners/partners.module';
|
||||||
|
import { OperatorsModule } from './modules/operators/operators.module';
|
||||||
|
import { PaymentsModule } from './modules/payments/payments.module';
|
||||||
|
import { SubscriptionsModule } from './modules/subscriptions/subscriptions.module';
|
||||||
|
import { NotificationsModule } from './modules/notifications/notifications.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
ConfigModule.forRoot({
|
||||||
|
isGlobal: true,
|
||||||
|
load: [appConfig, operatorsConfig],
|
||||||
|
}),
|
||||||
|
BullModule.forRootAsync({
|
||||||
|
imports: [ConfigModule],
|
||||||
|
useFactory: (configService: ConfigService) => ({
|
||||||
|
redis: {
|
||||||
|
host: configService.get('REDIS_HOST'),
|
||||||
|
port: configService.get('REDIS_PORT'),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
inject: [ConfigService],
|
||||||
|
}),
|
||||||
|
CacheModule.register({
|
||||||
|
isGlobal: true,
|
||||||
|
store: redisStore,
|
||||||
|
host: process.env.REDIS_HOST || 'localhost',
|
||||||
|
port: process.env.REDIS_PORT || 6379,
|
||||||
|
}),
|
||||||
|
ScheduleModule.forRoot(),
|
||||||
|
EventEmitterModule.forRoot(),
|
||||||
|
AuthModule,
|
||||||
|
PartnersModule,
|
||||||
|
OperatorsModule,
|
||||||
|
PaymentsModule,
|
||||||
|
SubscriptionsModule,
|
||||||
|
NotificationsModule,
|
||||||
|
],
|
||||||
|
providers: [PrismaService],
|
||||||
|
exports: [PrismaService],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
||||||
8
src/app.service.ts
Normal file
8
src/app.service.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AppService {
|
||||||
|
getHello(): string {
|
||||||
|
return 'Hello World!';
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/config/app.config.ts
Normal file
9
src/config/app.config.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { registerAs } from '@nestjs/config';
|
||||||
|
|
||||||
|
export default registerAs('app', () => ({
|
||||||
|
port: parseInt(process.env.PORT, 10) || 3000,
|
||||||
|
env: process.env.NODE_ENV || 'development',
|
||||||
|
apiPrefix: process.env.API_PREFIX || 'v2',
|
||||||
|
jwtSecret: process.env.JWT_SECRET || 'your-secret-key',
|
||||||
|
jwtExpiresIn: process.env.JWT_EXPIRES_IN || '7d',
|
||||||
|
}));
|
||||||
90
src/config/operators.config.ts
Normal file
90
src/config/operators.config.ts
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const operatorsConfig = (): Record<string, OperatorConfig> => ({
|
||||||
|
ORANGE_CIV: {
|
||||||
|
name: 'Orange Côte d\'Ivoire',
|
||||||
|
baseUrl: process.env.ORANGE_CIV_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': 'OCI',
|
||||||
|
},
|
||||||
|
transformers: {
|
||||||
|
request: 'OrangeRequestTransformer',
|
||||||
|
response: 'OrangeResponseTransformer',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MTN_CMR: {
|
||||||
|
name: 'MTN Cameroon',
|
||||||
|
baseUrl: process.env.MTN_CMR_BASE_URL || 'https://api.mtn.cm',
|
||||||
|
authType: 'REDIRECT',
|
||||||
|
endpoints: {
|
||||||
|
auth: {
|
||||||
|
initialize: '/oauth/v2/authorize',
|
||||||
|
validate: '/oauth/v2/token',
|
||||||
|
},
|
||||||
|
payment: {
|
||||||
|
charge: '/payments/v1/charge',
|
||||||
|
refund: '/payments/v1/refund',
|
||||||
|
status: '/payments/v1/status/{transactionId}',
|
||||||
|
},
|
||||||
|
subscription: {
|
||||||
|
create: '/subscriptions/v1/create',
|
||||||
|
cancel: '/subscriptions/v1/cancel',
|
||||||
|
status: '/subscriptions/v1/status/{subscriptionId}',
|
||||||
|
},
|
||||||
|
sms: {
|
||||||
|
send: '/sms/v1/send',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
headers: {
|
||||||
|
'X-MTN-API-Version': 'v1',
|
||||||
|
},
|
||||||
|
transformers: {
|
||||||
|
request: 'MTNRequestTransformer',
|
||||||
|
response: 'MTNResponseTransformer',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
46
src/main.ts
Normal file
46
src/main.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
|
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||||
|
import { AppModule } from './app.module';
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
const app = await NestFactory.create(AppModule);
|
||||||
|
|
||||||
|
// Global prefix
|
||||||
|
app.setGlobalPrefix('api/v2');
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
app.useGlobalPipes(
|
||||||
|
new ValidationPipe({
|
||||||
|
whitelist: true,
|
||||||
|
transform: true,
|
||||||
|
forbidNonWhitelisted: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// CORS
|
||||||
|
app.enableCors({
|
||||||
|
origin: process.env.CORS_ORIGINS?.split(',') || '*',
|
||||||
|
credentials: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Swagger
|
||||||
|
const config = new DocumentBuilder()
|
||||||
|
.setTitle('Payment Hub API')
|
||||||
|
.setDescription('Unified DCB Payment Aggregation Platform')
|
||||||
|
.setVersion('2.0.0')
|
||||||
|
.addBearerAuth()
|
||||||
|
.addTag('auth')
|
||||||
|
.addTag('payments')
|
||||||
|
.addTag('subscriptions')
|
||||||
|
.build();
|
||||||
|
|
||||||
|
const document = SwaggerModule.createDocument(app, config);
|
||||||
|
SwaggerModule.setup('api/docs', app, document);
|
||||||
|
|
||||||
|
const port = process.env.PORT || 3000;
|
||||||
|
await app.listen(port);
|
||||||
|
console.log(`Application is running on: http://localhost:${port}`);
|
||||||
|
console.log(`Swagger docs: http://localhost:${port}/api/docs`);
|
||||||
|
}
|
||||||
|
bootstrap();
|
||||||
32
src/modules/operators/adapters/operator-adapter.factory.ts
Normal file
32
src/modules/operators/adapters/operator-adapter.factory.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { OrangeAdapter } from './orange.adapter';
|
||||||
|
import { MTNAdapter } from './mtn.adapter';
|
||||||
|
import { IOperatorAdapter } from './operator.adapter.interface';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class OperatorAdapterFactory {
|
||||||
|
constructor(
|
||||||
|
private readonly orangeAdapter: OrangeAdapter,
|
||||||
|
private readonly mtnAdapter: MTNAdapter,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
getAdapter(operator: string, country: string): IOperatorAdapter {
|
||||||
|
const key = `${operator}_${country}`.toUpperCase();
|
||||||
|
|
||||||
|
const adapterMap = {
|
||||||
|
'ORANGE_CI': this.orangeAdapter,
|
||||||
|
'ORANGE_SN': this.orangeAdapter,
|
||||||
|
'ORANGE_CM': this.orangeAdapter,
|
||||||
|
'MTN_CI': this.mtnAdapter,
|
||||||
|
'MTN_CM': this.mtnAdapter,
|
||||||
|
// Ajouter d'autres mappings
|
||||||
|
};
|
||||||
|
|
||||||
|
const adapter = adapterMap[key];
|
||||||
|
if (!adapter) {
|
||||||
|
throw new Error(`No adapter found for ${operator} in ${country}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return adapter;
|
||||||
|
}
|
||||||
|
}
|
||||||
40
src/modules/operators/adapters/operator.adapter.interface.ts
Normal file
40
src/modules/operators/adapters/operator.adapter.interface.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
export interface IOperatorAdapter {
|
||||||
|
initializeAuth(params: AuthInitParams): Promise<AuthInitResponse>;
|
||||||
|
validateAuth(params: AuthValidateParams): Promise<AuthValidateResponse>;
|
||||||
|
charge(params: ChargeParams): Promise<ChargeResponse>;
|
||||||
|
refund(params: RefundParams): Promise<RefundResponse>;
|
||||||
|
sendSms(params: SmsParams): Promise<SmsResponse>;
|
||||||
|
createSubscription?(params: SubscriptionParams): Promise<SubscriptionResponse>;
|
||||||
|
cancelSubscription?(subscriptionId: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthInitParams {
|
||||||
|
msisdn: string;
|
||||||
|
country: string;
|
||||||
|
metadata?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthInitResponse {
|
||||||
|
sessionId: string;
|
||||||
|
challengeId?: string;
|
||||||
|
redirectUrl?: string;
|
||||||
|
status: 'PENDING' | 'IN_PROGRESS';
|
||||||
|
expiresAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChargeParams {
|
||||||
|
userToken: string;
|
||||||
|
userAlias: string;
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
description: string;
|
||||||
|
reference: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChargeResponse {
|
||||||
|
paymentId: string;
|
||||||
|
status: 'SUCCESS' | 'FAILED' | 'PENDING';
|
||||||
|
operatorReference: string;
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
}
|
||||||
259
src/modules/operators/adapters/orange.adapter.ts
Normal file
259
src/modules/operators/adapters/orange.adapter.ts
Normal file
@ -0,0 +1,259 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { HttpService } from '@nestjs/axios';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
import {
|
||||||
|
IOperatorAdapter,
|
||||||
|
AuthInitParams,
|
||||||
|
AuthInitResponse,
|
||||||
|
ChargeParams,
|
||||||
|
ChargeResponse,
|
||||||
|
} from './operator.adapter.interface';
|
||||||
|
import { OrangeTransformer } from '../transformers/orange.transformer';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class OrangeAdapter implements IOperatorAdapter {
|
||||||
|
private baseUrl: string;
|
||||||
|
private accessToken: string;
|
||||||
|
private transformer: OrangeTransformer;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly httpService: HttpService,
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
) {
|
||||||
|
this.baseUrl = this.configService.get('ORANGE_API_URL');
|
||||||
|
this.accessToken = this.configService.get('ORANGE_ACCESS_TOKEN');
|
||||||
|
this.transformer = new OrangeTransformer();
|
||||||
|
}
|
||||||
|
|
||||||
|
async initializeAuth(params: AuthInitParams): Promise<AuthInitResponse> {
|
||||||
|
const countryCode = this.getCountryCode(params.country);
|
||||||
|
|
||||||
|
const bizaoRequest = {
|
||||||
|
challenge: {
|
||||||
|
method: 'OTP-SMS-AUTH',
|
||||||
|
country: countryCode,
|
||||||
|
service: 'BIZAO',
|
||||||
|
partnerId: 'PDKSUB',
|
||||||
|
inputs: [
|
||||||
|
{
|
||||||
|
type: 'MSISDN',
|
||||||
|
value: params.msisdn,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'confirmationCode',
|
||||||
|
value: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'message',
|
||||||
|
value: 'Please confirm your purchase using this code: %OTP%',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'otpLength',
|
||||||
|
value: '4',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'senderName',
|
||||||
|
value: 'PAYMENTHUB',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await firstValueFrom(
|
||||||
|
this.httpService.post(
|
||||||
|
`${this.baseUrl}/challenge/v1/challenges`,
|
||||||
|
bizaoRequest,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${this.accessToken}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Extract challengeId from Location header
|
||||||
|
const location = response.headers.location;
|
||||||
|
const challengeId = location?.split('/').pop();
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionId: challengeId,
|
||||||
|
challengeId,
|
||||||
|
status: 'PENDING',
|
||||||
|
expiresAt: new Date(Date.now() + 15 * 60 * 1000), // 15 minutes
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async validateAuth(params: any): Promise<any> {
|
||||||
|
const bizaoRequest = {
|
||||||
|
challenge: {
|
||||||
|
method: 'OTP-SMS-AUTH',
|
||||||
|
country: params.country,
|
||||||
|
service: 'BIZAO',
|
||||||
|
partnerId: 'PDKSUB',
|
||||||
|
inputs: [
|
||||||
|
{
|
||||||
|
type: 'MSISDN',
|
||||||
|
value: params.msisdn,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'confirmationCode',
|
||||||
|
value: params.otpCode,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'info',
|
||||||
|
value: 'OrangeApiToken,ise2',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await firstValueFrom(
|
||||||
|
this.httpService.post(
|
||||||
|
`${this.baseUrl}/challenge/v1/challenges/${params.challengeId}`,
|
||||||
|
bizaoRequest,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${this.accessToken}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = response.data.challenge.result;
|
||||||
|
const userToken = result.find(r => r.type === 'OrangeApiToken')?.value;
|
||||||
|
const userAlias = result.find(r => r.type === 'ise2')?.value;
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
userToken,
|
||||||
|
userAlias,
|
||||||
|
msisdn: params.msisdn,
|
||||||
|
operator: 'ORANGE',
|
||||||
|
country: params.country,
|
||||||
|
expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async charge(params: ChargeParams): Promise<ChargeResponse> {
|
||||||
|
const bizaoRequest = {
|
||||||
|
amountTransaction: {
|
||||||
|
endUserId: 'acr:OrangeAPIToken',
|
||||||
|
paymentAmount: {
|
||||||
|
chargingInformation: {
|
||||||
|
amount: params.amount.toString(),
|
||||||
|
currency: params.currency,
|
||||||
|
description: params.description,
|
||||||
|
},
|
||||||
|
chargingMetaData: {
|
||||||
|
onBehalfOf: 'PaymentHub',
|
||||||
|
serviceId: 'BIZAO',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
transactionOperationStatus: 'Charged',
|
||||||
|
referenceCode: params.reference,
|
||||||
|
clientCorrelator: `${params.reference}-${Date.now()}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await firstValueFrom(
|
||||||
|
this.httpService.post(
|
||||||
|
`${this.baseUrl}/payment/v1/acr%3AOrangeAPIToken/transactions/amount`,
|
||||||
|
bizaoRequest,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${this.accessToken}`,
|
||||||
|
'bizao-token': params.userToken,
|
||||||
|
'bizao-alias': params.userAlias,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.transformer.transformChargeResponse(response.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async refund(params: any): Promise<any> {
|
||||||
|
// Implement refund logic
|
||||||
|
throw new Error('Refund not implemented for Orange');
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendSms(params: any): Promise<any> {
|
||||||
|
const smsRequest = {
|
||||||
|
outboundSMSMessageRequest: {
|
||||||
|
address: ['acr:X-Orange-ISE2'],
|
||||||
|
senderAddress: `tel:+${this.getSenderNumber(params.country)}`,
|
||||||
|
outboundSMSTextMessage: {
|
||||||
|
message: params.message,
|
||||||
|
},
|
||||||
|
clientCorrelator: `${Date.now()}`,
|
||||||
|
senderName: params.senderName || 'PAYMENTHUB',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await firstValueFrom(
|
||||||
|
this.httpService.post(
|
||||||
|
`${this.baseUrl}/smsmessaging/v1/outbound/tel%3A%2B${this.getSenderNumber(
|
||||||
|
params.country,
|
||||||
|
)}/requests`,
|
||||||
|
smsRequest,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${this.accessToken}`,
|
||||||
|
'X-OAPI-Application-Id': 'BIZAO',
|
||||||
|
'X-OAPI-Contact-Id': 'b2b-bizao-97b5878',
|
||||||
|
'X-OAPI-Resource-Type': 'SMS_OSM',
|
||||||
|
'bizao-alias': params.userAlias,
|
||||||
|
'bizao-token': params.userToken,
|
||||||
|
'X-Orange-MCO': this.getMCO(params.country),
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
messageId: response.data.outboundSMSMessageRequest.clientCorrelator,
|
||||||
|
status: 'SENT',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private getCountryCode(country: string): string {
|
||||||
|
const countryMap = {
|
||||||
|
CI: 'CIV',
|
||||||
|
SN: 'SEN',
|
||||||
|
CM: 'CMR',
|
||||||
|
CD: 'COD',
|
||||||
|
TN: 'TUN',
|
||||||
|
BF: 'BFA',
|
||||||
|
};
|
||||||
|
return countryMap[country] || country;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getMCO(country: string): string {
|
||||||
|
const mcoMap = {
|
||||||
|
CI: 'OCI',
|
||||||
|
SN: 'OSN',
|
||||||
|
CM: 'OCM',
|
||||||
|
CD: 'ODC',
|
||||||
|
TN: 'OTN',
|
||||||
|
BF: 'OBF',
|
||||||
|
};
|
||||||
|
return mcoMap[country];
|
||||||
|
}
|
||||||
|
|
||||||
|
private getSenderNumber(country: string): string {
|
||||||
|
const senderMap = {
|
||||||
|
CI: '2250000',
|
||||||
|
SN: '2210000',
|
||||||
|
CM: '2370000',
|
||||||
|
CD: '2430000',
|
||||||
|
TN: '2160000',
|
||||||
|
BF: '2260000',
|
||||||
|
};
|
||||||
|
return senderMap[country];
|
||||||
|
}
|
||||||
|
}
|
||||||
24
src/modules/operators/adapters/orange.transformer.ts
Normal file
24
src/modules/operators/adapters/orange.transformer.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class OrangeTransformer {
|
||||||
|
transformChargeResponse(bizaoResponse: any): any {
|
||||||
|
return {
|
||||||
|
paymentId: bizaoResponse.amountTransaction?.serverReferenceCode,
|
||||||
|
status: this.mapStatus(bizaoResponse.amountTransaction?.transactionOperationStatus),
|
||||||
|
operatorReference: bizaoResponse.amountTransaction?.serverReferenceCode,
|
||||||
|
amount: parseFloat(bizaoResponse.amountTransaction?.paymentAmount?.totalAmountCharged),
|
||||||
|
currency: bizaoResponse.amountTransaction?.paymentAmount?.chargingInformation?.currency,
|
||||||
|
createdAt: new Date(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapStatus(bizaoStatus: string): string {
|
||||||
|
const statusMap = {
|
||||||
|
'Charged': 'SUCCESS',
|
||||||
|
'Failed': 'FAILED',
|
||||||
|
'Pending': 'PENDING',
|
||||||
|
};
|
||||||
|
return statusMap[bizaoStatus] || 'PENDING';
|
||||||
|
}
|
||||||
|
}
|
||||||
31
src/modules/operators/operators.module.ts
Normal file
31
src/modules/operators/operators.module.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { HttpModule } from '@nestjs/axios';
|
||||||
|
import { OperatorsController } from './operators.controller';
|
||||||
|
import { OperatorsService } from './operators.service';
|
||||||
|
import { OperatorAdapterFactory } from './adapters/operator-adapter.factory';
|
||||||
|
import { OrangeAdapter } from './adapters/orange.adapter';
|
||||||
|
import { MTNAdapter } from './adapters/mtn.adapter';
|
||||||
|
import { OrangeTransformer } from './transformers/orange.transformer';
|
||||||
|
import { MTNTransformer } from './transformers/mtn.transformer';
|
||||||
|
import { PrismaService } from '../../shared/services/prisma.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
HttpModule.register({
|
||||||
|
timeout: 30000,
|
||||||
|
maxRedirects: 3,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
controllers: [OperatorsController],
|
||||||
|
providers: [
|
||||||
|
OperatorsService,
|
||||||
|
OperatorAdapterFactory,
|
||||||
|
OrangeAdapter,
|
||||||
|
MTNAdapter,
|
||||||
|
OrangeTransformer,
|
||||||
|
MTNTransformer,
|
||||||
|
PrismaService,
|
||||||
|
],
|
||||||
|
exports: [OperatorsService],
|
||||||
|
})
|
||||||
|
export class OperatorsModule {}
|
||||||
35
src/modules/payments/dto/charge.dto.ts
Normal file
35
src/modules/payments/dto/charge.dto.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { IsString, IsNumber, IsOptional, IsUrl, Min } from 'class-validator';
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class ChargeDto {
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
userToken: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
amount: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
currency: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
description: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
reference?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUrl()
|
||||||
|
callbackUrl?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
@IsOptional()
|
||||||
|
metadata?: Record<string, any>;
|
||||||
|
}
|
||||||
0
src/modules/payments/payments.controller.ts
Normal file
0
src/modules/payments/payments.controller.ts
Normal file
104
src/modules/payments/payments.service.ts
Normal file
104
src/modules/payments/payments.service.ts
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
import { Injectable, BadRequestException } from '@nestjs/common';
|
||||||
|
import { OperatorsService } from '../operators/operators.service';
|
||||||
|
import { PrismaService } from '../../shared/services/prisma.service';
|
||||||
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
|
import { ChargeDto } from './dto/charge.dto';
|
||||||
|
import { PaymentStatus } from '@prisma/client';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PaymentsService {
|
||||||
|
constructor(
|
||||||
|
private readonly operatorsService: OperatorsService,
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly eventEmitter: EventEmitter2,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async createCharge(chargeDto: ChargeDto) {
|
||||||
|
// Récupérer les informations de l'utilisateur
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { userToken: chargeDto.userToken },
|
||||||
|
include: { operator: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new BadRequestException('Invalid user token');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Créer la transaction dans la base
|
||||||
|
const payment = await this.prisma.payment.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
amount: chargeDto.amount,
|
||||||
|
currency: chargeDto.currency,
|
||||||
|
description: chargeDto.description,
|
||||||
|
reference: chargeDto.reference || this.generateReference(),
|
||||||
|
status: PaymentStatus.PENDING,
|
||||||
|
metadata: chargeDto.metadata,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Router vers le bon opérateur
|
||||||
|
const adapter = this.operatorsService.getAdapter(
|
||||||
|
user.operator.code,
|
||||||
|
user.country,
|
||||||
|
);
|
||||||
|
|
||||||
|
const chargeParams = {
|
||||||
|
userToken: user.userToken,
|
||||||
|
userAlias: user.userAlias,
|
||||||
|
amount: chargeDto.amount,
|
||||||
|
currency: chargeDto.currency,
|
||||||
|
description: chargeDto.description,
|
||||||
|
reference: payment.reference,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await adapter.charge(chargeParams);
|
||||||
|
|
||||||
|
// Mettre à jour le paiement
|
||||||
|
const updatedPayment = await this.prisma.payment.update({
|
||||||
|
where: { id: payment.id },
|
||||||
|
data: {
|
||||||
|
status: result.status === 'SUCCESS'
|
||||||
|
? PaymentStatus.SUCCESS
|
||||||
|
: PaymentStatus.FAILED,
|
||||||
|
operatorReference: result.operatorReference,
|
||||||
|
completedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Émettre un événement
|
||||||
|
this.eventEmitter.emit('payment.completed', {
|
||||||
|
payment: updatedPayment,
|
||||||
|
operator: user.operator.code,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Appeler le callback du partenaire si fourni
|
||||||
|
if (chargeDto.callbackUrl) {
|
||||||
|
await this.notifyPartner(chargeDto.callbackUrl, updatedPayment);
|
||||||
|
}
|
||||||
|
|
||||||
|
return updatedPayment;
|
||||||
|
} catch (error) {
|
||||||
|
// En cas d'erreur, marquer comme échoué
|
||||||
|
await this.prisma.payment.update({
|
||||||
|
where: { id: payment.id },
|
||||||
|
data: {
|
||||||
|
status: PaymentStatus.FAILED,
|
||||||
|
failureReason: error.message,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateReference(): string {
|
||||||
|
return `PAY-${Date.now()}-${Math.random().toString(36).substring(7)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async notifyPartner(callbackUrl: string, payment: any) {
|
||||||
|
// Implémenter la notification webhook
|
||||||
|
// Utiliser Bull Queue pour gérer les retries
|
||||||
|
}
|
||||||
|
}
|
||||||
207
src/modules/subscriptions/subscriptions.service.ts
Normal file
207
src/modules/subscriptions/subscriptions.service.ts
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||||
|
import { PrismaService } from '../../shared/services/prisma.service';
|
||||||
|
import { PaymentsService } from '../payments/payments.service';
|
||||||
|
import { SubscriptionStatus } from '@prisma/client';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SubscriptionsService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly paymentsService: PaymentsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async createSubscription(dto: CreateSubscriptionDto) {
|
||||||
|
// Vérifier l'utilisateur
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { userToken: dto.userToken },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new BadRequestException('Invalid user token');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Récupérer le plan
|
||||||
|
const plan = await this.prisma.plan.findUnique({
|
||||||
|
where: { id: dto.planId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!plan) {
|
||||||
|
throw new BadRequestException('Invalid plan');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Activer en période d'essai
|
||||||
|
await this.prisma.subscription.update({
|
||||||
|
where: { id: subscription.id },
|
||||||
|
data: {
|
||||||
|
status: SubscriptionStatus.TRIAL,
|
||||||
|
trialEndsAt: this.calculateTrialEnd(dto.trialPeriod),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
user: true,
|
||||||
|
plan: 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 (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 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 calculateNextBillingDate(plan: any): Date {
|
||||||
|
return this.calculatePeriodEnd(plan);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
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
|
||||||
|
await this.prisma.subscription.update({
|
||||||
|
where: { id: subscriptionId },
|
||||||
|
data: {
|
||||||
|
status: SubscriptionStatus.SUSPENDED,
|
||||||
|
failureCount,
|
||||||
|
suspendedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Incrémenter le compteur d'échecs
|
||||||
|
await this.prisma.subscription.update({
|
||||||
|
where: { id: subscriptionId },
|
||||||
|
data: {
|
||||||
|
failureCount,
|
||||||
|
nextBillingDate: new Date(Date.now() + 24 * 60 * 60 * 1000), // Réessayer demain
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
19
src/shared/services/prisma.service.ts
Normal file
19
src/shared/services/prisma.service.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||||
|
constructor() {
|
||||||
|
super({
|
||||||
|
log: ['query', 'info', 'warn', 'error'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleInit() {
|
||||||
|
await this.$connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleDestroy() {
|
||||||
|
await this.$disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
25
test/app.e2e-spec.ts
Normal file
25
test/app.e2e-spec.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import * as request from 'supertest';
|
||||||
|
import { App } from 'supertest/types';
|
||||||
|
import { AppModule } from './../src/app.module';
|
||||||
|
|
||||||
|
describe('AppController (e2e)', () => {
|
||||||
|
let app: INestApplication<App>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||||
|
imports: [AppModule],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
app = moduleFixture.createNestApplication();
|
||||||
|
await app.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('/ (GET)', () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.get('/')
|
||||||
|
.expect(200)
|
||||||
|
.expect('Hello World!');
|
||||||
|
});
|
||||||
|
});
|
||||||
9
test/jest-e2e.json
Normal file
9
test/jest-e2e.json
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"moduleFileExtensions": ["js", "json", "ts"],
|
||||||
|
"rootDir": ".",
|
||||||
|
"testEnvironment": "node",
|
||||||
|
"testRegex": ".e2e-spec.ts$",
|
||||||
|
"transform": {
|
||||||
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
|
}
|
||||||
|
}
|
||||||
4
tsconfig.build.json
Normal file
4
tsconfig.build.json
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||||
|
}
|
||||||
25
tsconfig.json
Normal file
25
tsconfig.json
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "nodenext",
|
||||||
|
"moduleResolution": "nodenext",
|
||||||
|
"resolvePackageJsonExports": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"declaration": true,
|
||||||
|
"removeComments": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"target": "ES2023",
|
||||||
|
"sourceMap": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"baseUrl": "./",
|
||||||
|
"incremental": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strictNullChecks": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noImplicitAny": false,
|
||||||
|
"strictBindCallApply": false,
|
||||||
|
"noFallthroughCasesInSwitch": false
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user