Compare commits

...

10 Commits

Author SHA1 Message Date
Mamadou Khoussa [028918 DSI/DAC/DIF/DS]
0adc8cf161 move dockerfile 2025-12-01 01:03:20 +00:00
Mamadou Khoussa [028918 DSI/DAC/DIF/DS]
415a98efa8 move dockerfile 2025-12-01 01:02:37 +00:00
Mamadou Khoussa [028918 DSI/DAC/DIF/DS]
b8a801edd8 move dockerfile 2025-12-01 01:00:37 +00:00
Mamadou Khoussa [028918 DSI/DAC/DIF/DS]
dfb19e3448 move dockerfile 2025-12-01 00:55:01 +00:00
Mamadou Khoussa [028918 DSI/DAC/DIF/DS]
9d848817a0 move dockerfile 2025-12-01 00:51:19 +00:00
Mamadou Khoussa [028918 DSI/DAC/DIF/DS]
161230cf12 move dockerfile 2025-12-01 00:49:58 +00:00
Mamadou Khoussa [028918 DSI/DAC/DIF/DS]
46a469bb43 move dockerfile 2025-12-01 00:44:06 +00:00
Mamadou Khoussa [028918 DSI/DAC/DIF/DS]
ef941a6fe4 fix it 2025-12-01 00:41:28 +00:00
Mamadou Khoussa [028918 DSI/DAC/DIF/DS]
29e82bc746 fix it 2025-12-01 00:40:15 +00:00
Mamadou Khoussa [028918 DSI/DAC/DIF/DS]
1bede21bbb structure 2025-11-30 22:56:06 +00:00
24 changed files with 1952 additions and 22 deletions

2
.gitignore vendored
View File

@ -54,3 +54,5 @@ pids
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
/generated/prisma

68
Dockerfile Normal file
View 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
View 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

View File

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

805
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -2,6 +2,7 @@
"name": "reporting-api",
"version": "0.0.1",
"description": "",
"type": "commonjs",
"author": "",
"private": true,
"license": "UNLICENSED",
@ -21,8 +22,17 @@
},
"dependencies": {
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.0.1",
"@nestjs/mongoose": "^11.0.3",
"@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",
"rxjs": "^7.8.1"
},
@ -34,7 +44,7 @@
"@nestjs/testing": "^11.0.1",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/node": "^22.10.7",
"@types/node": "^22.19.1",
"@types/supertest": "^6.0.2",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",

14
prisma.config.ts Normal file
View 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
View 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
View File

View File

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

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

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

11
src/sync/sync.module.ts Normal file
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);
}
}
}

165
src/sync/sync.service.ts Normal file
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');
}
}