--- id: "ET-SAAS-017" title: "Especificacion Tecnica Reports Generation" type: "TechnicalSpec" status: "Proposed" priority: "P2" module: "reports" version: "1.0.0" created_date: "2026-01-24" updated_date: "2026-01-24" story_points: 5 --- # ET-SAAS-017: Especificacion Tecnica - Sistema de Reportes ## Metadata - **Codigo:** ET-SAAS-017 - **Modulo:** Reports - **Version:** 1.0.0 - **Estado:** Propuesto - **Fecha:** 2026-01-24 - **Basado en:** PDFKit, ExcelJS best practices --- ## 1. Resumen Ejecutivo ### 1.1 Estado Actual No existe sistema de reportes implementado. | Capacidad | Estado | Notas | |-----------|--------|-------| | Export PDF | NO | Sin implementacion | | Export Excel | NO | Sin implementacion | | Export CSV | NO | Sin implementacion | | Templates | NO | Sin templates definidos | | Email delivery | Parcial | Email module existe | ### 1.2 Propuesta v1.0 Sistema de reportes con: - **3 Formatos**: PDF (formateado), Excel (tabular), CSV (crudo) - **6 Tipos de reporte**: Users, Billing, Invoices, Audit, Usage, Subscriptions - **Templates predefinidos**: Diseño profesional con branding - **Filtros avanzados**: Fecha, status, plan, usuario - **Delivery**: Descarga directa + envio por email --- ## 2. Tipos de Reportes ### 2.1 Catalogo de Reportes | ID | Nombre | Formatos | Descripcion | |----|--------|----------|-------------| | R01 | Users List | PDF, Excel, CSV | Lista completa de usuarios | | R02 | Active Users | PDF, Excel | Usuarios activos en periodo | | R03 | Billing Summary | PDF | Resumen de facturacion | | R04 | Invoices List | PDF, Excel | Facturas emitidas | | R05 | Audit Log | CSV | Log de auditoría | | R06 | Subscriptions | Excel | Estado de suscripciones | | R07 | Usage Report | Excel | Uso de recursos | | R08 | Revenue Report | PDF, Excel | Ingresos detallados | ### 2.2 Campos por Reporte **R01 - Users List** ``` | Name | Email | Role | Plan | Status | Created | Last Login | ``` **R03 - Billing Summary** ``` Period: January 2026 MRR: $45,000 Revenue: $52,000 New Subscriptions: 15 Canceled: 3 Top Plan: Pro (65%) ``` **R05 - Audit Log** ``` | Timestamp | User | Action | Resource | IP | Details | ``` --- ## 3. Arquitectura ### 3.1 Diagrama de Componentes ``` +------------------+ +-------------------+ +------------------+ | Frontend UI | | ReportsController | | ReportsService | | Report Config |---->| /reports/:type |---->| (orchestration) | +------------------+ +-------------------+ +------------------+ | +--------------------------------+ | | | v v v +-------------+ +-------------+ +-------------+ | PdfGenerator| |ExcelGenerator| |CsvGenerator | | (PDFKit) | | (ExcelJS) | | (manual) | +-------------+ +-------------+ +-------------+ | | | v v v +---------------------------------------------------+ | Response | | - Stream (download) or Email (attachment) | +---------------------------------------------------+ ``` ### 3.2 Flujo de Generacion ``` 1. Usuario configura reporte (tipo, filtros, formato) | 2. Frontend POST /reports/generate | 3. ReportsService.generate(config) | 4. DataService.fetchData(type, filters) | 5. Select generator by format: |-- PDF: PdfGeneratorService |-- Excel: ExcelGeneratorService |-- CSV: CsvGeneratorService | 6. Generator produces file buffer | 7. Response: |-- Download: Stream to client |-- Email: Queue email with attachment | 8. AuditLog: Register report generation ``` --- ## 4. Implementacion Backend ### 4.1 Estructura de Archivos ``` backend/src/modules/reports/ ├── reports.module.ts ├── controllers/ │ └── reports.controller.ts ├── services/ │ ├── reports.service.ts │ ├── pdf-generator.service.ts │ ├── excel-generator.service.ts │ └── csv-generator.service.ts ├── templates/ │ ├── pdf/ │ │ ├── users-report.template.ts │ │ ├── billing-summary.template.ts │ │ └── invoices-report.template.ts │ └── excel/ │ ├── users-report.template.ts │ └── usage-report.template.ts └── dto/ ├── generate-report.dto.ts └── report-config.dto.ts ``` ### 4.2 DTOs ```typescript export class GenerateReportDto { @IsEnum(ReportType) type: ReportType; @IsEnum(ReportFormat) format: ReportFormat; @IsOptional() @IsDateString() startDate?: string; @IsOptional() @IsDateString() endDate?: string; @IsOptional() @IsObject() filters?: Record; } export class SendReportEmailDto extends GenerateReportDto { @IsArray() @IsEmail({}, { each: true }) recipients: string[]; @IsOptional() @IsString() subject?: string; @IsOptional() @IsString() message?: string; } export enum ReportType { USERS = 'users', ACTIVE_USERS = 'active_users', BILLING_SUMMARY = 'billing_summary', INVOICES = 'invoices', AUDIT_LOG = 'audit_log', SUBSCRIPTIONS = 'subscriptions', USAGE = 'usage', REVENUE = 'revenue', } export enum ReportFormat { PDF = 'pdf', EXCEL = 'excel', CSV = 'csv', } ``` ### 4.3 Service: ReportsService ```typescript @Injectable() export class ReportsService { constructor( private readonly pdfGenerator: PdfGeneratorService, private readonly excelGenerator: ExcelGeneratorService, private readonly csvGenerator: CsvGeneratorService, private readonly dataService: ReportDataService, private readonly emailService: EmailService, private readonly auditService: AuditService, ) {} async generate( tenantId: string, userId: string, config: GenerateReportDto, ): Promise { // 1. Validate limits await this.validateLimits(tenantId, config); // 2. Fetch data const data = await this.dataService.fetchData(tenantId, config); // 3. Generate report let buffer: Buffer; let mimeType: string; let extension: string; switch (config.format) { case ReportFormat.PDF: buffer = await this.pdfGenerator.generate(config.type, data); mimeType = 'application/pdf'; extension = 'pdf'; break; case ReportFormat.EXCEL: buffer = await this.excelGenerator.generate(config.type, data); mimeType = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; extension = 'xlsx'; break; case ReportFormat.CSV: buffer = await this.csvGenerator.generate(config.type, data); mimeType = 'text/csv'; extension = 'csv'; break; } // 4. Audit log await this.auditService.log({ tenantId, userId, action: 'report_generated', resource: 'report', details: { type: config.type, format: config.format, rows: data.length }, }); const filename = this.generateFilename(config.type, extension); return { buffer, mimeType, filename }; } async sendByEmail( tenantId: string, userId: string, config: SendReportEmailDto, ): Promise { const report = await this.generate(tenantId, userId, config); await this.emailService.sendWithAttachment({ to: config.recipients, subject: config.subject || `${config.type} Report`, body: config.message || 'Please find the attached report.', attachment: { filename: report.filename, content: report.buffer, contentType: report.mimeType, }, }); await this.auditService.log({ tenantId, userId, action: 'report_emailed', resource: 'report', details: { type: config.type, recipients: config.recipients }, }); } private async validateLimits( tenantId: string, config: GenerateReportDto, ): Promise { const count = await this.dataService.countRecords(tenantId, config); if (count > 10000) { throw new BadRequestException( `Report exceeds limit of 10,000 records (found ${count}). Please narrow your filters.`, ); } } private generateFilename(type: string, extension: string): string { const date = new Date().toISOString().split('T')[0]; return `${type}-report-${date}.${extension}`; } } ``` ### 4.4 Service: PdfGeneratorService ```typescript @Injectable() export class PdfGeneratorService { async generate(type: ReportType, data: any[]): Promise { return new Promise((resolve, reject) => { const doc = new PDFDocument({ margin: 50 }); const chunks: Buffer[] = []; doc.on('data', (chunk) => chunks.push(chunk)); doc.on('end', () => resolve(Buffer.concat(chunks))); doc.on('error', reject); // Header this.addHeader(doc, type); // Content based on type switch (type) { case ReportType.USERS: this.generateUsersReport(doc, data); break; case ReportType.BILLING_SUMMARY: this.generateBillingSummary(doc, data); break; case ReportType.INVOICES: this.generateInvoicesReport(doc, data); break; default: this.generateGenericTable(doc, data); } // Footer this.addFooter(doc); doc.end(); }); } private addHeader(doc: PDFKit.PDFDocument, type: ReportType): void { doc.fontSize(20).text(this.getTitle(type), { align: 'center' }); doc.moveDown(0.5); doc.fontSize(10).fillColor('#666').text(`Generated: ${new Date().toLocaleString()}`, { align: 'center' }); doc.moveDown(2); doc.fillColor('#000'); } private generateUsersReport(doc: PDFKit.PDFDocument, users: any[]): void { // Summary doc.fontSize(14).text('Summary'); doc.fontSize(12).text(`Total Users: ${users.length}`); doc.text(`Active: ${users.filter((u) => u.status === 'active').length}`); doc.moveDown(2); // Table const headers = ['Name', 'Email', 'Role', 'Plan', 'Status', 'Created']; const rows = users.map((u) => [ u.name, u.email, u.role, u.plan, u.status, new Date(u.createdAt).toLocaleDateString(), ]); this.drawTable(doc, headers, rows); } private generateBillingSummary(doc: PDFKit.PDFDocument, data: any): void { doc.fontSize(14).text('Billing Summary'); doc.moveDown(); const metrics = [ ['MRR', `$${data.mrr.toLocaleString()}`], ['ARR', `$${data.arr.toLocaleString()}`], ['Revenue (Period)', `$${data.revenue.toLocaleString()}`], ['Active Subscriptions', data.subscriptionsActive.toString()], ['New Subscriptions', data.subscriptionsNew.toString()], ['Churned', data.subscriptionsChurned.toString()], ]; metrics.forEach(([label, value]) => { doc.fontSize(12).text(`${label}: `, { continued: true }); doc.fontSize(12).fillColor('#3B82F6').text(value); doc.fillColor('#000'); }); } private drawTable( doc: PDFKit.PDFDocument, headers: string[], rows: string[][], ): void { const startX = 50; const startY = doc.y; const colWidth = (doc.page.width - 100) / headers.length; const rowHeight = 20; // Headers doc.font('Helvetica-Bold').fontSize(10); headers.forEach((header, i) => { doc.text(header, startX + i * colWidth, startY, { width: colWidth }); }); // Rows doc.font('Helvetica').fontSize(9); rows.forEach((row, rowIndex) => { const y = startY + (rowIndex + 1) * rowHeight; if (y > doc.page.height - 100) { doc.addPage(); } row.forEach((cell, colIndex) => { doc.text(cell || '-', startX + colIndex * colWidth, y, { width: colWidth, ellipsis: true, }); }); }); } private addFooter(doc: PDFKit.PDFDocument): void { const pages = doc.bufferedPageRange(); for (let i = 0; i < pages.count; i++) { doc.switchToPage(i); doc.fontSize(8).fillColor('#999').text( `Page ${i + 1} of ${pages.count}`, 50, doc.page.height - 50, { align: 'center' }, ); } } private getTitle(type: ReportType): string { const titles: Record = { [ReportType.USERS]: 'Users Report', [ReportType.ACTIVE_USERS]: 'Active Users Report', [ReportType.BILLING_SUMMARY]: 'Billing Summary', [ReportType.INVOICES]: 'Invoices Report', [ReportType.AUDIT_LOG]: 'Audit Log', [ReportType.SUBSCRIPTIONS]: 'Subscriptions Report', [ReportType.USAGE]: 'Usage Report', [ReportType.REVENUE]: 'Revenue Report', }; return titles[type] || 'Report'; } } ``` ### 4.5 Service: ExcelGeneratorService ```typescript @Injectable() export class ExcelGeneratorService { async generate(type: ReportType, data: any[]): Promise { const workbook = new ExcelJS.Workbook(); workbook.creator = 'Template SaaS'; workbook.created = new Date(); const sheet = workbook.addWorksheet(this.getSheetName(type)); switch (type) { case ReportType.USERS: this.generateUsersSheet(sheet, data); break; case ReportType.INVOICES: this.generateInvoicesSheet(sheet, data); break; case ReportType.USAGE: this.generateUsageSheet(sheet, data); break; default: this.generateGenericSheet(sheet, data); } return workbook.xlsx.writeBuffer() as Promise; } private generateUsersSheet(sheet: ExcelJS.Worksheet, users: any[]): void { sheet.columns = [ { header: 'Name', key: 'name', width: 25 }, { header: 'Email', key: 'email', width: 30 }, { header: 'Role', key: 'role', width: 15 }, { header: 'Plan', key: 'plan', width: 15 }, { header: 'Status', key: 'status', width: 12 }, { header: 'Created', key: 'createdAt', width: 15 }, { header: 'Last Login', key: 'lastLoginAt', width: 15 }, ]; // Style header row sheet.getRow(1).font = { bold: true }; sheet.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF3B82F6' }, }; sheet.getRow(1).font = { bold: true, color: { argb: 'FFFFFFFF' } }; // Add data users.forEach((user) => { sheet.addRow({ name: user.name, email: user.email, role: user.role, plan: user.plan, status: user.status, createdAt: new Date(user.createdAt), lastLoginAt: user.lastLoginAt ? new Date(user.lastLoginAt) : null, }); }); // Date formatting sheet.getColumn('createdAt').numFmt = 'yyyy-mm-dd'; sheet.getColumn('lastLoginAt').numFmt = 'yyyy-mm-dd'; } private generateUsageSheet(sheet: ExcelJS.Worksheet, data: any): void { sheet.columns = [ { header: 'Resource', key: 'resource', width: 20 }, { header: 'Usage', key: 'usage', width: 15 }, { header: 'Unit', key: 'unit', width: 10 }, { header: 'Limit', key: 'limit', width: 15 }, { header: '% Used', key: 'percent', width: 10 }, ]; sheet.getRow(1).font = { bold: true }; const resources = [ { resource: 'API Calls', usage: data.apiCalls, unit: 'calls', limit: data.apiLimit }, { resource: 'Storage', usage: data.storageGb, unit: 'GB', limit: data.storageLimit }, { resource: 'AI Tokens', usage: data.aiTokens, unit: 'tokens', limit: data.aiLimit }, ]; resources.forEach((r) => { sheet.addRow({ ...r, percent: r.limit > 0 ? Math.round((r.usage / r.limit) * 100) : 0, }); }); } private getSheetName(type: ReportType): string { return type.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase()); } } ``` ### 4.6 Controller: ReportsController ```typescript @Controller('reports') @ApiTags('Reports') @UseGuards(JwtAuthGuard, RolesGuard) @Roles('admin', 'owner') export class ReportsController { constructor(private readonly reportsService: ReportsService) {} @Post('generate') @ApiOperation({ summary: 'Generate and download report' }) async generateReport( @GetTenant() tenantId: string, @GetUser() user: User, @Body() dto: GenerateReportDto, @Res() res: Response, ): Promise { const report = await this.reportsService.generate(tenantId, user.id, dto); res.setHeader('Content-Type', report.mimeType); res.setHeader('Content-Disposition', `attachment; filename="${report.filename}"`); res.send(report.buffer); } @Post('send-email') @ApiOperation({ summary: 'Generate and send report by email' }) async sendReportByEmail( @GetTenant() tenantId: string, @GetUser() user: User, @Body() dto: SendReportEmailDto, ): Promise<{ message: string }> { await this.reportsService.sendByEmail(tenantId, user.id, dto); return { message: 'Report sent successfully' }; } @Get('types') @ApiOperation({ summary: 'Get available report types' }) getReportTypes(): ReportTypeInfo[] { return [ { type: 'users', name: 'Users List', formats: ['pdf', 'excel', 'csv'] }, { type: 'active_users', name: 'Active Users', formats: ['pdf', 'excel'] }, { type: 'billing_summary', name: 'Billing Summary', formats: ['pdf'] }, { type: 'invoices', name: 'Invoices', formats: ['pdf', 'excel'] }, { type: 'audit_log', name: 'Audit Log', formats: ['csv'] }, { type: 'subscriptions', name: 'Subscriptions', formats: ['excel'] }, { type: 'usage', name: 'Usage Report', formats: ['excel'] }, { type: 'revenue', name: 'Revenue Report', formats: ['pdf', 'excel'] }, ]; } } ``` --- ## 5. Implementacion Frontend ### 5.1 ReportGenerator Component ```tsx export const ReportGenerator: React.FC = () => { const [reportType, setReportType] = useState('users'); const [format, setFormat] = useState('pdf'); const [dateRange, setDateRange] = useState({ start: subDays(new Date(), 30), end: new Date(), }); const [isLoading, setIsLoading] = useState(false); const [showEmailModal, setShowEmailModal] = useState(false); const { data: reportTypes } = useReportTypes(); const handleDownload = async () => { setIsLoading(true); try { const blob = await reportsApi.generate({ type: reportType, format, startDate: dateRange.start.toISOString(), endDate: dateRange.end.toISOString(), }); downloadBlob(blob, `${reportType}-report.${format === 'excel' ? 'xlsx' : format}`); toast.success('Report downloaded'); } catch (error) { toast.error('Failed to generate report'); } finally { setIsLoading(false); } }; return (

Generate Report

{/* Report Type */}
{/* Format */}
{/* Date Range */}
{/* Actions */}
setShowEmailModal(false)} reportConfig={{ type: reportType, format, dateRange }} />
); }; ``` --- ## 6. Criterios de Aceptacion - [ ] Generar PDF de usuarios con formato profesional - [ ] Generar Excel de usuarios con datos tabulares - [ ] Generar CSV de audit log - [ ] Filtros por fecha funcionan correctamente - [ ] Limite de 10,000 registros se respeta - [ ] Envio por email funciona con adjunto - [ ] Solo admins pueden generar reportes - [ ] Cada generacion se registra en audit log - [ ] Performance < 5s para reportes de 5000 registros - [ ] Tests unitarios con cobertura >70% --- ## 7. Dependencias ### NPM Packages ```json { "pdfkit": "^0.15.0", "exceljs": "^4.4.0" } ``` --- *ET-SAAS-017 v1.0.0 - Template SaaS*