fix it
This commit is contained in:
parent
1bede21bbb
commit
29e82bc746
@ -2,6 +2,7 @@
|
||||
import eslint from '@eslint/js';
|
||||
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
|
||||
import globals from 'globals';
|
||||
import { off } from 'process';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(
|
||||
@ -17,9 +18,9 @@ export default tseslint.config(
|
||||
...globals.node,
|
||||
...globals.jest,
|
||||
},
|
||||
sourceType: 'commonjs',
|
||||
sourceType: 'module', // Changé de 'commonjs' à 'module'
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
project: './tsconfig.json', // Ajout du chemin vers tsconfig
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
@ -27,8 +28,16 @@ export default tseslint.config(
|
||||
{
|
||||
rules: {
|
||||
'@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-unsafe-argument': 'warn'
|
||||
'@typescript-eslint/no-unsafe-argument': 'warn',
|
||||
'prettier/prettier': ['error', {
|
||||
singleQuote: true,
|
||||
trailingComma: 'all',
|
||||
printWidth: 100,
|
||||
tabWidth: 2,
|
||||
}],
|
||||
},
|
||||
},
|
||||
);
|
||||
704
package-lock.json
generated
704
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -2,6 +2,7 @@
|
||||
"name": "reporting-api",
|
||||
"version": "0.0.1",
|
||||
"description": "",
|
||||
"type": "commonjs",
|
||||
"author": "",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
@ -26,12 +27,12 @@
|
||||
"@nestjs/mongoose": "^11.0.3",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@nestjs/schedule": "^6.0.1",
|
||||
"@prisma/client": "^7.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",
|
||||
"mongoose": "^9.0.0",
|
||||
"prisma": "^7.0.1",
|
||||
"prisma": "^6.17.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
|
||||
@ -5,15 +5,17 @@
|
||||
// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client"
|
||||
output = "../generated/prisma"
|
||||
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())
|
||||
|
||||
@ -0,0 +1,76 @@
|
||||
# Stage 1: Build
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
# Définir le répertoire de travail
|
||||
WORKDIR /app
|
||||
|
||||
# Copier les fichiers de dépendances
|
||||
COPY package*.json ./
|
||||
|
||||
# Copier le schema Prisma AVANT npm ci
|
||||
COPY prisma ./prisma/
|
||||
|
||||
# Installer les dépendances avec --legacy-peer-deps pour résoudre les conflits
|
||||
RUN npm ci --legacy-peer-deps
|
||||
|
||||
# Copier le code source
|
||||
COPY . .
|
||||
|
||||
# Générer Prisma Client
|
||||
RUN npx prisma generate
|
||||
|
||||
# Builder l'application
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Production
|
||||
FROM node:20-alpine AS production
|
||||
|
||||
# Installer dumb-init pour une meilleure gestion des signaux
|
||||
RUN apk add --no-cache dumb-init
|
||||
|
||||
# Créer un utilisateur non-root
|
||||
RUN addgroup -g 1001 -S nodejs && \
|
||||
adduser -S nestjs -u 1001
|
||||
|
||||
# Définir le répertoire de travail
|
||||
WORKDIR /app
|
||||
|
||||
# Copier package.json et package-lock.json
|
||||
COPY package*.json ./
|
||||
|
||||
# Copier le schema Prisma
|
||||
COPY prisma ./prisma/
|
||||
|
||||
# Installer UNIQUEMENT les dépendances de production avec --legacy-peer-deps
|
||||
RUN npm ci --omit=dev --legacy-peer-deps && \
|
||||
npm cache clean --force
|
||||
|
||||
# 🔥 IMPORTANT: Générer Prisma Client en production
|
||||
RUN npx prisma generate
|
||||
|
||||
# Copier le code buildé depuis le builder
|
||||
COPY --from=builder --chown=nestjs:nodejs /app/dist ./dist
|
||||
|
||||
# 🔥 IMPORTANT: Copier les fichiers générés de Prisma depuis le builder
|
||||
COPY --from=builder --chown=nestjs:nodejs /app/node_modules/.prisma ./node_modules/.prisma
|
||||
COPY --from=builder --chown=nestjs:nodejs /app/node_modules/@prisma ./node_modules/@prisma
|
||||
|
||||
# Si vous utilisez un output personnalisé dans schema.prisma, copiez aussi:
|
||||
# COPY --from=builder --chown=nestjs:nodejs /app/generated ./generated
|
||||
|
||||
# Changer le propriétaire des fichiers
|
||||
RUN chown -R nestjs:nodejs /app
|
||||
|
||||
# Utiliser l'utilisateur non-root
|
||||
USER nestjs
|
||||
|
||||
# Exposer le port
|
||||
EXPOSE 3000
|
||||
|
||||
# Healthcheck
|
||||
#HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
|
||||
# CMD node -e "require('http').get('http://localhost:3000/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"
|
||||
|
||||
# Démarrer l'application avec dumb-init
|
||||
ENTRYPOINT ["dumb-init", "--"]
|
||||
CMD ["node", "dist/main"]
|
||||
@ -1,10 +1,51 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
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({
|
||||
imports: [],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
imports: [
|
||||
// Configuration globale
|
||||
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 {}
|
||||
|
||||
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 { ValidationPipe } from '@nestjs/common';
|
||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
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 || 3004;
|
||||
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();
|
||||
|
||||
@ -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 {}
|
||||
@ -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 });
|
||||
@ -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 });
|
||||
@ -0,0 +1,9 @@
|
||||
import { Module, Global } from '@nestjs/common';
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
};
|
||||
}
|
||||
@ -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 {}
|
||||
@ -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 '';
|
||||
}
|
||||
}
|
||||
@ -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 {}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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