Compare commits
No commits in common. "0adc8cf161846e7f64d47d32f6d4c6d441cb0e9c" and "8b548f4cdc610e5fd3ed9fc2099e837be5ce86ba" have entirely different histories.
0adc8cf161
...
8b548f4cdc
2
.gitignore
vendored
2
.gitignore
vendored
@ -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
|
||||
|
||||
68
Dockerfile
68
Dockerfile
@ -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
30
docs.md
@ -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
|
||||
@ -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
805
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
12
package.json
12
package.json
@ -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",
|
||||
|
||||
@ -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"),
|
||||
},
|
||||
});
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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 {}
|
||||
|
||||
46
src/main.ts
46
src/main.ts
@ -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();
|
||||
|
||||
@ -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 {}
|
||||
@ -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 });
|
||||
@ -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 });
|
||||
@ -1,9 +0,0 @@
|
||||
import { Module, Global } from '@nestjs/common';
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
};
|
||||
}
|
||||
@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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 {}
|
||||
@ -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 '';
|
||||
}
|
||||
}
|
||||
@ -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 {}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user