Compare commits
10 Commits
8b548f4cdc
...
0adc8cf161
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0adc8cf161 | ||
|
|
415a98efa8 | ||
|
|
b8a801edd8 | ||
|
|
dfb19e3448 | ||
|
|
9d848817a0 | ||
|
|
161230cf12 | ||
|
|
46a469bb43 | ||
|
|
ef941a6fe4 | ||
|
|
29e82bc746 | ||
|
|
1bede21bbb |
2
.gitignore
vendored
2
.gitignore
vendored
@ -54,3 +54,5 @@ pids
|
|||||||
|
|
||||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||||
|
|
||||||
|
/generated/prisma
|
||||||
|
|||||||
68
Dockerfile
Normal file
68
Dockerfile
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
# FILE: Dockerfile (VERSION CORRIGÉE)
|
||||||
|
# ============================================================================
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
# Ajouter des dépendances nécessaires
|
||||||
|
RUN apk add --no-cache libc6-compat openssl
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copier les fichiers de dépendances
|
||||||
|
COPY package*.json ./
|
||||||
|
COPY tsconfig*.json ./
|
||||||
|
COPY prisma ./prisma/
|
||||||
|
|
||||||
|
# Installer TOUTES les dépendances (dev incluses pour le build)
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# Générer le client Prisma
|
||||||
|
RUN npx prisma generate
|
||||||
|
|
||||||
|
# Copier le code source
|
||||||
|
COPY src ./src
|
||||||
|
|
||||||
|
# Build l'application
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Vérifier que dist existe
|
||||||
|
RUN ls -la dist/
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Production stage
|
||||||
|
# ============================================================================
|
||||||
|
FROM node:20-alpine AS production
|
||||||
|
|
||||||
|
# Ajouter OpenSSL pour Prisma
|
||||||
|
RUN apk add --no-cache openssl
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copier package.json
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# Installer uniquement les dépendances de production
|
||||||
|
RUN npm ci --only=production && npm cache clean --force
|
||||||
|
|
||||||
|
# Copier le schema Prisma
|
||||||
|
COPY --from=builder /app/prisma ./prisma/
|
||||||
|
|
||||||
|
# Générer le client Prisma en production
|
||||||
|
RUN npx prisma generate
|
||||||
|
|
||||||
|
# Copier les fichiers buildés depuis le builder
|
||||||
|
COPY --from=builder /app/dist ./dist
|
||||||
|
|
||||||
|
|
||||||
|
# Créer un utilisateur non-root
|
||||||
|
RUN addgroup -g 1001 -S nodejs && \
|
||||||
|
adduser -S nestjs -u 1001 && \
|
||||||
|
chown -R nestjs:nodejs /app
|
||||||
|
|
||||||
|
USER nestjs
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
# Démarrer l'application
|
||||||
|
CMD ["node", "dist/src/main.js"]
|
||||||
30
docs.md
Normal file
30
docs.md
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
3. (Première fois seulement) Faire une synchro complète
|
||||||
|
curl -X POST http://localhost:3001/reporting/sync/full
|
||||||
|
|
||||||
|
|
||||||
|
# Test de santé
|
||||||
|
curl http://localhost:3001
|
||||||
|
|
||||||
|
# Transactions journalières (global)
|
||||||
|
curl http://localhost:3001/reporting/transactions/daily
|
||||||
|
|
||||||
|
# Transactions journalières (par marché)
|
||||||
|
curl "http://localhost:3001/reporting/transactions/daily?merchantPartnerId=1"
|
||||||
|
|
||||||
|
# Transactions hebdomadaires
|
||||||
|
curl http://localhost:3001/reporting/transactions/weekly
|
||||||
|
|
||||||
|
# Transactions mensuelles
|
||||||
|
curl http://localhost:3001/reporting/transactions/monthly
|
||||||
|
|
||||||
|
# Avec dates
|
||||||
|
curl "http://localhost:3001/reporting/transactions/daily?startDate=2024-11-01&endDate=2024-11-30"
|
||||||
|
|
||||||
|
# Subscriptions journalières
|
||||||
|
curl http://localhost:3001/reporting/subscriptions/daily
|
||||||
|
|
||||||
|
# Subscriptions mensuelles par marché
|
||||||
|
curl "http://localhost:3001/reporting/subscriptions/monthly?merchantPartnerId=1"
|
||||||
|
|
||||||
|
# Synchronisation manuelle
|
||||||
|
curl -X POST http://localhost:3001/reporting/sync/full
|
||||||
@ -2,6 +2,7 @@
|
|||||||
import eslint from '@eslint/js';
|
import eslint from '@eslint/js';
|
||||||
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
|
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
|
||||||
import globals from 'globals';
|
import globals from 'globals';
|
||||||
|
import { off } from 'process';
|
||||||
import tseslint from 'typescript-eslint';
|
import tseslint from 'typescript-eslint';
|
||||||
|
|
||||||
export default tseslint.config(
|
export default tseslint.config(
|
||||||
@ -17,9 +18,9 @@ export default tseslint.config(
|
|||||||
...globals.node,
|
...globals.node,
|
||||||
...globals.jest,
|
...globals.jest,
|
||||||
},
|
},
|
||||||
sourceType: 'commonjs',
|
sourceType: 'module', // Changé de 'commonjs' à 'module'
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
projectService: true,
|
project: './tsconfig.json', // Ajout du chemin vers tsconfig
|
||||||
tsconfigRootDir: import.meta.dirname,
|
tsconfigRootDir: import.meta.dirname,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -27,8 +28,16 @@ export default tseslint.config(
|
|||||||
{
|
{
|
||||||
rules: {
|
rules: {
|
||||||
'@typescript-eslint/no-explicit-any': 'off',
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
|
'@typescript-eslint/no-unsafe-assignment':'off',
|
||||||
|
'eslint-disable-next-line @typescript-eslint/no-unsafe-call':'off',
|
||||||
'@typescript-eslint/no-floating-promises': 'warn',
|
'@typescript-eslint/no-floating-promises': 'warn',
|
||||||
'@typescript-eslint/no-unsafe-argument': 'warn'
|
'@typescript-eslint/no-unsafe-argument': 'warn',
|
||||||
|
'prettier/prettier': ['error', {
|
||||||
|
singleQuote: true,
|
||||||
|
trailingComma: 'all',
|
||||||
|
printWidth: 100,
|
||||||
|
tabWidth: 2,
|
||||||
|
}],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
805
package-lock.json
generated
805
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
12
package.json
12
package.json
@ -2,6 +2,7 @@
|
|||||||
"name": "reporting-api",
|
"name": "reporting-api",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"description": "",
|
"description": "",
|
||||||
|
"type": "commonjs",
|
||||||
"author": "",
|
"author": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
@ -21,8 +22,17 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/common": "^11.0.1",
|
"@nestjs/common": "^11.0.1",
|
||||||
|
"@nestjs/config": "^4.0.2",
|
||||||
"@nestjs/core": "^11.0.1",
|
"@nestjs/core": "^11.0.1",
|
||||||
|
"@nestjs/mongoose": "^11.0.3",
|
||||||
"@nestjs/platform-express": "^11.0.1",
|
"@nestjs/platform-express": "^11.0.1",
|
||||||
|
"@nestjs/schedule": "^6.0.1",
|
||||||
|
"@nestjs/swagger": "^11.2.3",
|
||||||
|
"@prisma/client": "^6.17.1",
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
|
"class-validator": "^0.14.3",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"prisma": "^6.17.1",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1"
|
"rxjs": "^7.8.1"
|
||||||
},
|
},
|
||||||
@ -34,7 +44,7 @@
|
|||||||
"@nestjs/testing": "^11.0.1",
|
"@nestjs/testing": "^11.0.1",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/node": "^22.10.7",
|
"@types/node": "^22.19.1",
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.0.2",
|
||||||
"eslint": "^9.18.0",
|
"eslint": "^9.18.0",
|
||||||
"eslint-config-prettier": "^10.0.1",
|
"eslint-config-prettier": "^10.0.1",
|
||||||
|
|||||||
14
prisma.config.ts
Normal file
14
prisma.config.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
// This file was generated by Prisma and assumes you have installed the following:
|
||||||
|
// npm install --save-dev prisma dotenv
|
||||||
|
import "dotenv/config";
|
||||||
|
import { defineConfig, env } from "prisma/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: "prisma/schema.prisma",
|
||||||
|
migrations: {
|
||||||
|
path: "prisma/migrations",
|
||||||
|
},
|
||||||
|
datasource: {
|
||||||
|
url: env("DATABASE_URL"),
|
||||||
|
},
|
||||||
|
});
|
||||||
140
prisma/schema.prisma
Normal file
140
prisma/schema.prisma
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
// 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"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL") // ← AJOUTEZ CETTE LIGNE
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
model Transaction {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
date DateTime @default(now())
|
||||||
|
amount Float
|
||||||
|
tax Float
|
||||||
|
status TransactionStatus
|
||||||
|
merchantPartnerId Int
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
reversementRequests ReversementRequest[]
|
||||||
|
|
||||||
|
@@map("transactions")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TransactionStatus {
|
||||||
|
SUCCESS
|
||||||
|
FAILED
|
||||||
|
PENDING
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SubscriptionStatus {
|
||||||
|
ACTIVE
|
||||||
|
TRIAL
|
||||||
|
PENDING
|
||||||
|
SUSPENDED
|
||||||
|
EXPIRED
|
||||||
|
CANCELLED
|
||||||
|
}
|
||||||
|
enum Periodicity {
|
||||||
|
Daily
|
||||||
|
Weekly
|
||||||
|
Monthly
|
||||||
|
OneTime
|
||||||
|
}
|
||||||
|
|
||||||
|
model Subscription {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
externalReference String?
|
||||||
|
periodicity Periodicity
|
||||||
|
startDate DateTime
|
||||||
|
endDate DateTime?
|
||||||
|
amount Float
|
||||||
|
currency String
|
||||||
|
token String
|
||||||
|
status SubscriptionStatus
|
||||||
|
nextPaymentDate DateTime
|
||||||
|
suspendedAt DateTime?
|
||||||
|
merchantPartnerId Int
|
||||||
|
customerId Int
|
||||||
|
planId Int
|
||||||
|
serviceId Int
|
||||||
|
failureCount Int?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
metadata Json?
|
||||||
|
|
||||||
|
@@map("subscriptions")
|
||||||
|
}
|
||||||
|
|
||||||
|
model ReversementRequest {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
externalReference String?
|
||||||
|
startDate DateTime
|
||||||
|
endDate DateTime
|
||||||
|
amount Float
|
||||||
|
tax Float
|
||||||
|
status TransactionStatus
|
||||||
|
transactionId Int
|
||||||
|
paymentId Int?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
metadata Json?
|
||||||
|
|
||||||
|
transaction Transaction @relation(fields: [transactionId], references: [id], onDelete: Cascade)
|
||||||
|
payment Payment? @relation(fields: [paymentId], references: [id])
|
||||||
|
|
||||||
|
@@map("reversement_requests")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Payment {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
externalReference String?
|
||||||
|
reference String?
|
||||||
|
type PaymentType
|
||||||
|
status TransactionStatus
|
||||||
|
merchantPartnerId Int
|
||||||
|
failureReason String?
|
||||||
|
amount Float?
|
||||||
|
currency String
|
||||||
|
completedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
customerId Int
|
||||||
|
subscriptionId Int?
|
||||||
|
metadata Json?
|
||||||
|
link String?
|
||||||
|
|
||||||
|
reversementRequests ReversementRequest[]
|
||||||
|
|
||||||
|
@@map("payments")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum PaymentType {
|
||||||
|
MM
|
||||||
|
BANK
|
||||||
|
CHEQUE
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
msisdn String @unique
|
||||||
|
userToken String @unique
|
||||||
|
userAlias String
|
||||||
|
operatorId String
|
||||||
|
merchantPartnerId Int
|
||||||
|
country String
|
||||||
|
metadata Json?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
0
prisma/seed.ts
Normal file
0
prisma/seed.ts
Normal file
@ -1,10 +1,51 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { AppController } from './app.controller';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { AppService } from './app.service';
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
|
import { MongooseModule } from '@nestjs/mongoose';
|
||||||
|
import { PrismaModule } from './prisma/prisma.module';
|
||||||
|
import { MongodbModule } from './mongodb/mongodb.module';
|
||||||
|
import { SyncModule } from './sync/sync.module';
|
||||||
|
import { ReportingModule } from './reporting/reporting.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [],
|
imports: [
|
||||||
controllers: [AppController],
|
// Configuration globale
|
||||||
providers: [AppService],
|
ConfigModule.forRoot({
|
||||||
|
isGlobal: true,
|
||||||
|
envFilePath: '.env',
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Scheduler pour la synchronisation automatique
|
||||||
|
ScheduleModule.forRoot(),
|
||||||
|
|
||||||
|
// Connexion MongoDB
|
||||||
|
MongooseModule.forRootAsync({
|
||||||
|
useFactory: () => ({
|
||||||
|
uri: process.env.MONGODB_URI,
|
||||||
|
/**
|
||||||
|
|
||||||
|
|
||||||
|
connectionFactory: (connection) => {
|
||||||
|
connection.on('connected', () => {
|
||||||
|
console.log('✅ MongoDB connected');
|
||||||
|
});
|
||||||
|
connection.on('error', (error) => {
|
||||||
|
console.error('❌ MongoDB connection error:', error);
|
||||||
|
});
|
||||||
|
connection.on('disconnected', () => {
|
||||||
|
console.log('❌ MongoDB disconnected');
|
||||||
|
});
|
||||||
|
return connection;
|
||||||
|
},
|
||||||
|
*/
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Modules de l'application
|
||||||
|
PrismaModule,
|
||||||
|
MongodbModule,
|
||||||
|
SyncModule,
|
||||||
|
ReportingModule,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
46
src/main.ts
46
src/main.ts
@ -1,8 +1,52 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
||||||
|
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
|
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create(AppModule);
|
||||||
await app.listen(process.env.PORT ?? 3000);
|
|
||||||
|
// Global prefix
|
||||||
|
app.setGlobalPrefix('api/v1');
|
||||||
|
|
||||||
|
// 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('1.0.0')
|
||||||
|
.addBearerAuth()
|
||||||
|
.addTag('payments')
|
||||||
|
.addTag('subscriptions')
|
||||||
|
.build();
|
||||||
|
|
||||||
|
const document = SwaggerModule.createDocument(app, config);
|
||||||
|
SwaggerModule.setup('api/docs', app, document);
|
||||||
|
|
||||||
|
app.getHttpAdapter().get('/api/swagger-json', (req, res) => {
|
||||||
|
res.json(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`);
|
||||||
|
console.log(`Swagger docs: http://localhost:${port}/api/swagger-json`);
|
||||||
}
|
}
|
||||||
bootstrap();
|
bootstrap();
|
||||||
|
|||||||
21
src/mongodb/mongodb.module.ts
Normal file
21
src/mongodb/mongodb.module.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { MongooseModule } from '@nestjs/mongoose';
|
||||||
|
import {
|
||||||
|
TransactionDoc,
|
||||||
|
TransactionSchema,
|
||||||
|
} from './schemas/transaction.schema';
|
||||||
|
import {
|
||||||
|
SubscriptionDoc,
|
||||||
|
SubscriptionSchema,
|
||||||
|
} from './schemas/subscription.schema';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
MongooseModule.forFeature([
|
||||||
|
{ name: TransactionDoc.name, schema: TransactionSchema },
|
||||||
|
{ name: SubscriptionDoc.name, schema: SubscriptionSchema },
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
exports: [MongooseModule],
|
||||||
|
})
|
||||||
|
export class MongodbModule {}
|
||||||
51
src/mongodb/schemas/subscription.schema.ts
Normal file
51
src/mongodb/schemas/subscription.schema.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
|
||||||
|
import { Document } from 'mongoose';
|
||||||
|
|
||||||
|
@Schema({ collection: 'subscriptions', timestamps: true })
|
||||||
|
export class SubscriptionDoc extends Document {
|
||||||
|
@Prop({ required: true, unique: true })
|
||||||
|
subscriptionId: number;
|
||||||
|
|
||||||
|
@Prop({ required: true })
|
||||||
|
startDate: Date;
|
||||||
|
|
||||||
|
@Prop()
|
||||||
|
endDate: Date;
|
||||||
|
|
||||||
|
@Prop({ required: true })
|
||||||
|
amount: number;
|
||||||
|
|
||||||
|
@Prop({ required: true })
|
||||||
|
currency: string;
|
||||||
|
|
||||||
|
@Prop({ required: true })
|
||||||
|
status: string;
|
||||||
|
|
||||||
|
@Prop({ required: true })
|
||||||
|
periodicity: string;
|
||||||
|
|
||||||
|
@Prop({ required: true })
|
||||||
|
merchantPartnerId: number;
|
||||||
|
|
||||||
|
@Prop()
|
||||||
|
year: number;
|
||||||
|
|
||||||
|
@Prop()
|
||||||
|
month: number;
|
||||||
|
|
||||||
|
@Prop()
|
||||||
|
week: number;
|
||||||
|
|
||||||
|
@Prop()
|
||||||
|
day: string; // Format: YYYY-MM-DD
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SubscriptionSchema = SchemaFactory.createForClass(SubscriptionDoc);
|
||||||
|
|
||||||
|
// Index pour optimiser les requêtes
|
||||||
|
SubscriptionSchema.index({ merchantPartnerId: 1, startDate: -1 });
|
||||||
|
SubscriptionSchema.index({ startDate: -1 });
|
||||||
|
SubscriptionSchema.index({ day: 1, merchantPartnerId: 1 });
|
||||||
|
SubscriptionSchema.index({ year: 1, month: 1 });
|
||||||
|
SubscriptionSchema.index({ year: 1, week: 1 });
|
||||||
|
SubscriptionSchema.index({ status: 1 });
|
||||||
45
src/mongodb/schemas/transaction.schema.ts
Normal file
45
src/mongodb/schemas/transaction.schema.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
|
||||||
|
import { Document } from 'mongoose';
|
||||||
|
|
||||||
|
@Schema({ collection: 'transactions', timestamps: true })
|
||||||
|
export class TransactionDoc extends Document {
|
||||||
|
@Prop({ required: true, unique: true })
|
||||||
|
transactionId: number;
|
||||||
|
|
||||||
|
@Prop({ required: true })
|
||||||
|
date: Date;
|
||||||
|
|
||||||
|
@Prop({ required: true })
|
||||||
|
amount: number;
|
||||||
|
|
||||||
|
@Prop({ required: true })
|
||||||
|
tax: number;
|
||||||
|
|
||||||
|
@Prop({ required: true })
|
||||||
|
status: string;
|
||||||
|
|
||||||
|
@Prop({ required: true })
|
||||||
|
merchantPartnerId: number;
|
||||||
|
|
||||||
|
@Prop()
|
||||||
|
year: number;
|
||||||
|
|
||||||
|
@Prop()
|
||||||
|
month: number;
|
||||||
|
|
||||||
|
@Prop()
|
||||||
|
week: number;
|
||||||
|
|
||||||
|
@Prop()
|
||||||
|
day: string; // Format: YYYY-MM-DD
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TransactionSchema = SchemaFactory.createForClass(TransactionDoc);
|
||||||
|
|
||||||
|
// Index pour optimiser les requêtes
|
||||||
|
TransactionSchema.index({ merchantPartnerId: 1, date: -1 });
|
||||||
|
TransactionSchema.index({ date: -1 });
|
||||||
|
TransactionSchema.index({ day: 1, merchantPartnerId: 1 });
|
||||||
|
TransactionSchema.index({ year: 1, month: 1 });
|
||||||
|
TransactionSchema.index({ year: 1, week: 1 });
|
||||||
|
TransactionSchema.index({ status: 1 });
|
||||||
9
src/prisma/prisma.module.ts
Normal file
9
src/prisma/prisma.module.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { Module, Global } from '@nestjs/common';
|
||||||
|
import { PrismaService } from './prisma.service';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [PrismaService],
|
||||||
|
exports: [PrismaService],
|
||||||
|
})
|
||||||
|
export class PrismaModule {}
|
||||||
18
src/prisma/prisma.service.ts
Normal file
18
src/prisma/prisma.service.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
//import { PrismaClient } from 'generated/prisma/client';
|
||||||
|
@Injectable()
|
||||||
|
export class PrismaService
|
||||||
|
extends PrismaClient
|
||||||
|
implements OnModuleInit, OnModuleDestroy
|
||||||
|
{
|
||||||
|
async onModuleInit() {
|
||||||
|
await this.$connect();
|
||||||
|
console.log('✅ PostgreSQL connected via Prisma');
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleDestroy() {
|
||||||
|
await this.$disconnect();
|
||||||
|
console.log('❌ PostgreSQL disconnected');
|
||||||
|
}
|
||||||
|
}
|
||||||
34
src/reporting/dto/report-query.dto.ts
Normal file
34
src/reporting/dto/report-query.dto.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import { IsEnum, IsOptional, IsInt, IsDateString } from 'class-validator';
|
||||||
|
import { Transform } from 'class-transformer';
|
||||||
|
|
||||||
|
export enum ReportPeriod {
|
||||||
|
DAILY = 'daily',
|
||||||
|
WEEKLY = 'weekly',
|
||||||
|
MONTHLY = 'monthly',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ReportType {
|
||||||
|
TRANSACTION = 'transaction',
|
||||||
|
SUBSCRIPTION = 'subscription',
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ReportQueryDto {
|
||||||
|
@IsEnum(ReportPeriod)
|
||||||
|
period: ReportPeriod;
|
||||||
|
|
||||||
|
@IsEnum(ReportType)
|
||||||
|
type: ReportType;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Transform(({ value }) => parseInt(value))
|
||||||
|
merchantPartnerId?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
startDate?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
endDate?: string;
|
||||||
|
}
|
||||||
29
src/reporting/dto/report-response.dto.ts
Normal file
29
src/reporting/dto/report-response.dto.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
export class ReportItemDto {
|
||||||
|
period: string; // "2024-01-15", "2024-W03", "2024-01"
|
||||||
|
totalAmount: number;
|
||||||
|
totalTax?: number;
|
||||||
|
count: number;
|
||||||
|
successCount?: number;
|
||||||
|
failedCount?: number;
|
||||||
|
pendingCount?: number;
|
||||||
|
activeCount?: number;
|
||||||
|
cancelledCount?: number;
|
||||||
|
merchantPartnerId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ReportResponseDto {
|
||||||
|
type: string; // 'transaction' | 'subscription'
|
||||||
|
period: string; // 'daily' | 'weekly' | 'monthly'
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
merchantPartnerId?: number;
|
||||||
|
totalAmount: number;
|
||||||
|
totalCount: number;
|
||||||
|
items: ReportItemDto[];
|
||||||
|
generatedAt: Date;
|
||||||
|
summary?: {
|
||||||
|
avgAmount?: number;
|
||||||
|
minAmount?: number;
|
||||||
|
maxAmount?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
150
src/reporting/reporting.controller.ts
Normal file
150
src/reporting/reporting.controller.ts
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Query,
|
||||||
|
Post,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
Logger,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ReportingService } from './reporting.service';
|
||||||
|
import { ReportPeriod, ReportType } from './dto/report-query.dto';
|
||||||
|
import { ReportResponseDto } from './dto/report-response.dto';
|
||||||
|
import { SyncService } from '../sync/sync.service';
|
||||||
|
|
||||||
|
@Controller('reporting')
|
||||||
|
export class ReportingController {
|
||||||
|
private readonly logger = new Logger(ReportingController.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private reportingService: ReportingService,
|
||||||
|
private syncService: SyncService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get('transactions/daily')
|
||||||
|
async getTransactionsDaily(
|
||||||
|
@Query('merchantPartnerId') merchantPartnerId?: string,
|
||||||
|
@Query('startDate') startDate?: string,
|
||||||
|
@Query('endDate') endDate?: string,
|
||||||
|
): Promise<ReportResponseDto> {
|
||||||
|
return this.reportingService.generateReport({
|
||||||
|
period: ReportPeriod.DAILY,
|
||||||
|
type: ReportType.TRANSACTION,
|
||||||
|
merchantPartnerId: merchantPartnerId
|
||||||
|
? parseInt(merchantPartnerId)
|
||||||
|
: undefined,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('transactions/weekly')
|
||||||
|
async getTransactionsWeekly(
|
||||||
|
@Query('merchantPartnerId') merchantPartnerId?: string,
|
||||||
|
@Query('startDate') startDate?: string,
|
||||||
|
@Query('endDate') endDate?: string,
|
||||||
|
): Promise<ReportResponseDto> {
|
||||||
|
return this.reportingService.generateReport({
|
||||||
|
period: ReportPeriod.WEEKLY,
|
||||||
|
type: ReportType.TRANSACTION,
|
||||||
|
merchantPartnerId: merchantPartnerId
|
||||||
|
? parseInt(merchantPartnerId)
|
||||||
|
: undefined,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('transactions/monthly')
|
||||||
|
async getTransactionsMonthly(
|
||||||
|
@Query('merchantPartnerId') merchantPartnerId?: string,
|
||||||
|
@Query('startDate') startDate?: string,
|
||||||
|
@Query('endDate') endDate?: string,
|
||||||
|
): Promise<ReportResponseDto> {
|
||||||
|
return this.reportingService.generateReport({
|
||||||
|
period: ReportPeriod.MONTHLY,
|
||||||
|
type: ReportType.TRANSACTION,
|
||||||
|
merchantPartnerId: merchantPartnerId
|
||||||
|
? parseInt(merchantPartnerId)
|
||||||
|
: undefined,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('subscriptions/daily')
|
||||||
|
async getSubscriptionsDaily(
|
||||||
|
@Query('merchantPartnerId') merchantPartnerId?: string,
|
||||||
|
@Query('startDate') startDate?: string,
|
||||||
|
@Query('endDate') endDate?: string,
|
||||||
|
): Promise<ReportResponseDto> {
|
||||||
|
return this.reportingService.generateReport({
|
||||||
|
period: ReportPeriod.DAILY,
|
||||||
|
type: ReportType.SUBSCRIPTION,
|
||||||
|
merchantPartnerId: merchantPartnerId
|
||||||
|
? parseInt(merchantPartnerId)
|
||||||
|
: undefined,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('subscriptions/weekly')
|
||||||
|
async getSubscriptionsWeekly(
|
||||||
|
@Query('merchantPartnerId') merchantPartnerId?: string,
|
||||||
|
@Query('startDate') startDate?: string,
|
||||||
|
@Query('endDate') endDate?: string,
|
||||||
|
): Promise<ReportResponseDto> {
|
||||||
|
return this.reportingService.generateReport({
|
||||||
|
period: ReportPeriod.WEEKLY,
|
||||||
|
type: ReportType.SUBSCRIPTION,
|
||||||
|
merchantPartnerId: merchantPartnerId
|
||||||
|
? parseInt(merchantPartnerId)
|
||||||
|
: undefined,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('subscriptions/monthly')
|
||||||
|
async getSubscriptionsMonthly(
|
||||||
|
@Query('merchantPartnerId') merchantPartnerId?: string,
|
||||||
|
@Query('startDate') startDate?: string,
|
||||||
|
@Query('endDate') endDate?: string,
|
||||||
|
): Promise<ReportResponseDto> {
|
||||||
|
return this.reportingService.generateReport({
|
||||||
|
period: ReportPeriod.MONTHLY,
|
||||||
|
type: ReportType.SUBSCRIPTION,
|
||||||
|
merchantPartnerId: merchantPartnerId
|
||||||
|
? parseInt(merchantPartnerId)
|
||||||
|
: undefined,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('sync/full')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async triggerFullSync(): Promise<{ message: string; timestamp: Date }> {
|
||||||
|
this.logger.log('🔄 Manual full sync triggered');
|
||||||
|
await this.syncService.fullSync();
|
||||||
|
return {
|
||||||
|
message: 'Full sync completed successfully',
|
||||||
|
timestamp: new Date(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('sync/incremental')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async triggerIncrementalSync(): Promise<{
|
||||||
|
message: string;
|
||||||
|
timestamp: Date;
|
||||||
|
}> {
|
||||||
|
this.logger.log('🔄 Manual incremental sync triggered');
|
||||||
|
await this.syncService.incrementalSync();
|
||||||
|
return {
|
||||||
|
message: 'Incremental sync completed successfully',
|
||||||
|
timestamp: new Date(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/reporting/reporting.module.ts
Normal file
12
src/reporting/reporting.module.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { MongodbModule } from '../mongodb/mongodb.module';
|
||||||
|
import { SyncModule } from '../sync/sync.module';
|
||||||
|
import { ReportingController } from './reporting.controller';
|
||||||
|
import { ReportingService } from './reporting.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [MongodbModule, SyncModule],
|
||||||
|
controllers: [ReportingController],
|
||||||
|
providers: [ReportingService],
|
||||||
|
})
|
||||||
|
export class ReportingModule {}
|
||||||
225
src/reporting/reporting.service.ts
Normal file
225
src/reporting/reporting.service.ts
Normal file
@ -0,0 +1,225 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { InjectModel } from '@nestjs/mongoose';
|
||||||
|
import { Model } from 'mongoose';
|
||||||
|
import { TransactionDoc } from '../mongodb/schemas/transaction.schema';
|
||||||
|
import { SubscriptionDoc } from '../mongodb/schemas/subscription.schema';
|
||||||
|
import {
|
||||||
|
ReportQueryDto,
|
||||||
|
ReportPeriod,
|
||||||
|
ReportType,
|
||||||
|
} from './dto/report-query.dto';
|
||||||
|
import { ReportResponseDto, ReportItemDto } from './dto/report-response.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ReportingService {
|
||||||
|
private readonly logger = new Logger(ReportingService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectModel(TransactionDoc.name)
|
||||||
|
private transactionModel: Model<TransactionDoc>,
|
||||||
|
@InjectModel(SubscriptionDoc.name)
|
||||||
|
private subscriptionModel: Model<SubscriptionDoc>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async generateReport(query: ReportQueryDto): Promise<ReportResponseDto> {
|
||||||
|
this.logger.log(`📊 Generating ${query.type} report - ${query.period}`);
|
||||||
|
|
||||||
|
const { period, type, merchantPartnerId, startDate, endDate } = query;
|
||||||
|
const dateFilter = this.buildDateFilter(startDate, endDate, type);
|
||||||
|
const merchantFilter = merchantPartnerId ? { merchantPartnerId } : {};
|
||||||
|
const filter = { ...dateFilter, ...merchantFilter };
|
||||||
|
|
||||||
|
if (type === ReportType.TRANSACTION) {
|
||||||
|
return this.generateTransactionReport(period, filter, query);
|
||||||
|
} else {
|
||||||
|
return this.generateSubscriptionReport(period, filter, query);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async generateTransactionReport(
|
||||||
|
period: ReportPeriod,
|
||||||
|
filter: any,
|
||||||
|
query: ReportQueryDto,
|
||||||
|
): Promise<ReportResponseDto> {
|
||||||
|
const groupBy = this.getGroupByField(period);
|
||||||
|
|
||||||
|
const pipeline: any[] = [
|
||||||
|
{ $match: filter },
|
||||||
|
{
|
||||||
|
$group: {
|
||||||
|
_id: groupBy,
|
||||||
|
totalAmount: { $sum: '$amount' },
|
||||||
|
totalTax: { $sum: '$tax' },
|
||||||
|
count: { $sum: 1 },
|
||||||
|
successCount: {
|
||||||
|
$sum: { $cond: [{ $eq: ['$status', 'SUCCESS'] }, 1, 0] },
|
||||||
|
},
|
||||||
|
failedCount: {
|
||||||
|
$sum: { $cond: [{ $eq: ['$status', 'FAILED'] }, 1, 0] },
|
||||||
|
},
|
||||||
|
pendingCount: {
|
||||||
|
$sum: { $cond: [{ $eq: ['$status', 'PENDING'] }, 1, 0] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ $sort: { _id: 1 } },
|
||||||
|
];
|
||||||
|
|
||||||
|
if (filter.merchantPartnerId) {
|
||||||
|
pipeline[1].$group['merchantPartnerId'] = {
|
||||||
|
$first: '$merchantPartnerId',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await this.transactionModel.aggregate(pipeline);
|
||||||
|
|
||||||
|
const items: ReportItemDto[] = results.map((item) => ({
|
||||||
|
period: this.formatPeriod(item._id, period),
|
||||||
|
totalAmount: Math.round(item.totalAmount * 100) / 100,
|
||||||
|
totalTax: Math.round(item.totalTax * 100) / 100,
|
||||||
|
count: item.count,
|
||||||
|
successCount: item.successCount,
|
||||||
|
failedCount: item.failedCount,
|
||||||
|
pendingCount: item.pendingCount,
|
||||||
|
merchantPartnerId: item.merchantPartnerId,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const totalAmount = items.reduce((sum, item) => sum + item.totalAmount, 0);
|
||||||
|
const totalCount = items.reduce((sum, item) => sum + item.count, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'transaction',
|
||||||
|
period: period,
|
||||||
|
startDate: query.startDate || '',
|
||||||
|
endDate: query.endDate || '',
|
||||||
|
merchantPartnerId: query.merchantPartnerId,
|
||||||
|
totalAmount: Math.round(totalAmount * 100) / 100,
|
||||||
|
totalCount,
|
||||||
|
items,
|
||||||
|
summary: {
|
||||||
|
avgAmount:
|
||||||
|
totalCount > 0
|
||||||
|
? Math.round((totalAmount / totalCount) * 100) / 100
|
||||||
|
: 0,
|
||||||
|
minAmount:
|
||||||
|
items.length > 0 ? Math.min(...items.map((i) => i.totalAmount)) : 0,
|
||||||
|
maxAmount:
|
||||||
|
items.length > 0 ? Math.max(...items.map((i) => i.totalAmount)) : 0,
|
||||||
|
},
|
||||||
|
generatedAt: new Date(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async generateSubscriptionReport(
|
||||||
|
period: ReportPeriod,
|
||||||
|
filter: any,
|
||||||
|
query: ReportQueryDto,
|
||||||
|
): Promise<ReportResponseDto> {
|
||||||
|
const groupBy = this.getGroupByField(period);
|
||||||
|
|
||||||
|
const pipeline: any[] = [
|
||||||
|
{ $match: filter },
|
||||||
|
{
|
||||||
|
$group: {
|
||||||
|
_id: groupBy,
|
||||||
|
totalAmount: { $sum: '$amount' },
|
||||||
|
count: { $sum: 1 },
|
||||||
|
activeCount: {
|
||||||
|
$sum: { $cond: [{ $eq: ['$status', 'ACTIVE'] }, 1, 0] },
|
||||||
|
},
|
||||||
|
cancelledCount: {
|
||||||
|
$sum: { $cond: [{ $eq: ['$status', 'CANCELLED'] }, 1, 0] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ $sort: { _id: 1 } },
|
||||||
|
];
|
||||||
|
|
||||||
|
if (filter.merchantPartnerId) {
|
||||||
|
pipeline[1].$group['merchantPartnerId'] = {
|
||||||
|
$first: '$merchantPartnerId',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await this.subscriptionModel.aggregate(pipeline);
|
||||||
|
|
||||||
|
const items: ReportItemDto[] = results.map((item) => ({
|
||||||
|
period: this.formatPeriod(item._id, period),
|
||||||
|
totalAmount: Math.round(item.totalAmount * 100) / 100,
|
||||||
|
count: item.count,
|
||||||
|
activeCount: item.activeCount,
|
||||||
|
cancelledCount: item.cancelledCount,
|
||||||
|
merchantPartnerId: item.merchantPartnerId,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const totalAmount = items.reduce((sum, item) => sum + item.totalAmount, 0);
|
||||||
|
const totalCount = items.reduce((sum, item) => sum + item.count, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'subscription',
|
||||||
|
period: period,
|
||||||
|
startDate: query.startDate || '',
|
||||||
|
endDate: query.endDate || '',
|
||||||
|
merchantPartnerId: query.merchantPartnerId,
|
||||||
|
totalAmount: Math.round(totalAmount * 100) / 100,
|
||||||
|
totalCount,
|
||||||
|
items,
|
||||||
|
summary: {
|
||||||
|
avgAmount:
|
||||||
|
totalCount > 0
|
||||||
|
? Math.round((totalAmount / totalCount) * 100) / 100
|
||||||
|
: 0,
|
||||||
|
minAmount:
|
||||||
|
items.length > 0 ? Math.min(...items.map((i) => i.totalAmount)) : 0,
|
||||||
|
maxAmount:
|
||||||
|
items.length > 0 ? Math.max(...items.map((i) => i.totalAmount)) : 0,
|
||||||
|
},
|
||||||
|
generatedAt: new Date(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildDateFilter(
|
||||||
|
startDate?: string,
|
||||||
|
endDate?: string,
|
||||||
|
type?: ReportType,
|
||||||
|
): any {
|
||||||
|
if (!startDate && !endDate) return {};
|
||||||
|
|
||||||
|
const filter: any = {};
|
||||||
|
const dateField = type === ReportType.TRANSACTION ? 'date' : 'startDate';
|
||||||
|
|
||||||
|
if (startDate) {
|
||||||
|
filter[dateField] = { $gte: new Date(startDate) };
|
||||||
|
}
|
||||||
|
if (endDate) {
|
||||||
|
const end = new Date(endDate);
|
||||||
|
end.setHours(23, 59, 59, 999);
|
||||||
|
filter[dateField] = { ...filter[dateField], $lte: end };
|
||||||
|
}
|
||||||
|
|
||||||
|
return filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getGroupByField(period: ReportPeriod): any {
|
||||||
|
switch (period) {
|
||||||
|
case ReportPeriod.DAILY:
|
||||||
|
return '$day';
|
||||||
|
case ReportPeriod.WEEKLY:
|
||||||
|
return { year: '$year', week: '$week' };
|
||||||
|
case ReportPeriod.MONTHLY:
|
||||||
|
return { year: '$year', month: '$month' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatPeriod(value: any, period: ReportPeriod): string {
|
||||||
|
if (period === ReportPeriod.DAILY) return value;
|
||||||
|
if (period === ReportPeriod.WEEKLY) {
|
||||||
|
return `${value.year}-W${String(value.week).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
if (period === ReportPeriod.MONTHLY) {
|
||||||
|
return `${value.year}-${String(value.month).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/sync/sync.module.ts
Normal file
11
src/sync/sync.module.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { MongodbModule } from '../mongodb/mongodb.module';
|
||||||
|
import { SyncService } from './sync.service';
|
||||||
|
import { SyncScheduler } from './sync.scheduler';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [MongodbModule],
|
||||||
|
providers: [SyncService, SyncScheduler],
|
||||||
|
exports: [SyncService],
|
||||||
|
})
|
||||||
|
export class SyncModule {}
|
||||||
21
src/sync/sync.scheduler.ts
Normal file
21
src/sync/sync.scheduler.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||||
|
import { SyncService } from './sync.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SyncScheduler {
|
||||||
|
private readonly logger = new Logger(SyncScheduler.name);
|
||||||
|
|
||||||
|
constructor(private syncService: SyncService) {}
|
||||||
|
|
||||||
|
@Cron(CronExpression.EVERY_5_MINUTES)
|
||||||
|
async handleIncrementalSync() {
|
||||||
|
this.logger.log('⏰ Running scheduled incremental sync');
|
||||||
|
try {
|
||||||
|
await this.syncService.incrementalSync();
|
||||||
|
} catch (error) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||||
|
this.logger.error('❌ Scheduled sync failed', error.stack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
165
src/sync/sync.service.ts
Normal file
165
src/sync/sync.service.ts
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { InjectModel } from '@nestjs/mongoose';
|
||||||
|
import { Model } from 'mongoose';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { TransactionDoc } from '../mongodb/schemas/transaction.schema';
|
||||||
|
import { SubscriptionDoc } from '../mongodb/schemas/subscription.schema';
|
||||||
|
import { getYear, getMonth, getWeek, format } from 'date-fns';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SyncService {
|
||||||
|
private readonly logger = new Logger(SyncService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private prisma: PrismaService,
|
||||||
|
@InjectModel(TransactionDoc.name)
|
||||||
|
private transactionModel: Model<TransactionDoc>,
|
||||||
|
@InjectModel(SubscriptionDoc.name)
|
||||||
|
private subscriptionModel: Model<SubscriptionDoc>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async syncTransactions(fromDate?: Date): Promise<void> {
|
||||||
|
this.logger.log('🔄 Starting transaction sync...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const whereClause = fromDate ? { updatedAt: { gte: fromDate } } : {};
|
||||||
|
|
||||||
|
const transactions = await this.prisma.payment.findMany({
|
||||||
|
where: {},
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`📊 Found ${transactions.length} transactions to sync`);
|
||||||
|
|
||||||
|
if (transactions.length === 0) {
|
||||||
|
this.logger.log('✅ No transactions to sync');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const batchSize = 100;
|
||||||
|
for (let i = 0; i < transactions.length; i += batchSize) {
|
||||||
|
const batch = transactions.slice(i, i + batchSize);
|
||||||
|
|
||||||
|
const operations = batch.map((transaction) => {
|
||||||
|
const date = new Date(transaction.createdAt);
|
||||||
|
|
||||||
|
return {
|
||||||
|
updateOne: {
|
||||||
|
filter: { transactionId: transaction.id },
|
||||||
|
update: {
|
||||||
|
$set: {
|
||||||
|
transactionId: transaction.id,
|
||||||
|
date: transaction.createdAt,
|
||||||
|
amount: transaction.amount,
|
||||||
|
//todo add tax field in prisma payment model
|
||||||
|
tax: transaction.amount,
|
||||||
|
status: transaction.status,
|
||||||
|
merchantPartnerId: transaction.merchantPartnerId,
|
||||||
|
year: getYear(date),
|
||||||
|
month: getMonth(date) + 1,
|
||||||
|
week: getWeek(date),
|
||||||
|
day: format(date, 'yyyy-MM-dd'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
upsert: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.transactionModel.bulkWrite(operations);
|
||||||
|
this.logger.log(
|
||||||
|
`✅ Synced ${Math.min(i + batchSize, transactions.length)}/${transactions.length}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log('✅ Transaction sync completed');
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('❌ Error syncing transactions', error.stack);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async syncSubscriptions(fromDate?: Date): Promise<void> {
|
||||||
|
this.logger.log('🔄 Starting subscription sync...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const whereClause = fromDate ? { updatedAt: { gte: fromDate } } : {};
|
||||||
|
|
||||||
|
const subscriptions = await this.prisma.subscription.findMany({
|
||||||
|
where: whereClause,
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`📊 Found ${subscriptions.length} subscriptions to sync`);
|
||||||
|
|
||||||
|
if (subscriptions.length === 0) {
|
||||||
|
this.logger.log('✅ No subscriptions to sync');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const batchSize = 100;
|
||||||
|
for (let i = 0; i < subscriptions.length; i += batchSize) {
|
||||||
|
const batch = subscriptions.slice(i, i + batchSize);
|
||||||
|
|
||||||
|
const operations = batch.map((subscription) => {
|
||||||
|
const date = new Date(subscription.startDate);
|
||||||
|
|
||||||
|
return {
|
||||||
|
updateOne: {
|
||||||
|
filter: { subscriptionId: subscription.id },
|
||||||
|
update: {
|
||||||
|
$set: {
|
||||||
|
subscriptionId: subscription.id,
|
||||||
|
startDate: subscription.startDate,
|
||||||
|
endDate: subscription.endDate,
|
||||||
|
amount: subscription.amount,
|
||||||
|
currency: subscription.currency,
|
||||||
|
status: subscription.status,
|
||||||
|
periodicity: subscription.periodicity,
|
||||||
|
merchantPartnerId: subscription.merchantPartnerId,
|
||||||
|
year: getYear(date),
|
||||||
|
month: getMonth(date) + 1,
|
||||||
|
week: getWeek(date),
|
||||||
|
day: format(date, 'yyyy-MM-dd'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
upsert: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.subscriptionModel.bulkWrite(operations);
|
||||||
|
this.logger.log(
|
||||||
|
`✅ Synced ${Math.min(i + batchSize, subscriptions.length)}/${subscriptions.length}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log('✅ Subscription sync completed');
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('❌ Error syncing subscriptions', error.stack);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fullSync(): Promise<void> {
|
||||||
|
this.logger.log('🚀 Starting full sync...');
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
await this.syncTransactions();
|
||||||
|
await this.syncSubscriptions();
|
||||||
|
|
||||||
|
const duration = ((Date.now() - startTime) / 1000).toFixed(2);
|
||||||
|
this.logger.log(`🎉 Full sync completed in ${duration}s`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async incrementalSync(): Promise<void> {
|
||||||
|
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000);
|
||||||
|
this.logger.log(`🔄 Starting incremental sync...`);
|
||||||
|
|
||||||
|
await this.syncTransactions(fiveMinutesAgo);
|
||||||
|
await this.syncSubscriptions(fiveMinutesAgo);
|
||||||
|
|
||||||
|
this.logger.log('✅ Incremental sync completed');
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user