Skip to main content

Overview

The analytics system provides comprehensive insights into lottery operations through multiple specialized endpoints. All endpoints support RBAC filtering, date ranges, and dimensional breakdowns.

Dashboard Overview

The main dashboard endpoint aggregates all key metrics in a single response.
GET /api/v1/admin/dashboard?date=today&dimension=ventana
From src/api/v1/controllers/dashboard.controller.ts:44-74:
async getMainDashboard(req: AuthenticatedRequest, res: Response) {
  if (!req.user) throw new AppError("Unauthorized", 401);
  
  // Validate access: only ADMIN and VENTANA
  if (req.user.role === Role.VENDEDOR) {
    throw new AppError("No autorizado para ver dashboard", 403);
  }
  
  const query = req.query as any;
  const date = query.date || 'today';
  const dateRange = resolveDateRange(date, query.fromDate, query.toDate);
  
  const { ventanaId, bancaId } = await applyDashboardRbac(req, query);
  
  const result = await DashboardService.getFullDashboard({
    fromDate: dateRange.fromAt,
    toDate: dateRange.toAt,
    ventanaId,
    bancaId,
    loteriaId: query.loteriaId,
    betType: query.betType,
    interval: query.interval,
    scope: query.scope || 'all',
    dimension: query.dimension,
  }, req.user!.role);
  
  return success(res, result);
}

Query Parameters

ParameterTypeDescriptionDefault
datestringtoday | yesterday | week | month | rangetoday
fromDatestringStart date (YYYY-MM-DD) for date=range-
toDatestringEnd date (YYYY-MM-DD) for date=range-
dimensionstringventana | loteria | vendedor-
ventanaIdstringFilter by ventana (ADMIN only)-
loteriaIdstringFilter by lottery-
betTypestringNUMERO | REVENTADO-
intervalstringday | hourday
scopestringall | mineall
Date range validation:
  • When using fromDate and toDate, you must set date=range
  • hour interval only allowed for periods ≤ 7 days
  • All dates use Costa Rica timezone (UTC-6)

Response Structure

{
  "summary": {
    "totalSales": 250000,
    "totalTickets": 450,
    "avgTicketAmount": 556,
    "totalPayout": 180000,
    "totalWinners": 125,
    "netRevenue": 70000,
    "totalCommissions": 12500,
    "netAfterCommission": 57500
  },
  "cxc": {
    "totalAmount": 45000,
    "overdueAmount": 12000,
    "oldestDays": 15
  },
  "payments": {
    "totalPaid": 125000,
    "remainingAmount": 55000,
    "paidCount": 85,
    "unpaidCount": 40
  },
  "breakdown": {
    "byVentana": [...],
    "byLoteria": [...],
    "byVendedor": [...]
  },
  "meta": {
    "fromDate": "2025-01-20T06:00:00.000Z",
    "toDate": "2025-01-20T05:59:59.999Z",
    "timezone": "America/Costa_Rica",
    "queryExecutionTime": 145,
    "totalQueries": 8
  }
}

Ganancia (Profit) Analysis

Detailed profit breakdown with commission deduction.
GET /api/v1/admin/dashboard/ganancia?date=today&dimension=ventana
From src/api/v1/controllers/dashboard.controller.ts:81-114:
async getGanancia(req: AuthenticatedRequest, res: Response) {
  if (!req.user) throw new AppError("Unauthorized", 401);
  
  if (req.user.role === Role.VENDEDOR) {
    throw new AppError("No autorizado", 403);
  }
  
  const query = req.query as any;
  const date = query.date || 'today';
  const dateRange = resolveDateRange(date, query.fromDate, query.toDate);
  
  const { ventanaId, bancaId } = await applyDashboardRbac(req, query);
  
  const result = await DashboardService.calculateGanancia({
    fromDate: dateRange.fromAt,
    toDate: dateRange.toAt,
    ventanaId,
    bancaId,
    dimension: query.dimension,
  }, req.user!.role);
  
  return success(res, {
    data: result,
    meta: {
      range: {
        fromAt: dateRange.fromAt.toISOString(),
        toAt: dateRange.toAt.toISOString(),
      },
      generatedAt: new Date().toISOString(),
    },
  });
}

Response Structure

{
  "data": {
    "totalSales": 250000,
    "totalPayouts": 180000,
    "totalCommissions": 12500,
    "commissionUserTotal": 8000,
    "commissionVentanaTotal": 4500,
    "totalNet": 57500,
    "margin": 23.0,
    "items": [
      {
        "ventanaId": "uuid-ventana-1",
        "ventanaName": "Ventana Centro",
        "sales": 150000,
        "payout": 100000,
        "commissions": 7500,
        "netProfit": 42500,
        "margin": 28.3
      }
    ]
  },
  "meta": {...}
}

Month-to-Date Ganancia

Accumulated profit from start of month to today.
GET /api/v1/admin/dashboard/ganancia/month-to-date?dimension=ventana
From src/api/v1/controllers/dashboard.controller.ts:121-180:
async getGananciaMonthToDate(req: AuthenticatedRequest, res: Response) {
  if (!req.user) throw new AppError("Unauthorized", 401);
  
  if (req.user.role === Role.VENDEDOR) {
    throw new AppError("No autorizado", 403);
  }
  
  const query = req.query as any;
  
  // Validate dimension is 'ventana'
  if (query.dimension !== 'ventana') {
    throw new AppError("El parámetro 'dimension' debe ser 'ventana'", 400);
  }
  
  // Auto-calculate month range in CR timezone
  const monthRange = resolveDateRange('month');
  const todayRange = resolveDateRange('today');
  
  const { ventanaId, bancaId } = await applyDashboardRbac(req, query);
  
  const result = await DashboardService.calculateGanancia({
    fromDate: monthRange.fromAt,
    toDate: todayRange.toAt,
    ventanaId,
    bancaId,
    dimension: 'ventana',
  }, req.user!.role);
  
  // ... format response with month name in Spanish
}
Month-to-date calculation:
  • Start: First day of current month at 00:00 CR time
  • End: Current day at 23:59:59.999 CR time
  • Automatically resolves month name in Spanish

CXC (Accounts Receivable)

Track amounts owed by customers.
GET /api/v1/admin/dashboard/cxc?date=today&dimension=ventana
From src/api/v1/controllers/dashboard.controller.ts:186-218:
async getCxC(req: AuthenticatedRequest, res: Response) {
  if (!req.user) throw new AppError("Unauthorized", 401);
  
  if (req.user.role === Role.VENDEDOR) {
    throw new AppError("No autorizado", 403);
  }
  
  const query = req.query as any;
  const date = query.date || 'today';
  const dateRange = resolveDateRange(date, query.fromDate, query.toDate);
  
  const { ventanaId, bancaId } = await applyDashboardRbac(req, query);
  
  const result = await DashboardService.calculateCxC({
    fromDate: dateRange.fromAt,
    toDate: dateRange.toAt,
    ventanaId,
    bancaId,
    cxcDimension: query.dimension === 'vendedor' ? 'vendedor' : 'ventana',
  }, req.user!.role);
  
  return success(res, {
    data: result,
    meta: {...}
  });
}

Dimensions

  • dimension=ventana: CXC grouped by ventana
  • dimension=vendedor: CXC grouped by vendedor

Response

{
  "data": {
    "items": [
      {
        "ventanaId": "uuid-ventana",
        "ventanaName": "Centro",
        "totalOwed": 45000,
        "overdueAmount": 12000,
        "oldestInvoiceDays": 15,
        "invoiceCount": 8
      }
    ],
    "global": {
      "totalOwed": 45000,
      "overdueAmount": 12000
    }
  }
}

CXP (Accounts Payable)

Track amounts owed to vendedores/ventanas.
GET /api/v1/admin/dashboard/cxp?date=today&dimension=ventana
From src/api/v1/controllers/dashboard.controller.ts:224-257:
async getCxP(req: AuthenticatedRequest, res: Response) {
  if (!req.user) throw new AppError("Unauthorized", 401);
  
  if (req.user.role === Role.VENDEDOR) {
    throw new AppError("No autorizado", 403);
  }
  
  const query = req.query as any;
  const date = query.date || 'today';
  const dateRange = resolveDateRange(date, query.fromDate, query.toDate);
  
  const { ventanaId, bancaId } = await applyDashboardRbac(req, query);
  
  const result = await DashboardService.calculateCxP({
    fromDate: dateRange.fromAt,
    toDate: dateRange.toAt,
    ventanaId,
    bancaId,
    cxcDimension: query.dimension === 'vendedor' ? 'vendedor' : 'ventana',
  }, req.user!.role);
  
  return success(res, {...});
}

Time Series

Sales and profit trends over time.
GET /api/v1/admin/dashboard/timeseries?date=week&interval=day&compare=true
From src/api/v1/controllers/dashboard.controller.ts:263-303:
async getTimeSeries(req: AuthenticatedRequest, res: Response) {
  if (!req.user) throw new AppError("Unauthorized", 401);
  
  if (req.user.role === Role.VENDEDOR) {
    throw new AppError("No autorizado", 403);
  }
  
  const query = req.query as any;
  const date = query.date || 'today';
  const dateRange = resolveDateRange(date, query.fromDate, query.toDate);
  
  const { ventanaId, bancaId } = await applyDashboardRbac(req, query);
  
  const interval = query.interval || query.granularity || 'day';
  const compare = query.compare === true || query.compare === 'true';
  
  const result = await DashboardService.getTimeSeries({
    fromDate: dateRange.fromAt,
    toDate: dateRange.toAt,
    ventanaId,
    bancaId,
    loteriaId: query.loteriaId,
    betType: query.betType,
    interval,
    compare,
  });
  
  return success(res, {...});
}

Intervals

  • interval=day: Daily aggregation (any period)
  • interval=hour: Hourly aggregation (max 7 days)

Compare Mode

When compare=true, includes data from equivalent previous period.
{
  "current": [
    {"date": "2025-01-20", "sales": 250000, "tickets": 450},
    {"date": "2025-01-21", "sales": 280000, "tickets": 520}
  ],
  "previous": [
    {"date": "2025-01-13", "sales": 220000, "tickets": 400},
    {"date": "2025-01-14", "sales": 260000, "tickets": 480}
  ]
}

Exposure Analysis

Financial risk analysis by number, showing potential payout if specific numbers win.
GET /api/v1/admin/dashboard/exposure?date=today&top=20
From src/api/v1/controllers/dashboard.controller.ts:309-344:
async getExposure(req: AuthenticatedRequest, res: Response) {
  if (!req.user) throw new AppError("Unauthorized", 401);
  
  if (req.user.role === Role.VENDEDOR) {
    throw new AppError("No autorizado", 403);
  }
  
  const query = req.query as any;
  const date = query.date || 'today';
  const dateRange = resolveDateRange(date, query.fromDate, query.toDate);
  
  const { ventanaId, bancaId } = await applyDashboardRbac(req, query);
  
  const result = await DashboardService.calculateExposure({
    fromDate: dateRange.fromAt,
    toDate: dateRange.toAt,
    ventanaId,
    bancaId,
    loteriaId: query.loteriaId,
    betType: query.betType,
    top: query.top ? parseInt(query.top) : 10,
  });
  
  return success(res, {...});
}

Response

{
  "data": {
    "topExposures": [
      {
        "number": "25",
        "totalBets": 45000,
        "potentialPayout": 4275000,
        "ticketCount": 125,
        "avgMultiplier": 95
      },
      {
        "number": "50",
        "totalBets": 38000,
        "potentialPayout": 3610000,
        "ticketCount": 98,
        "avgMultiplier": 95
      }
    ],
    "summary": {
      "totalExposure": 15000000,
      "highestSingleNumber": 4275000,
      "avgExposurePerNumber": 150000
    }
  }
}
High exposure indicators:
  • potentialPayout > 10x daily average sales
  • Single number with > 20% of total exposure
  • Consider creating restriction rules for high-exposure numbers

Vendedor Rankings

Performance ranking with pagination.
GET /api/v1/admin/dashboard/vendedores?date=today&orderBy=sales&order=desc&page=1&pageSize=20
From src/api/v1/controllers/dashboard.controller.ts:350-390:
async getVendedores(req: AuthenticatedRequest, res: Response) {
  if (!req.user) throw new AppError("Unauthorized", 401);
  
  if (req.user.role === Role.VENDEDOR) {
    throw new AppError("No autorizado", 403);
  }
  
  const query = req.query as any;
  const date = query.date || 'today';
  const dateRange = resolveDateRange(date, query.fromDate, query.toDate);
  
  const { ventanaId, bancaId } = await applyDashboardRbac(req, query);
  
  const result = await DashboardService.getVendedores({
    fromDate: dateRange.fromAt,
    toDate: dateRange.toAt,
    ventanaId,
    bancaId,
    loteriaId: query.loteriaId,
    betType: query.betType,
    top: query.top ? parseInt(query.top) : undefined,
    orderBy: query.orderBy || 'sales',
    order: query.order || 'desc',
    page: query.page ? parseInt(query.page) : 1,
    pageSize: query.pageSize ? parseInt(query.pageSize) : 20,
  });
  
  return success(res, {
    data: result.byVendedor,
    pagination: result.pagination,
    meta: {...}
  });
}

Sorting Options

  • orderBy=sales: Sort by total sales
  • orderBy=tickets: Sort by ticket count
  • orderBy=payout: Sort by total payout
  • orderBy=net: Sort by net profit
  • orderBy=commission: Sort by commission earned

Dashboard Summary (Unified)

Consolidated endpoint replacing multiple individual calls.
GET /api/v1/admin/dashboard/summary?date=today
From src/api/v1/controllers/dashboard.controller.ts:547-567:
async getDashboardSummary(req: AuthenticatedRequest, res: Response) {
  if (!req.user) throw new AppError("Unauthorized", 401);
  
  if (req.user.role === Role.VENDEDOR) {
    throw new AppError("No autorizado para ver dashboard", 403);
  }
  
  const query = req.query as any;
  const date = query.date || 'today';
  const dateRange = resolveDateRange(date, query.fromDate, query.toDate);
  const { ventanaId, bancaId } = await applyDashboardRbac(req, query);
  
  const result = await DashboardService.calculateDashboardSummary({
    fromDate: dateRange.fromAt,
    toDate:   dateRange.toAt,
    ventanaId,
    bancaId,
  });
  
  return success(res, result);
}
Replaces:
  • /ganancia
  • /cxc?dimension=ventana
  • /cxp?dimension=ventana
  • /ganancia/month-to-date
  • /accumulated-balances?dimension=ventana

Dashboard Entities (Vendedor Detail)

GET /api/v1/admin/dashboard/entities?date=today
From src/api/v1/controllers/dashboard.controller.ts:575-595:
async getDashboardEntities(req: AuthenticatedRequest, res: Response) {
  if (!req.user) throw new AppError("Unauthorized", 401);
  
  if (req.user.role === Role.VENDEDOR) {
    throw new AppError("No autorizado para ver dashboard", 403);
  }
  
  const query = req.query as any;
  const date = query.date || 'today';
  const dateRange = resolveDateRange(date, query.fromDate, query.toDate);
  const { ventanaId, bancaId } = await applyDashboardRbac(req, query);
  
  const result = await DashboardService.calculateDashboardEntities({
    fromDate: dateRange.fromAt,
    toDate:   dateRange.toAt,
    ventanaId,
    bancaId,
  });
  
  return success(res, result);
}
Replaces:
  • /cxc?dimension=vendedor
  • /cxp?dimension=vendedor
  • /vendedores
  • /accumulated-balances?dimension=vendedor

Export Dashboard

Export dashboard data in CSV, XLSX, or PDF format.
GET /api/v1/admin/dashboard/export?date=today&format=xlsx
From src/api/v1/controllers/dashboard.controller.ts:431-539:
async exportDashboard(req: AuthenticatedRequest, res: Response) {
  if (!req.user) throw new AppError("Unauthorized", 401);
  
  if (req.user.role === Role.VENDEDOR) {
    throw new AppError("No autorizado", 403);
  }
  
  const query = req.query as any;
  const format = query.format || 'csv';
  
  if (!['csv', 'xlsx', 'pdf'].includes(format)) {
    throw new AppError("Formato inválido. Use: csv, xlsx, pdf", 422);
  }
  
  const date = query.fromDate && query.toDate ? 'range' : (query.date || 'today');
  const dateRange = resolveDateRange(date, query.fromDate, query.toDate);
  
  const { ventanaId, bancaId } = await applyDashboardRbac(req, query);
  
  const dashboard = await DashboardService.getFullDashboard({...});
  
  const timestamp = new Date().toISOString().split('T')[0];
  const filename = `dashboard-${timestamp}.${format}`;
  
  res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
  
  if (format === 'csv') {
    res.setHeader('Content-Type', 'text/csv; charset=utf-8');
    const csv = DashboardExportService.generateCSV(dashboard as any);
    return res.send('\ufeff' + csv); // UTF-8 BOM for Excel
  } else if (format === 'xlsx') {
    res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
    const workbook = await DashboardExportService.generateWorkbook(dashboard as any);
    await workbook.xlsx.write(res);
    return res.end();
  } else if (format === 'pdf') {
    // PDF generation logic
  }
}

Export Formats

  • format=csv: UTF-8 CSV with BOM (Excel compatible)
  • format=xlsx: Excel workbook with multiple sheets
  • format=pdf: PDF report with charts

RBAC and Permissions

Role-Based Filtering

From src/api/v1/controllers/dashboard.controller.ts:20-37:
async function applyDashboardRbac(req: AuthenticatedRequest, query: any) {
  let ventanaId = query.ventanaId;
  let bancaId: string | undefined = undefined;
  
  if (req.user!.role === Role.VENTANA) {
    // VENTANA only sees their own data
    const validatedVentanaId = await validateVentanaUser(
      req.user!.role, 
      req.user!.ventanaId, 
      req.user!.id
    );
    ventanaId = validatedVentanaId!;
  } else if (req.user!.role === Role.ADMIN) {
    // ADMIN: filter by active banca if available
    if (req.bancaContext?.bancaId) {
      bancaId = req.bancaContext.bancaId;
    }
  }
  
  return { ventanaId, bancaId };
}
Access levels:
  • VENDEDOR: ❌ No dashboard access
  • VENTANA: ✅ Own ventana data only
  • ADMIN: ✅ All data, filtered by active banca context

Best Practices

Prefer /dashboard/summary and /dashboard/entities over multiple individual calls to reduce latency.
Dashboard queries are expensive. Cache results for 1-5 minutes and refresh on user action.
interval=hour is only allowed for periods ≤7 days and generates 168+ data points. Use for tactical views only.
Check /dashboard/exposure before each major sorteo. Create restriction rules for numbers with >10x average exposure.
Use /export?format=xlsx for auditable financial reports. Include date ranges in filename.

Build docs developers (and LLMs) love