Compare commits

..

No commits in common. "0adc8cf161846e7f64d47d32f6d4c6d441cb0e9c" and "8b548f4cdc610e5fd3ed9fc2099e837be5ce86ba" have entirely different histories.

24 changed files with 22 additions and 1952 deletions

2
.gitignore vendored
View File

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

View File

@ -1,68 +0,0 @@
# 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
View File

@ -1,30 +0,0 @@
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,7 +2,6 @@
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(
@ -18,9 +17,9 @@ export default tseslint.config(
...globals.node,
...globals.jest,
},
sourceType: 'module', // Changé de 'commonjs' à 'module'
sourceType: 'commonjs',
parserOptions: {
project: './tsconfig.json', // Ajout du chemin vers tsconfig
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
@ -28,16 +27,8 @@ 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',
'prettier/prettier': ['error', {
singleQuote: true,
trailingComma: 'all',
printWidth: 100,
tabWidth: 2,
}],
'@typescript-eslint/no-unsafe-argument': 'warn'
},
},
);

805
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -1,14 +0,0 @@
// 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"),
},
});

View File

@ -1,140 +0,0 @@
// 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
}

View File

View File

@ -1,51 +1,10 @@
import { Module } from '@nestjs/common';
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';
import { AppController } from './app.controller';
import { AppService } from './app.service';
@Module({
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,
],
imports: [],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}

View File

@ -1,52 +1,8 @@
/* 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);
// 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`);
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();

View File

@ -1,21 +0,0 @@
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

@ -1,51 +0,0 @@
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

@ -1,45 +0,0 @@
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

@ -1,9 +0,0 @@
import { Module, Global } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}

View File

@ -1,18 +0,0 @@
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

@ -1,34 +0,0 @@
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

@ -1,29 +0,0 @@
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

@ -1,150 +0,0 @@
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

@ -1,12 +0,0 @@
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

@ -1,225 +0,0 @@
/* 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

@ -1,11 +0,0 @@
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

@ -1,21 +0,0 @@
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

@ -1,165 +0,0 @@
/* 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');
}
}