Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import { SearchModule } from './search/search.module';
import { AnalyticsModule } from './analytics/analytics.module';

import { MessagingModule } from './messaging/messaging.module';

Check warning on line 11 in src/app.module.ts

View workflow job for this annotation

GitHub Actions / ESLint

'MessagingModule' is defined but never used. Allowed unused vars must match /^_/u

Check warning on line 11 in src/app.module.ts

View workflow job for this annotation

GitHub Actions / Quality Gates (lint)

'MessagingModule' is defined but never used. Allowed unused vars must match /^_/u
import { IndexOptimizationModule } from './database/index-optimization/index-optimization.module';
import { RateLimitingModule } from './rate-limiting/rate-limiting.module';
import { QuotaGuard } from './rate-limiting/guards/quota.guard';
Expand All @@ -22,6 +22,8 @@
import { MonitoringModule } from './monitoring/monitoring.module';
import { RequestTimeoutInterceptor } from './common/interceptors/request-timeout.interceptor';
import { DeepLinkModule } from './deep-link/deep-link.module';
import { InvoicesModule } from './payments/invoices/invoices.module';
import { ReportingModule } from './payments/reporting/reporting.module';
import { HealthModule } from './health/health.module';

// ✅ keep BOTH modules
Expand All @@ -47,6 +49,8 @@
IncidentManagementModule,
MonitoringModule,
DeepLinkModule,
InvoicesModule,
ReportingModule,
HealthModule,

// ✅ always include read replicas (or wrap if needed)
Expand All @@ -64,3 +68,3 @@
{ provide: APP_INTERCEPTOR, useClass: RequestTimeoutInterceptor },
],
})
Expand Down
3 changes: 3 additions & 0 deletions src/payments/entities/invoice.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ export class Invoice {
@Column({ type: 'text', nullable: true })
terms: string;

@Column({ type: 'varchar', nullable: true })
fileUrl: string;

@ManyToOne(() => Payment, (payment) => payment.id)
@JoinColumn({ name: 'payment_id' })
payment: Payment;
Expand Down
34 changes: 34 additions & 0 deletions src/payments/invoices/invoices.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { Controller, Get, Param, StreamableFile, Header, Res, NotFoundException } from '@nestjs/common';
import { Response } from 'express';
import { createReadStream } from 'fs';
import { InvoicesService } from './invoices.service';

@Controller('invoices')
export class InvoicesController {
constructor(private readonly invoicesService: InvoicesService) {}

@Get(':id')
async getInvoice(@Param('id') id: string) {
return this.invoicesService.getInvoice(id);
}

@Get(':id/download')
@Header('Content-Type', 'text/html')
@Header('Content-Disposition', 'attachment; filename="invoice.html"')
async downloadInvoice(@Param('id') id: string, @Res({ passthrough: true }) res: Response): Promise<StreamableFile> {
const invoice = await this.invoicesService.getInvoice(id);

if (!invoice.fileUrl) {
throw new NotFoundException('Invoice file not generated yet');
}

const filePath = this.invoicesService.getInvoiceFilePath(invoice.fileUrl);
const file = createReadStream(filePath);

res.set({
'Content-Disposition': `attachment; filename="${invoice.invoiceNumber}.html"`,
});

return new StreamableFile(file);
}
}
14 changes: 14 additions & 0 deletions src/payments/invoices/invoices.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Invoice } from '../entities/invoice.entity';
import { Payment } from '../entities/payment.entity';
import { InvoicesService } from './invoices.service';
import { InvoicesController } from './invoices.controller';

@Module({
imports: [TypeOrmModule.forFeature([Invoice, Payment])],
controllers: [InvoicesController],
providers: [InvoicesService],
exports: [InvoicesService],
})
export class InvoicesModule {}
118 changes: 118 additions & 0 deletions src/payments/invoices/invoices.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { OnEvent } from '@nestjs/event-emitter';
import * as fs from 'fs';
import * as path from 'path';
import { Invoice, InvoiceStatus } from '../entities/invoice.entity';
import { Payment } from '../entities/payment.entity';
import { APP_EVENTS } from '../../common/constants/event.constants';

@Injectable()
export class InvoicesService {
private readonly logger = new Logger(InvoicesService.name);
private readonly storagePath = path.join(process.cwd(), 'archived_invoices');

constructor(
@InjectRepository(Invoice)
private readonly invoiceRepository: Repository<Invoice>,
@InjectRepository(Payment)
private readonly paymentRepository: Repository<Payment>,
) {
if (!fs.existsSync(this.storagePath)) {
fs.mkdirSync(this.storagePath, { recursive: true });
}
}

@OnEvent(APP_EVENTS.PAYMENT_COMPLETED)
async handlePaymentCompletedEvent(payload: { paymentId: string }) {
this.logger.log(`Received PAYMENT_COMPLETED event for payment ${payload.paymentId}`);
try {
const payment = await this.paymentRepository.findOne({
where: { id: payload.paymentId },
relations: ['user'],
});

if (!payment) {
this.logger.warn(`Payment ${payload.paymentId} not found, skipping invoice generation`);
return;
}

await this.generateAndArchiveInvoice(payment);
} catch (error) {
this.logger.error(`Error generating invoice for payment ${payload.paymentId}:`, error.stack);
}
}

async generateAndArchiveInvoice(payment: Payment): Promise<Invoice> {
const invoiceNumber = `INV-${Date.now()}-${Math.floor(Math.random() * 1000)}`;
const items = [
{
description: `Payment for transaction ${payment.id}`,
amount: Number(payment.amount),
quantity: 1,
},
];

let invoice = this.invoiceRepository.create({
invoiceNumber,
amount: payment.amount,
taxAmount: 0,
totalAmount: payment.amount,
currency: payment.currency,
items,
status: InvoiceStatus.PAID,
issuedDate: new Date(),
paymentId: payment.id,
userId: payment.userId,
});

invoice = await this.invoiceRepository.save(invoice);

// Generate HTML template
const htmlContent = `
<html>
<head><title>Invoice ${invoice.invoiceNumber}</title></head>
<body>
<h1>Invoice</h1>
<p><strong>Invoice Number:</strong> ${invoice.invoiceNumber}</p>
<p><strong>Date:</strong> ${invoice.issuedDate.toISOString()}</p>
<p><strong>Status:</strong> ${invoice.status.toUpperCase()}</p>
<p><strong>Total Amount:</strong> ${invoice.totalAmount} ${invoice.currency}</p>
<hr/>
<h3>Items</h3>
<ul>
${invoice.items.map(i => `<li>${i.description} - ${i.amount} x ${i.quantity}</li>`).join('')}
</ul>
</body>
</html>
`;

// Save to archival storage
const fileName = `${invoice.invoiceNumber}.html`;
const filePath = path.join(this.storagePath, fileName);
fs.writeFileSync(filePath, htmlContent, 'utf-8');

// Update entity with fileUrl
invoice.fileUrl = filePath;
await this.invoiceRepository.save(invoice);

this.logger.log(`Invoice ${invoice.id} generated and archived at ${filePath}`);
return invoice;
}

async getInvoice(id: string): Promise<Invoice> {
const invoice = await this.invoiceRepository.findOne({ where: { id } });
if (!invoice) {
throw new NotFoundException(`Invoice with ID ${id} not found`);
}
return invoice;
}

getInvoiceFilePath(fileUrl: string): string {
if (!fs.existsSync(fileUrl)) {
throw new NotFoundException('Invoice file not found in archival storage');
}
return fileUrl;
}
}
64 changes: 64 additions & 0 deletions src/payments/reporting/reporting.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { Controller, Get, Query, BadRequestException } from '@nestjs/common';
import { ReportingService } from './reporting.service';

@Controller('reports')
export class ReportingController {
constructor(private readonly reportingService: ReportingService) {}

@Get('reconciliation/daily')
async getDailyReconciliation(@Query('date') dateStr: string) {
if (!dateStr) {
throw new BadRequestException('Query parameter "date" is required (YYYY-MM-DD)');
}
const date = new Date(dateStr);
if (isNaN(date.getTime())) {
throw new BadRequestException('Invalid date format');
}
return this.reportingService.generateDailyReconciliationReport(date);
}

@Get('refunds')
async getRefundsReport(
@Query('startDate') startDateStr: string,
@Query('endDate') endDateStr: string,
) {
const { startDate, endDate } = this.parseDateRange(startDateStr, endDateStr);
return this.reportingService.generateRefundReport(startDate, endDate);
}

@Get('revenue')
async getRevenueReport(
@Query('startDate') startDateStr: string,
@Query('endDate') endDateStr: string,
) {
const { startDate, endDate } = this.parseDateRange(startDateStr, endDateStr);
return this.reportingService.generateRevenueRecognitionReport(startDate, endDate);
}

@Get('tax')
async getTaxReport(
@Query('startDate') startDateStr: string,
@Query('endDate') endDateStr: string,
) {
const { startDate, endDate } = this.parseDateRange(startDateStr, endDateStr);
return this.reportingService.generateTaxReport(startDate, endDate);
}

private parseDateRange(startDateStr: string, endDateStr: string) {
if (!startDateStr || !endDateStr) {
throw new BadRequestException('Both "startDate" and "endDate" query parameters are required');
}
const startDate = new Date(startDateStr);
const endDate = new Date(endDateStr);

if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
throw new BadRequestException('Invalid date format for startDate or endDate');
}

if (startDate > endDate) {
throw new BadRequestException('startDate must be before or equal to endDate');
}

return { startDate, endDate };
}
}
15 changes: 15 additions & 0 deletions src/payments/reporting/reporting.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Payment } from '../entities/payment.entity';
import { Refund } from '../entities/refund.entity';
import { Invoice } from '../entities/invoice.entity';
import { ReportingService } from './reporting.service';
import { ReportingController } from './reporting.controller';

@Module({
imports: [TypeOrmModule.forFeature([Payment, Refund, Invoice])],
controllers: [ReportingController],
providers: [ReportingService],
exports: [ReportingService],
})
export class ReportingModule {}
Loading
Loading