first commit

This commit is contained in:
Mamadou Khoussa [028918 DSI/DAC/DIF/DS] 2025-10-21 22:54:20 +00:00
commit cb7314e386
32 changed files with 12448 additions and 0 deletions

58
.gitignore vendored Normal file
View 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
View File

@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}

98
README.md Normal file
View 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>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](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
View 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
View 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

File diff suppressed because it is too large Load Diff

73
package.json Normal file
View 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
View File

@ -0,0 +1,5 @@
# Créer les migrations
npx prisma migrate dev --name init
# Générer le client Prisma
npx prisma generate

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

25
tsconfig.json Normal file
View 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
}
}