3 * @module routes/reports
4 * @description Business intelligence and reporting endpoints for RMM-PSA platform.
5 * Provides financial, operational, and customer analytics reports with multi-tenant support.
6 * All endpoints require authentication and automatically filter by tenant context.
8 * @requires middleware/auth
9 * @requires middleware/tenant
10 * @requires services/db
11 * @author RMM-PSA Development Team
12 * @copyright 2026 RMM-PSA Platform
13 * @license Proprietary
17 * @apiDefine Reports Reports
18 * Business intelligence and analytics endpoints for financial, operational, and customer reports
21const express = require('express');
22const router = express.Router();
23const authenticateToken = require('../middleware/auth');
24const { setTenantContext, getTenantFilter } = require('../middleware/tenant');
25const pool = require('../services/db');
27// Apply authentication and tenant context to all routes
28router.use(authenticateToken, setTenantContext);
31 * @api {get} /api/reports/revenue Get revenue report
32 * @apiName GetRevenueReport
34 * @apiDescription Get monthly revenue statistics for the last 12 months. Shows total invoices,
35 * total revenue, paid revenue, and unpaid revenue by month. Automatically filtered by tenant.
36 * @apiSuccess {Array} revenue Array of monthly revenue objects
37 * @apiSuccess {string} revenue.month Month in YYYY-MM format
38 * @apiSuccess {number} revenue.invoice_count Total invoices issued in month
39 * @apiSuccess {number} revenue.total_revenue Total revenue for month (all invoices)
40 * @apiSuccess {number} revenue.paid_revenue Revenue from paid invoices
41 * @apiSuccess {number} revenue.unpaid_revenue Revenue from unpaid invoices
42 * @apiError {string} error (500) Database query failed
43 * @apiExample {curl} Example:
44 * curl -X GET http://localhost:3000/api/reports/revenue \
45 * -H "Authorization: Bearer YOUR_TOKEN"
46 * @apiSuccessExample {json} Success-Response:
51 * "invoice_count": 25,
52 * "total_revenue": 45000.00,
53 * "paid_revenue": 38000.00,
54 * "unpaid_revenue": 7000.00
58 * "invoice_count": 22,
59 * "total_revenue": 41000.00,
60 * "paid_revenue": 39500.00,
61 * "unpaid_revenue": 1500.00
65router.get('/revenue', async (req, res) => {
67 const tenantFilter = getTenantFilter(req, 'i');
68 const whereClause = tenantFilter.clause ? `${tenantFilter.clause} AND` : '';
69 const params = [...tenantFilter.params];
71 const result = await pool.query(`
73 TO_CHAR(issued_date, 'YYYY-MM') as month,
74 COUNT(*) FILTER (WHERE refund_status != 'full') as invoice_count,
75 SUM(CASE WHEN refund_status != 'full' THEN (amount - COALESCE(amount_refunded, 0)) ELSE 0 END) as total_revenue,
76 SUM(CASE WHEN status = 'paid' AND refund_status != 'full' THEN (amount - COALESCE(amount_refunded, 0)) ELSE 0 END) as paid_revenue,
77 SUM(CASE WHEN status != 'paid' AND refund_status != 'full' THEN (amount - COALESCE(amount_refunded, 0)) ELSE 0 END) as unpaid_revenue
79 WHERE ${whereClause} issued_date >= NOW() - INTERVAL '12 months'
81 AND (refund_status IS NULL OR refund_status != 'full')
85 res.json(result.rows);
87 console.error('Revenue report error:', err);
88 res.status(500).json({ error: err.message });
93 * @api {get} /api/reports/tickets Get ticket statistics
94 * @apiName GetTicketReport
96 * @apiDescription Get comprehensive ticket statistics including counts by status, priority,
97 * and monthly trends (last 12 months). Useful for operational dashboards and performance metrics.
98 * @apiSuccess {Array} byStatus Ticket counts grouped by status
99 * @apiSuccess {string} byStatus.status Ticket status (open, in_progress, closed, etc)
100 * @apiSuccess {number} byStatus.count Number of tickets in this status
101 * @apiSuccess {Array} byPriority Ticket counts grouped by priority
102 * @apiSuccess {string} byPriority.priority Priority level (low, medium, high, critical)
103 * @apiSuccess {number} byPriority.count Number of tickets at this priority
104 * @apiSuccess {Array} monthlyTrend Monthly ticket creation and closure trends
105 * @apiSuccess {string} monthlyTrend.month Month in YYYY-MM format
106 * @apiSuccess {number} monthlyTrend.total Total tickets created in month
107 * @apiSuccess {number} monthlyTrend.closed Tickets closed in month
108 * @apiError {string} error (500) Database query failed
109 * @apiExample {curl} Example:
110 * curl -X GET http://localhost:3000/api/reports/tickets \
111 * -H "Authorization: Bearer YOUR_TOKEN"
112 * @apiSuccessExample {json} Success-Response:
116 * {"status": "open", "count": 15},
117 * {"status": "in_progress", "count": 8},
118 * {"status": "closed", "count": 142}
121 * {"priority": "low", "count": 45},
122 * {"priority": "medium", "count": 78},
123 * {"priority": "high", "count": 35},
124 * {"priority": "critical", "count": 7}
127 * {"month": "2026-03", "total": 18, "closed": 12},
128 * {"month": "2026-02", "total": 22, "closed": 19}
132router.get('/tickets', async (req, res) => {
134 const tenantFilter = getTenantFilter(req, 't');
135 const whereClause = tenantFilter.clause ? `WHERE ${tenantFilter.clause}` : '';
136 const params = [...tenantFilter.params];
138 // Get counts by status
139 const statusResult = await pool.query(`
140 SELECT status, COUNT(*) as count
146 // Get counts by priority
147 const priorityResult = await pool.query(`
148 SELECT priority, COUNT(*) as count
154 // Get monthly ticket trends
155 const trendResult = await pool.query(`
157 TO_CHAR(created_at, 'YYYY-MM') as month,
159 COUNT(*) FILTER (WHERE status = 'closed') as closed
161 ${whereClause ? whereClause + ' AND' : 'WHERE'} created_at >= NOW() - INTERVAL '12 months'
167 byStatus: statusResult.rows,
168 byPriority: priorityResult.rows,
169 monthlyTrend: trendResult.rows
172 console.error('Ticket stats error:', err);
173 res.status(500).json({ error: err.message });
178 * @api {get} /api/reports/customers Get customer report
179 * @apiName GetCustomerReport
181 * @apiDescription Get top customers ranked by revenue and ticket count. Shows top 10 customers
182 * in each category with aggregate metrics. Useful for identifying VIP customers and high-support accounts.
183 * @apiSuccess {Array} topByRevenue Top 10 customers by total revenue
184 * @apiSuccess {UUID} topByRevenue.customer_id Customer ID
185 * @apiSuccess {string} topByRevenue.customer_name Customer name
186 * @apiSuccess {number} topByRevenue.invoice_count Number of invoices (excluding void)
187 * @apiSuccess {number} topByRevenue.total_revenue Total revenue from customer
188 * @apiSuccess {Array} topByTickets Top 10 customers by ticket count
189 * @apiSuccess {UUID} topByTickets.customer_id Customer ID
190 * @apiSuccess {string} topByTickets.customer_name Customer name
191 * @apiSuccess {number} topByTickets.ticket_count Total tickets created
192 * @apiSuccess {number} topByTickets.open_tickets Currently open tickets
193 * @apiError {string} error (500) Database query failed
194 * @apiExample {curl} Example:
195 * curl -X GET http://localhost:3000/api/reports/customers \
196 * -H "Authorization: Bearer YOUR_TOKEN"
197 * @apiSuccessExample {json} Success-Response:
202 * "customer_id": "123e4567-e89b-12d3-a456-426614174000",
203 * "customer_name": "Acme Corp",
204 * "invoice_count": 24,
205 * "total_revenue": 125000.00
210 * "customer_id": "456e7890-ab12-34cd-5678-901234567890",
211 * "customer_name": "TechStart Inc",
212 * "ticket_count": 47,
218router.get('/customers', async (req, res) => {
220 const tenantFilter = getTenantFilter(req, 'c');
221 const whereClause = tenantFilter.clause ? `WHERE ${tenantFilter.clause}` : '';
222 const params = [...tenantFilter.params];
224 // Top customers by revenue
225 const revenueResult = await pool.query(`
228 c.name as customer_name,
229 COUNT(DISTINCT i.invoice_id) FILTER (WHERE i.status != 'void' AND (i.refund_status IS NULL OR i.refund_status != 'full')) as invoice_count,
230 COALESCE(SUM(CASE WHEN i.status != 'void' AND (i.refund_status IS NULL OR i.refund_status != 'full')
231 THEN (i.amount - COALESCE(i.amount_refunded, 0)) ELSE 0 END), 0) as total_revenue
233 LEFT JOIN invoices i ON c.customer_id = i.customer_id AND i.tenant_id = c.tenant_id
235 GROUP BY c.customer_id, c.name
236 ORDER BY total_revenue DESC NULLS LAST
240 // Top customers by ticket count
241 const ticketResult = await pool.query(`
244 c.name as customer_name,
245 COUNT(t.ticket_id) as ticket_count,
246 COUNT(t.ticket_id) FILTER (WHERE t.status = 'open') as open_tickets
248 LEFT JOIN tickets t ON c.customer_id = t.customer_id AND t.tenant_id = c.tenant_id
250 GROUP BY c.customer_id, c.name
251 ORDER BY ticket_count DESC
256 topByRevenue: revenueResult.rows,
257 topByTickets: ticketResult.rows
260 console.error('Customer report error:', err);
261 res.status(500).json({ error: err.message });
266 * @api {get} /api/reports/agents Get agent status report
267 * @apiName GetAgentReport
269 * @apiDescription Get comprehensive agent monitoring report showing status, last seen time,
270 * assigned tickets, and status summary. Useful for workforce management and workload balancing.
271 * @apiSuccess {Array} agents Array of agent detail objects
272 * @apiSuccess {UUID} agents.agent_id Agent ID
273 * @apiSuccess {string} agents.agent_name Agent name
274 * @apiSuccess {string} agents.status Agent status (online, offline, away, busy)
275 * @apiSuccess {DateTime} agents.last_seen Last heartbeat timestamp
276 * @apiSuccess {string} agents.tenant_name Tenant name
277 * @apiSuccess {number} agents.assigned_tickets Total tickets assigned to agent
278 * @apiSuccess {number} agents.open_tickets Currently open tickets assigned to agent
279 * @apiSuccess {Array} statusSummary Summary counts by status
280 * @apiSuccess {string} statusSummary.status Status type
281 * @apiSuccess {number} statusSummary.count Number of agents in this status
282 * @apiError {string} error (500) Database query failed
283 * @apiExample {curl} Example:
284 * curl -X GET http://localhost:3000/api/reports/agents \
285 * -H "Authorization: Bearer YOUR_TOKEN"
286 * @apiSuccessExample {json} Success-Response:
291 * "agent_id": "123e4567-e89b-12d3-a456-426614174000",
292 * "agent_name": "Desktop-WIN10-001",
293 * "status": "online",
294 * "last_seen": "2026-03-12T10:45:00.000Z",
295 * "tenant_name": "Acme Corp",
296 * "assigned_tickets": 5,
301 * {"status": "online", "count": 145},
302 * {"status": "offline", "count": 23},
303 * {"status": "away", "count": 8}
307router.get('/agents', async (req, res) => {
309 const tenantFilter = getTenantFilter(req, 'a');
310 const whereClause = tenantFilter.clause ? `WHERE ${tenantFilter.clause}` : '';
311 const params = [...tenantFilter.params];
313 const result = await pool.query(`
316 a.name as agent_name,
319 t.name as tenant_name,
320 COUNT(DISTINCT tk.ticket_id) as assigned_tickets,
321 COUNT(DISTINCT tk.ticket_id) FILTER (WHERE tk.status = 'open') as open_tickets
323 LEFT JOIN tenants t ON a.tenant_id = t.tenant_id
324 LEFT JOIN tickets tk ON a.agent_id = tk.assigned_to AND a.tenant_id = tk.tenant_id
326 GROUP BY a.agent_id, a.name, a.status, a.last_seen, t.name
327 ORDER BY a.status DESC, a.name
330 // Get status summary
331 const statusSummary = await pool.query(`
332 SELECT status, COUNT(*) as count
340 statusSummary: statusSummary.rows
343 console.error('Agent report error:', err);
344 res.status(500).json({ error: err.message });
349 * @api {get} /api/reports/invoice-aging Get invoice aging report
350 * @apiName GetInvoiceAgingReport
352 * @apiDescription Get detailed invoice aging report showing unpaid invoices with days overdue.
353 * Includes aging bucket summary (current, 1-30, 31-60, 61-90, 90+ days). Critical for
354 * accounts receivable management and cash flow forecasting.
355 * @apiSuccess {Array} invoices Array of unpaid/overdue invoice objects
356 * @apiSuccess {number} invoices.invoice_id Invoice ID
357 * @apiSuccess {string} invoices.invoice_number Formatted invoice number (INV-000001)
358 * @apiSuccess {string} invoices.customer_name Customer name
359 * @apiSuccess {Date} invoices.issued_date Invoice issue date
360 * @apiSuccess {Date} invoices.due_date Invoice due date
361 * @apiSuccess {number} invoices.total_amount Invoice amount
362 * @apiSuccess {string} invoices.payment_status Payment status
363 * @apiSuccess {number} invoices.days_overdue Days overdue (0 if not yet due)
364 * @apiSuccess {object} summary Aging bucket summary
365 * @apiSuccess {object} summary.current Current invoices (not yet due)
366 * @apiSuccess {number} summary.current.count Number of invoices
367 * @apiSuccess {number} summary.current.total Total amount
368 * @apiSuccess {Object} summary.overdue1_30 Invoices 1-30 days overdue
369 * @apiSuccess {Object} summary.overdue31_60 Invoices 31-60 days overdue
370 * @apiSuccess {Object} summary.overdue61_90 Invoices 61-90 days overdue
371 * @apiSuccess {Object} summary.overdue90plus Invoices over 90 days overdue
372 * @apiError {string} error (500) Database query failed
373 * @apiExample {curl} Example:
374 * curl -X GET http://localhost:3000/api/reports/invoice-aging \
375 * -H "Authorization: Bearer YOUR_TOKEN"
376 * @apiSuccessExample {json} Success-Response:
381 * "invoice_id": 1234,
382 * "invoice_number": "INV-001234",
383 * "customer_name": "Acme Corp",
384 * "issued_date": "2026-01-15",
385 * "due_date": "2026-02-15",
386 * "total_amount": 5000.00,
387 * "payment_status": "unpaid",
392 * "current": {"count": 8, "total": 12000.00},
393 * "overdue1_30": {"count": 3, "total": 7500.00},
394 * "overdue31_60": {"count": 1, "total": 3000.00},
395 * "overdue61_90": {"count": 0, "total": 0.00},
396 * "overdue90plus": {"count": 1, "total": 15000.00}
400router.get('/invoice-aging', async (req, res) => {
402 const tenantFilter = getTenantFilter(req, 'i');
403 const whereClause = tenantFilter.clause ? `${tenantFilter.clause} AND` : '';
404 const params = [...tenantFilter.params];
406 const result = await pool.query(`
409 'INV-' || LPAD(i.invoice_id::text, 6, '0') as invoice_number,
410 c.name as customer_name,
413 (i.amount - COALESCE(i.amount_refunded, 0)) as total_amount,
414 i.status as payment_status,
417 WHEN i.due_date < CURRENT_DATE AND i.status != 'paid'
418 THEN CURRENT_DATE - i.due_date
422 LEFT JOIN customers c ON i.customer_id = c.customer_id AND i.tenant_id = c.tenant_id
423 WHERE ${whereClause} i.status != 'paid'
424 AND i.status != 'void'
425 AND (i.refund_status IS NULL OR i.refund_status != 'full')
426 ORDER BY days_overdue DESC, i.due_date
429 // Calculate aging buckets
430 const current = result.rows.filter(r => r.days_overdue === 0);
431 const overdue1_30 = result.rows.filter(r => r.days_overdue > 0 && r.days_overdue <= 30);
432 const overdue31_60 = result.rows.filter(r => r.days_overdue > 30 && r.days_overdue <= 60);
433 const overdue61_90 = result.rows.filter(r => r.days_overdue > 60 && r.days_overdue <= 90);
434 const overdue90plus = result.rows.filter(r => r.days_overdue > 90);
437 invoices: result.rows,
439 current: { count: current.length, total: current.reduce((sum, i) => sum + parseFloat(i.total_amount || 0), 0) },
440 overdue1_30: { count: overdue1_30.length, total: overdue1_30.reduce((sum, i) => sum + parseFloat(i.total_amount || 0), 0) },
441 overdue31_60: { count: overdue31_60.length, total: overdue31_60.reduce((sum, i) => sum + parseFloat(i.total_amount || 0), 0) },
442 overdue61_90: { count: overdue61_90.length, total: overdue61_90.reduce((sum, i) => sum + parseFloat(i.total_amount || 0), 0) },
443 overdue90plus: { count: overdue90plus.length, total: overdue90plus.reduce((sum, i) => sum + parseFloat(i.total_amount || 0), 0) }
447 console.error('Invoice aging error:', err);
448 res.status(500).json({ error: err.message });
452module.exports = router;