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