This commit is contained in:
Mamadou Khoussa [028918 DSI/DAC/DIF/DS] 2025-12-01 00:40:15 +00:00
parent 1bede21bbb
commit 29e82bc746
21 changed files with 1169 additions and 527 deletions

View File

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

704
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -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",
@ -26,12 +27,12 @@
"@nestjs/mongoose": "^11.0.3", "@nestjs/mongoose": "^11.0.3",
"@nestjs/platform-express": "^11.0.1", "@nestjs/platform-express": "^11.0.1",
"@nestjs/schedule": "^6.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-transformer": "^0.5.1",
"class-validator": "^0.14.3", "class-validator": "^0.14.3",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"mongoose": "^9.0.0", "prisma": "^6.17.1",
"prisma": "^7.0.1",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1" "rxjs": "^7.8.1"
}, },

View File

@ -5,15 +5,17 @@
// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init // Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
generator client { generator client {
provider = "prisma-client" provider = "prisma-client-js"
output = "../generated/prisma"
} }
datasource db { datasource db {
provider = "postgresql" provider = "postgresql"
url = env("DATABASE_URL") // ← AJOUTEZ CETTE LIGNE
} }
model Transaction { model Transaction {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
date DateTime @default(now()) date DateTime @default(now())

View File

@ -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"]

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

View File

@ -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 || 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(); bootstrap();

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

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

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

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

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

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

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

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

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

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

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

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

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