feat: API Gateway (Express 5 + Prisma + Monday) y conexion de apps al API

- api-gateway: servicio Monday (retry 429/complejidad, paginacion cursor,
  column IDs por titulo), endpoints unidades/cotizaciones/clientes/planes,
  webhook con handshake challenge, Prisma SQLite dev, 55 tests
- cotizador: inventario desde /api/unidades con fallback a seed
- alta-clientes: POST /api/clientes + upload multipart de documentos
- cobranza: persistencia via API con respaldo offline en localStorage
- .env.example con board IDs del workspace Frasa-CAS
This commit is contained in:
2026-08-21 23:39:17 +00:00
parent ecd4edac02
commit 0546f35d2a
48 changed files with 7129 additions and 66 deletions

36
.env.example Normal file
View File

@@ -0,0 +1,36 @@
# ============================================
# MONDAY.COM
# ============================================
MONDAY_API_KEY="PONER_API_KEY_DE_MONDAY"
MONDAY_API_URL="https://api.monday.com/v2"
MONDAY_WORKSPACE="Frasa-CAS"
# Board IDs del workspace Frasa-CAS (ya creados)
MONDAY_BOARD_CONTACTOS="18427678476"
MONDAY_BOARD_DESARROLLOS="18427678485"
MONDAY_BOARD_UNIDADES="18427678487"
MONDAY_BOARD_LEADS="18427678493"
MONDAY_BOARD_OPORTUNIDADES="18427678497"
MONDAY_BOARD_APARTADO="18427678515"
MONDAY_BOARD_VENTAS="18427678520"
# ============================================
# BASE DE DATOS (fase API Gateway)
# ============================================
DATABASE_URL="postgresql://frasa_user:CAMBIAR@postgres:5432/frasa_db?schema=public"
POSTGRES_USER=frasa_user
POSTGRES_PASSWORD=CAMBIAR
POSTGRES_DB=frasa_db
REDIS_URL="redis://redis:6379"
# ============================================
# SEGURIDAD
# ============================================
JWT_SECRET="cambiar_por_secreto_largo_y_aleatorio"
JWT_EXPIRES_IN="7d"
# ============================================
# APP CONFIG
# ============================================
NODE_ENV=development
PORT=3000

4
.gitignore vendored
View File

@@ -11,6 +11,10 @@ dist/
# Material de referencia (contiene datos personales de formatos reales) # Material de referencia (contiene datos personales de formatos reales)
reference/ reference/
# Datos locales del API Gateway
api-gateway/prisma/dev.db*
api-gateway/uploads/
# Sistema # Sistema
.DS_Store .DS_Store
*.log *.log

View File

@@ -0,0 +1,8 @@
node_modules
dist
uploads
prisma/dev.db
prisma/dev.db-journal
.env
*.log
.git

28
api-gateway/Dockerfile Normal file
View File

@@ -0,0 +1,28 @@
# ---- Build ----
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
COPY prisma ./prisma
RUN npm ci
COPY tsconfig.json ./
COPY src ./src
RUN npx prisma generate && npm run build
# ---- Runner ----
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
COPY prisma ./prisma
RUN npm ci --omit=dev
COPY --from=build /app/node_modules/.prisma ./node_modules/.prisma
COPY --from=build /app/node_modules/@prisma/client ./node_modules/@prisma/client
COPY --from=build /app/dist ./dist
EXPOSE 3000
# Aplica migraciones pendientes antes de arrancar
CMD ["sh", "-c", "npx prisma migrate deploy && node dist/index.js"]

66
api-gateway/README.md Normal file
View File

@@ -0,0 +1,66 @@
# FRASA API Gateway
Backend Node.js que conecta las 3 apps web del ecosistema FRASA (cotizador :3001, alta-clientes :3002, cobranza :3003) con Monday.com y da persistencia real (Prisma + SQLite en dev, Postgres 16 en prod).
## Stack
Express 5 · TypeScript estricto · zod · Prisma (SQLite dev) · multer 2 · vitest
## Cómo correr en dev
```bash
npm install
npx prisma migrate dev # crea/actualiza prisma/dev.db
npm run dev # tsx watch en :3000
```
Las credenciales de Monday y los board IDs se leen del `.env` raíz del ecosistema (`../.env`); el servicio también acepta un `.env` local (Prisma CLI lo requiere para `DATABASE_URL`). El token NUNCA se imprime en logs.
## Tests
```bash
npm test # vitest: fórmulas del cotizador (fixture U-101), cobranza, monday.service con fetch mockeado
```
## Endpoints (prefijo /api)
| Método | Ruta | Descripción |
|---|---|---|
| GET | `/api/health` | `{status, monday: 'up'|'down'}` |
| GET | `/api/unidades` | Inventario desde Monday (`?disponibles=true` filtra) |
| GET | `/api/unidades/:codigo` | Una unidad (404 si no existe) |
| POST | `/api/cotizaciones` | Calcula propuesta (server-side), persiste y crea Oportunidad en Monday |
| GET | `/api/cotizaciones` · `/api/cotizaciones/:id` | Consulta |
| POST | `/api/clientes` | Alta de cliente: persiste payload completo, crea Contacto y Apartado en Monday |
| GET | `/api/clientes/:id` | Cliente con documentos |
| POST | `/api/clientes/:id/documentos` | Multipart (campo `archivos`, máx 10, pdf/jpg/png, 10 MB) |
| GET | `/api/clientes/:id/documentos` | Lista documentos |
| POST | `/api/planes` | Crea plan de pago con calendario generado |
| GET | `/api/planes` · `/api/planes/:id` | Con saldo/status calculados |
| GET | `/api/planes/cobranza-del-dia` | Urgencias de cobranza (`?diasAnticipacion=7`) |
| POST | `/api/planes/:id/abonos` | Aplica abono (estricto al centavo) |
| POST | `/api/planes/:id/notas` | Agrega nota a la bitácora |
| POST | `/api/planes/:id/cancelar` | Cancela el plan |
| POST | `/api/webhooks/monday` | Handshake `challenge` + log de eventos |
## Notas de arquitectura
- **Monday service** (`src/services/monday.service.ts`): resuelve column IDs por TÍTULO en runtime (los slugs autogenerados nunca se hardcodean), paginación por cursor, retry con backoff ante 429/complejidad, `column_values` siempre via `JSON.stringify` en variables, cache de unidades 5 min con invalidación tras escrituras.
- **Cálculos del cotizador** (`src/services/cotizacion.service.ts`): port exacto del Excel de producción (quirks incluidos: Infonavit usa precio de lista en mensualidades; enganche bancario sobre precio lista). NO corregir.
- **Cobranza** (`src/services/cobranza.service.ts`): espejo de la lógica del frontend; los controllers mapean Prisma → dominio, ejecutan las funciones puras y persisten.
- **Dinero como `Float`** por ahora — TODO prod: `Decimal` en Postgres.
- **Webhooks de Monday no están firmados** → el payload se trata como no confiable; antes de actuar, re-consultar vía API (por ahora solo se loggea).
## TODO producción (pendientes deliberados)
- Auth (JWT ya hay vars en .env), helmet, rate-limit — es red interna por ahora.
- Postgres 16 (`provider = "postgresql"`, `Decimal`), Redis para cache de unidades.
- CORS restringido a los orígenes de las apps.
## Docker
```bash
docker build -t frasa-api-gateway .
docker run -p 3000:3000 --env-file ../.env frasa-api-gateway
# El arranque corre `prisma migrate deploy` antes de levantar el server
```

3237
api-gateway/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

35
api-gateway/package.json Normal file
View File

@@ -0,0 +1,35 @@
{
"name": "frasa-api-gateway",
"version": "0.1.0",
"private": true,
"description": "API Gateway del ecosistema FRASA: conecta las apps web con Monday.com y da persistencia",
"type": "module",
"engines": {
"node": ">=22"
},
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/index.js",
"test": "vitest run",
"prisma:migrate": "prisma migrate dev"
},
"dependencies": {
"@prisma/client": "^6.14.0",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^5.1.0",
"multer": "^2.0.2",
"zod": "^3.25.76"
},
"devDependencies": {
"@types/cors": "^2.8.19",
"@types/express": "^5.0.3",
"@types/multer": "^2.0.0",
"@types/node": "^22.15.0",
"prisma": "^6.14.0",
"tsx": "^4.20.3",
"typescript": "^5.9.2",
"vitest": "^3.2.4"
}
}

View File

@@ -0,0 +1,121 @@
-- CreateTable
CREATE TABLE "Cotizacion" (
"id" TEXT NOT NULL PRIMARY KEY,
"unidadCodigo" TEXT NOT NULL,
"clienteNombre" TEXT NOT NULL,
"clienteEmail" TEXT,
"clienteTelefono" TEXT,
"tipoPropuesta" TEXT NOT NULL,
"acabados" BOOLEAN NOT NULL,
"apartado" REAL NOT NULL,
"descuento" REAL NOT NULL,
"precioLista" REAL NOT NULL,
"precioFinal" REAL NOT NULL,
"enganche" REAL NOT NULL,
"numMensualidades" INTEGER NOT NULL,
"mensualidad" REAL,
"pagoFinal" REAL NOT NULL,
"mondayItemId" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- CreateTable
CREATE TABLE "Cliente" (
"id" TEXT NOT NULL PRIMARY KEY,
"nombreCompleto" TEXT NOT NULL,
"curp" TEXT NOT NULL,
"rfc" TEXT NOT NULL,
"email" TEXT,
"celular" TEXT,
"proyecto" TEXT NOT NULL,
"unidadCodigo" TEXT,
"payloadJson" TEXT NOT NULL,
"firmaPng" TEXT,
"mondayContactoId" TEXT,
"mondayApartadoId" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- CreateTable
CREATE TABLE "Documento" (
"id" TEXT NOT NULL PRIMARY KEY,
"clienteId" TEXT NOT NULL,
"tipo" TEXT NOT NULL,
"nombreArchivo" TEXT NOT NULL,
"ruta" TEXT NOT NULL,
"mime" TEXT NOT NULL,
"tamano" INTEGER NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Documento_clienteId_fkey" FOREIGN KEY ("clienteId") REFERENCES "Cliente" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "PlanPago" (
"id" TEXT NOT NULL PRIMARY KEY,
"ventaRef" TEXT,
"clienteNombre" TEXT NOT NULL,
"clienteTelefono" TEXT,
"unidadCodigo" TEXT NOT NULL,
"proyecto" TEXT NOT NULL,
"fechaInicio" DATETIME NOT NULL,
"engancheTotal" REAL NOT NULL,
"numPagos" INTEGER NOT NULL,
"mensualidad" REAL NOT NULL,
"tasaMoraMensual" REAL NOT NULL DEFAULT 0,
"status" TEXT NOT NULL DEFAULT 'ACTIVO',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- CreateTable
CREATE TABLE "Pago" (
"id" TEXT NOT NULL PRIMARY KEY,
"planId" TEXT NOT NULL,
"numPago" INTEGER NOT NULL,
"fechaVencimiento" DATETIME NOT NULL,
"monto" REAL NOT NULL,
"montoPagado" REAL NOT NULL DEFAULT 0,
CONSTRAINT "Pago_planId_fkey" FOREIGN KEY ("planId") REFERENCES "PlanPago" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "AplicacionPago" (
"id" TEXT NOT NULL PRIMARY KEY,
"pagoId" TEXT NOT NULL,
"fechaPago" DATETIME NOT NULL,
"monto" REAL NOT NULL,
"referencia" TEXT NOT NULL,
CONSTRAINT "AplicacionPago_pagoId_fkey" FOREIGN KEY ("pagoId") REFERENCES "Pago" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "NotaPlan" (
"id" TEXT NOT NULL PRIMARY KEY,
"planId" TEXT NOT NULL,
"fecha" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"texto" TEXT NOT NULL,
CONSTRAINT "NotaPlan_planId_fkey" FOREIGN KEY ("planId") REFERENCES "PlanPago" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE INDEX "Cotizacion_unidadCodigo_idx" ON "Cotizacion"("unidadCodigo");
-- CreateIndex
CREATE INDEX "Cliente_curp_idx" ON "Cliente"("curp");
-- CreateIndex
CREATE INDEX "Documento_clienteId_idx" ON "Documento"("clienteId");
-- CreateIndex
CREATE INDEX "PlanPago_unidadCodigo_idx" ON "PlanPago"("unidadCodigo");
-- CreateIndex
CREATE INDEX "Pago_planId_idx" ON "Pago"("planId");
-- CreateIndex
CREATE INDEX "Pago_fechaVencimiento_idx" ON "Pago"("fechaVencimiento");
-- CreateIndex
CREATE INDEX "AplicacionPago_pagoId_idx" ON "AplicacionPago"("pagoId");
-- CreateIndex
CREATE INDEX "NotaPlan_planId_idx" ON "NotaPlan"("planId");

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "sqlite"

View File

@@ -0,0 +1,123 @@
// FRASA API Gateway — persistencia local
// Desarrollo: SQLite. Producción: Postgres 16 (cambiar provider y DATABASE_URL).
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite" // prod: "postgresql"
url = env("DATABASE_URL")
}
model Cotizacion {
id String @id @default(uuid())
unidadCodigo String
clienteNombre String
clienteEmail String?
clienteTelefono String?
tipoPropuesta String // BANCARIO | INFONAVIT | CONTADO | SEMI_CONTADO
acabados Boolean
apartado Float // TODO prod: Decimal en Postgres
descuento Float // fracción (0.02 = 2%)
precioLista Float // TODO prod: Decimal en Postgres
precioFinal Float
enganche Float
numMensualidades Int
mensualidad Float? // null => N/A (Contado)
pagoFinal Float
mondayItemId String?
createdAt DateTime @default(now())
@@index([unidadCodigo])
}
model Cliente {
id String @id @default(uuid())
nombreCompleto String
curp String
rfc String
email String?
celular String?
proyecto String
unidadCodigo String?
// Payload completo del alta como JSON serializado (SQLite no tiene Json nativo;
// en Postgres puede migrarse a la columna Json sin cambiar el contrato).
payloadJson String
firmaPng String? // base64 opcional
mondayContactoId String?
mondayApartadoId String?
createdAt DateTime @default(now())
documentos Documento[]
@@index([curp])
}
model Documento {
id String @id @default(uuid())
clienteId String
cliente Cliente @relation(fields: [clienteId], references: [id], onDelete: Cascade)
tipo String // INE | CONSTANCIA_RFC | CURP | COMPROBANTE_DOMICILIO | ACTA_NACIMIENTO | OTRO
nombreArchivo String
ruta String
mime String
tamano Int
createdAt DateTime @default(now())
@@index([clienteId])
}
model PlanPago {
id String @id @default(uuid())
ventaRef String?
clienteNombre String
clienteTelefono String?
unidadCodigo String
proyecto String
fechaInicio DateTime // se normaliza a medianoche local
engancheTotal Float // TODO prod: Decimal en Postgres
numPagos Int
mensualidad Float
tasaMoraMensual Float @default(0)
status String @default("ACTIVO") // ACTIVO | CANCELADO (LIQUIDADO se deriva)
createdAt DateTime @default(now())
notas NotaPlan[]
pagos Pago[]
@@index([unidadCodigo])
}
model Pago {
id String @id @default(uuid())
planId String
plan PlanPago @relation(fields: [planId], references: [id], onDelete: Cascade)
numPago Int
fechaVencimiento DateTime
monto Float
montoPagado Float @default(0)
aplicaciones AplicacionPago[]
@@index([planId])
@@index([fechaVencimiento])
}
model AplicacionPago {
id String @id @default(uuid())
pagoId String
pago Pago @relation(fields: [pagoId], references: [id], onDelete: Cascade)
fechaPago DateTime
monto Float
referencia String
@@index([pagoId])
}
model NotaPlan {
id String @id @default(uuid())
planId String
plan PlanPago @relation(fields: [planId], references: [id], onDelete: Cascade)
fecha DateTime @default(now())
texto String
@@index([planId])
}

22
api-gateway/src/app.ts Normal file
View File

@@ -0,0 +1,22 @@
import express from 'express'
import cors from 'cors'
import { apiRouter } from './routes/index.js'
import { errorHandler } from './middleware/errorHandler.js'
import { requestLogger } from './middleware/requestLogger.js'
export function crearApp(): express.Express {
const app = express()
app.use(cors()) // CORS abierto solo para dev (red interna)
app.use(express.json({ limit: '10mb' }))
app.use(requestLogger)
app.use('/api', apiRouter)
app.use((_req, res) => {
res.status(404).json({ error: 'Ruta no encontrada' })
})
app.use(errorHandler)
return app
}

View File

@@ -0,0 +1,34 @@
import path from 'node:path'
import dotenv from 'dotenv'
// Carga .env local si existe y luego el .env raíz del ecosistema (no pisa vars ya definidas)
dotenv.config({ path: [path.resolve(process.cwd(), '.env'), path.resolve(process.cwd(), '..', '.env')] })
function env(name: string, fallback?: string): string {
const v = process.env[name] ?? fallback
if (v === undefined) throw new Error(`Falta la variable de entorno ${name}`)
return v
}
export const config = {
port: Number(env('PORT', '3000')),
nodeEnv: env('NODE_ENV', 'development'),
databaseUrl: env('DATABASE_URL', 'file:./dev.db'),
uploadsDir: path.resolve(process.cwd(), env('UPLOADS_DIR', './uploads')),
monday: {
apiKey: env('MONDAY_API_KEY'),
apiUrl: env('MONDAY_API_URL', 'https://api.monday.com/v2'),
apiVersion: '2024-10',
boards: {
contactos: env('MONDAY_BOARD_CONTACTOS'),
desarrollos: env('MONDAY_BOARD_DESARROLLOS'),
unidades: env('MONDAY_BOARD_UNIDADES'),
leads: env('MONDAY_BOARD_LEADS'),
oportunidades: env('MONDAY_BOARD_OPORTUNIDADES'),
apartado: env('MONDAY_BOARD_APARTADO'),
ventas: env('MONDAY_BOARD_VENTAS'),
},
},
} as const
export type AppConfig = typeof config

View File

@@ -0,0 +1,191 @@
import type { Request, Response } from 'express'
import path from 'node:path'
import fs from 'node:fs'
import { randomUUID } from 'node:crypto'
import multer from 'multer'
import { z } from 'zod'
import { config } from '../config/index.js'
import { mondayService } from '../services/monday.service.js'
import { prisma } from '../services/prisma.js'
import { NotFoundError, BadRequestError } from '../utils/errors.js'
// ---------- Alta de clientes ----------
const crearClienteSchema = z
.object({
datosGenerales: z
.object({
nombreCompleto: z.string().min(1),
curp: z.string().min(1),
rfc: z.string().min(1),
email: z.string().optional(),
celular: z.string().optional(),
direccion: z.string().optional(),
})
.passthrough(),
viviendaCredito: z
.object({
proyecto: z.string().min(1),
ubicacion: z.string().min(1),
valorVenta: z.union([z.string(), z.number()]).optional(),
enganche: z.union([z.string(), z.number()]).optional(),
})
.passthrough(),
firma: z.object({ firmaDataUrl: z.string().optional() }).passthrough().optional(),
})
.passthrough() // el resto del payload pasa tal cual a payloadJson
const aNumero = (v: string | number | undefined): number => {
if (v === undefined) return 0
const n = typeof v === 'number' ? v : Number(String(v).replace(/[^0-9.-]/g, ''))
return Number.isFinite(n) ? n : 0
}
const hoyMas = (dias: number): string => {
const d = new Date()
d.setDate(d.getDate() + dias)
const m = String(d.getMonth() + 1).padStart(2, '0')
const dia = String(d.getDate()).padStart(2, '0')
return `${d.getFullYear()}-${m}-${dia}`
}
/** POST /api/clientes — persiste el alta y la replica en Monday (Contactos y Apartado) */
export async function crearCliente(req: Request, res: Response): Promise<void> {
const body = crearClienteSchema.parse(req.body)
const dg = body.datosGenerales
const vc = body.viviendaCredito
const cliente = await prisma.cliente.create({
data: {
nombreCompleto: dg.nombreCompleto,
curp: dg.curp,
rfc: dg.rfc,
email: dg.email ?? null,
celular: dg.celular ?? null,
proyecto: vc.proyecto,
unidadCodigo: vc.ubicacion, // la "ubicación" del formato es el código de unidad (U-101, etc.)
payloadJson: JSON.stringify(body),
firmaPng: body.firma?.firmaDataUrl ?? null,
},
})
// Réplica en Monday; si falla, el alta ya quedó persistida y se reportan ids null
let mondayContactoId: string | null = null
let mondayApartadoId: string | null = null
try {
mondayContactoId = await mondayService.crearContacto({
nombre: dg.nombreCompleto,
estadoLabel: 'CLIENTE',
...(dg.celular !== undefined ? { telefono: dg.celular } : {}),
...(dg.email !== undefined ? { email: dg.email } : {}),
curp: dg.curp,
rfc: dg.rfc,
...(dg.direccion !== undefined ? { direccion: dg.direccion } : {}),
})
const valorVenta = aNumero(vc.valorVenta)
if (valorVenta > 0) {
const enganche = aNumero(vc.enganche)
mondayApartadoId = await mondayService.crearApartado({
clienteNombre: dg.nombreCompleto,
unidadCodigo: vc.ubicacion,
...(enganche > 0 ? { montoApartado: enganche } : {}),
fechaApartado: hoyMas(0),
fechaLimite: hoyMas(15),
estadoLabel: 'VIGENTE',
})
}
await prisma.cliente.update({
where: { id: cliente.id },
data: { mondayContactoId, mondayApartadoId },
})
} catch (err) {
// eslint-disable-next-line no-console
console.error('No se pudo replicar el cliente en Monday:', err)
}
res.status(201).json({ ...cliente, mondayContactoId, mondayApartadoId })
}
/** GET /api/clientes/:id */
export async function obtenerCliente(req: Request, res: Response): Promise<void> {
const cliente = await prisma.cliente.findUnique({
where: { id: String(req.params['id']) },
include: { documentos: true },
})
if (!cliente) throw new NotFoundError('Cliente no encontrado')
res.json(cliente)
}
// ---------- Documentos ----------
const MIME_PERMITIDOS = new Set(['application/pdf', 'image/jpeg', 'image/jpg', 'image/png'])
const EXT_POR_MIME: Record<string, string> = {
'application/pdf': 'pdf',
'image/jpeg': 'jpg',
'image/jpg': 'jpg',
'image/png': 'png',
}
const storage = multer.diskStorage({
destination: (req, _file, cb) => {
const dir = path.join(config.uploadsDir, String(req.params['id']))
fs.mkdirSync(dir, { recursive: true })
cb(null, dir)
},
filename: (req, file, cb) => {
const tipo = String(req.body['tipo'] ?? 'OTRO').toUpperCase()
const ext = EXT_POR_MIME[file.mimetype] ?? 'bin'
cb(null, `${tipo}_${randomUUID()}.${ext}`)
},
})
export const uploadDocumentos = multer({
storage,
limits: { fileSize: 10 * 1024 * 1024, files: 10 }, // 10 MB por archivo, máx 10
fileFilter: (_req, file, cb) => {
if (!MIME_PERMITIDOS.has(file.mimetype)) {
cb(new BadRequestError(`Tipo de archivo no permitido: ${file.mimetype}`))
return
}
cb(null, true)
},
})
/** POST /api/clientes/:id/documentos — multipart, campo `archivos` (hasta 10) */
export async function subirDocumentos(req: Request, res: Response): Promise<void> {
const clienteId = String(req.params['id'])
const cliente = await prisma.cliente.findUnique({ where: { id: clienteId } })
if (!cliente) throw new NotFoundError('Cliente no encontrado')
const archivos = (req.files ?? []) as Express.Multer.File[]
const tipo = String(req.body['tipo'] ?? 'OTRO').toUpperCase()
const documentos = await Promise.all(
archivos.map((f) =>
prisma.documento.create({
data: {
clienteId,
tipo,
nombreArchivo: f.originalname,
ruta: path.relative(config.uploadsDir, f.path),
mime: f.mimetype,
tamano: f.size,
},
}),
),
)
res.status(201).json(documentos)
}
/** GET /api/clientes/:id/documentos */
export async function listarDocumentos(req: Request, res: Response): Promise<void> {
const clienteId = String(req.params['id'])
const cliente = await prisma.cliente.findUnique({ where: { id: clienteId } })
if (!cliente) throw new NotFoundError('Cliente no encontrado')
const documentos = await prisma.documento.findMany({
where: { clienteId },
orderBy: { createdAt: 'desc' },
})
res.json(documentos)
}

View File

@@ -0,0 +1,91 @@
import type { Request, Response } from 'express'
import { z } from 'zod'
import { mondayService } from '../services/monday.service.js'
import { calcularPropuesta, obtenerPrecioLista } from '../services/cotizacion.service.js'
import { prisma } from '../services/prisma.js'
import { NotFoundError } from '../utils/errors.js'
const crearCotizacionSchema = z.object({
unidadCodigo: z.string().min(1),
acabados: z.boolean(),
apartado: z.number().positive().optional(),
clienteNombre: z.string().min(1),
clienteEmail: z.string().email().optional(),
clienteTelefono: z.string().min(7).optional(),
tipoPropuesta: z.enum(['BANCARIO', 'INFONAVIT', 'CONTADO', 'SEMI_CONTADO']),
descuento: z.number().min(0).max(1).optional(), // fracción (0.02 = 2%)
})
const hoyISO = (): string => {
const d = new Date()
const m = String(d.getMonth() + 1).padStart(2, '0')
const dia = String(d.getDate()).padStart(2, '0')
return `${d.getFullYear()}-${m}-${dia}`
}
/** POST /api/cotizaciones — calcula, persiste y registra la oportunidad en Monday */
export async function crearCotizacion(req: Request, res: Response): Promise<void> {
const body = crearCotizacionSchema.parse(req.body)
const unidad = await mondayService.getUnidad(body.unidadCodigo)
if (!unidad) throw new NotFoundError(`Unidad ${body.unidadCodigo} no encontrada`)
const apartado = body.apartado ?? 20000 // APARTADO_DEFAULT
const propuesta = calcularPropuesta(unidad, body.tipoPropuesta, body.acabados, apartado, body.descuento)
const precioLista = obtenerPrecioLista(unidad, body.acabados)
const cotizacion = await prisma.cotizacion.create({
data: {
unidadCodigo: unidad.codigo,
clienteNombre: body.clienteNombre,
clienteEmail: body.clienteEmail ?? null,
clienteTelefono: body.clienteTelefono ?? null,
tipoPropuesta: body.tipoPropuesta,
acabados: body.acabados,
apartado,
descuento: propuesta.descuento,
precioLista,
precioFinal: propuesta.precioFinal,
enganche: propuesta.enganche,
numMensualidades: propuesta.numMensualidades,
mensualidad: propuesta.mensualidad,
pagoFinal: propuesta.pagoFinal,
},
})
// Registra la oportunidad en Monday; si Monday falla, la cotización ya quedó persistida
let mondayItemId: string | null = null
try {
mondayItemId = await mondayService.crearOportunidad({
clienteNombre: body.clienteNombre,
unidadCodigo: unidad.codigo,
tipoPropuestaLabel: body.tipoPropuesta,
precioLista,
descuentoPct: propuesta.descuento * 100,
precioFinal: propuesta.precioFinal,
fechaCotizacion: hoyISO(),
estadoLabel: 'COTIZADA',
...(body.clienteEmail !== undefined ? { clienteEmail: body.clienteEmail } : {}),
...(body.clienteTelefono !== undefined ? { clienteTelefono: body.clienteTelefono } : {}),
})
await prisma.cotizacion.update({ where: { id: cotizacion.id }, data: { mondayItemId } })
} catch (err) {
// eslint-disable-next-line no-console
console.error('No se pudo crear la oportunidad en Monday:', err)
}
res.status(201).json({ ...cotizacion, mondayItemId, propuesta })
}
/** GET /api/cotizaciones */
export async function listarCotizaciones(_req: Request, res: Response): Promise<void> {
const cotizaciones = await prisma.cotizacion.findMany({ orderBy: { createdAt: 'desc' } })
res.json(cotizaciones)
}
/** GET /api/cotizaciones/:id */
export async function obtenerCotizacion(req: Request, res: Response): Promise<void> {
const cotizacion = await prisma.cotizacion.findUnique({ where: { id: String(req.params['id']) } })
if (!cotizacion) throw new NotFoundError('Cotización no encontrada')
res.json(cotizacion)
}

View File

@@ -0,0 +1,8 @@
import type { Request, Response } from 'express'
import { mondayService } from '../services/monday.service.js'
/** GET /api/health — ping barato a Monday */
export async function health(_req: Request, res: Response): Promise<void> {
const mondayUp = await mondayService.ping()
res.json({ status: 'ok', monday: mondayUp ? 'up' : 'down' })
}

View File

@@ -0,0 +1,236 @@
import type { Request, Response } from 'express'
import { z } from 'zod'
import { prisma } from '../services/prisma.js'
import {
alCorriente,
avancePlan,
cobranzaDelDia,
construirNota,
diasAtraso,
generarCalendario,
pagadoTotal,
parseFechaLocal,
round2,
saldoPlan,
statusPago,
statusPlan,
totalMora,
validarAbono,
type PlanPago as PlanPagoDominio,
} from '../services/cobranza.service.js'
import { BadRequestError, NotFoundError } from '../utils/errors.js'
import type { AplicacionPago, NotaPlan, Pago, PlanPago } from '@prisma/client'
// ---------- Mapeo Prisma → dominio (funciones puras del servicio) ----------
const aFechaISO = (d: Date): string => {
const m = String(d.getMonth() + 1).padStart(2, '0')
const dia = String(d.getDate()).padStart(2, '0')
return `${d.getFullYear()}-${m}-${dia}`
}
type PlanCompleto = PlanPago & {
pagos: (Pago & { aplicaciones: AplicacionPago[] })[]
notas: NotaPlan[]
}
function aDominio(plan: PlanCompleto): PlanPagoDominio {
return {
id: plan.id,
ventaRef: plan.ventaRef,
clienteNombre: plan.clienteNombre,
clienteTelefono: plan.clienteTelefono,
unidadCodigo: plan.unidadCodigo,
proyecto: plan.proyecto,
fechaInicio: aFechaISO(plan.fechaInicio),
engancheTotal: plan.engancheTotal,
numPagos: plan.numPagos,
mensualidad: plan.mensualidad,
tasaMoraMensual: plan.tasaMoraMensual,
status: plan.status === 'CANCELADO' ? 'CANCELADO' : 'ACTIVO',
createdAt: plan.createdAt.toISOString(),
pagos: plan.pagos
.slice()
.sort((a, b) => a.numPago - b.numPago)
.map((p) => ({
id: p.id,
numPago: p.numPago,
fechaVencimiento: aFechaISO(p.fechaVencimiento),
monto: p.monto,
montoPagado: p.montoPagado,
aplicaciones: p.aplicaciones.map((a) => ({
id: a.id,
fechaPago: a.fechaPago.toISOString(),
monto: a.monto,
referencia: a.referencia,
})),
})),
notas: plan.notas.map((n) => ({ id: n.id, fecha: n.fecha.toISOString(), texto: n.texto })),
}
}
/** Plan con los campos calculados que la app de cobranza muestra */
function conCalculados(plan: PlanPagoDominio) {
const hoy = new Date()
return {
...plan,
status: statusPlan(plan),
saldo: saldoPlan(plan),
pagadoTotal: pagadoTotal(plan),
avance: avancePlan(plan),
alCorriente: alCorriente(plan, hoy),
diasAtraso: diasAtraso(plan, hoy),
totalMora: totalMora(plan, hoy),
pagos: plan.pagos.map((p) => ({ ...p, status: statusPago(p, hoy) })),
}
}
const includeCompleto = { pagos: { include: { aplicaciones: true } }, notas: true } as const
async function cargarPlan(id: string): Promise<PlanCompleto> {
const plan = await prisma.planPago.findUnique({ where: { id }, include: includeCompleto })
if (!plan) throw new NotFoundError('Plan de pago no encontrado')
return plan
}
// ---------- Endpoints ----------
const crearPlanSchema = z.object({
ventaRef: z.string().optional(),
clienteNombre: z.string().min(1),
clienteTelefono: z.string().optional(),
unidadCodigo: z.string().min(1),
proyecto: z.string().min(1),
fechaInicio: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'fechaInicio debe ser YYYY-MM-DD'),
engancheTotal: z.number().positive(),
numPagos: z.number().int().min(1),
tasaMoraMensual: z.number().min(0).optional(),
})
/** POST /api/planes — genera el calendario de pagos y lo persiste */
export async function crearPlan(req: Request, res: Response): Promise<void> {
const body = crearPlanSchema.parse(req.body)
let calendario
try {
calendario = generarCalendario(body.fechaInicio, body.engancheTotal, body.numPagos)
} catch (err) {
throw new BadRequestError(err instanceof Error ? err.message : 'Parámetros inválidos')
}
const plan = await prisma.planPago.create({
data: {
ventaRef: body.ventaRef ?? null,
clienteNombre: body.clienteNombre,
clienteTelefono: body.clienteTelefono ?? null,
unidadCodigo: body.unidadCodigo,
proyecto: body.proyecto,
fechaInicio: parseFechaLocal(body.fechaInicio),
engancheTotal: body.engancheTotal,
numPagos: body.numPagos,
mensualidad: calendario.mensualidad,
tasaMoraMensual: body.tasaMoraMensual ?? 0,
pagos: {
create: calendario.pagos.map((p) => ({
numPago: p.numPago,
fechaVencimiento: parseFechaLocal(p.fechaVencimiento),
monto: p.monto,
})),
},
},
include: includeCompleto,
})
res.status(201).json(conCalculados(aDominio(plan)))
}
/** GET /api/planes — con saldo/status calculados */
export async function listarPlanes(_req: Request, res: Response): Promise<void> {
const planes = await prisma.planPago.findMany({
include: includeCompleto,
orderBy: { createdAt: 'desc' },
})
res.json(planes.map((p) => conCalculados(aDominio(p))))
}
/** GET /api/planes/cobranza-del-dia — lista plana de urgencias (antes de /:id) */
export async function cobranzaHoy(req: Request, res: Response): Promise<void> {
const dias = Number(req.query['diasAnticipacion'] ?? 7)
const planes = await prisma.planPago.findMany({ include: includeCompleto })
const items = cobranzaDelDia(
planes.map(aDominio),
new Date(),
Number.isFinite(dias) ? dias : 7,
)
res.json(items)
}
/** GET /api/planes/:id */
export async function obtenerPlan(req: Request, res: Response): Promise<void> {
const plan = await cargarPlan(String(req.params['id']))
res.json(conCalculados(aDominio(plan)))
}
const abonoSchema = z.object({
pagoId: z.string().min(1),
fechaPago: z.string().min(1),
monto: z.number(),
referencia: z.string().min(1),
})
/** POST /api/planes/:id/abonos — aplica un abono (estricto al centavo) */
export async function registrarAbonoEndpoint(req: Request, res: Response): Promise<void> {
const body = abonoSchema.parse(req.body)
const plan = await cargarPlan(String(req.params['id']))
const dominio = aDominio(plan)
let resultado
try {
resultado = validarAbono(dominio, body)
} catch (err) {
throw new BadRequestError(err instanceof Error ? err.message : 'Abono inválido')
}
const { pago, aplicacion } = resultado
await prisma.$transaction([
prisma.aplicacionPago.create({
data: {
pagoId: pago.id,
fechaPago: new Date(body.fechaPago),
monto: aplicacion.monto,
referencia: aplicacion.referencia,
},
}),
prisma.pago.update({
where: { id: pago.id },
data: { montoPagado: round2(pago.montoPagado + aplicacion.monto) },
}),
])
const actualizado = await cargarPlan(plan.id)
res.status(201).json(conCalculados(aDominio(actualizado)))
}
const notaSchema = z.object({ texto: z.string() })
/** POST /api/planes/:id/notas */
export async function agregarNota(req: Request, res: Response): Promise<void> {
const { texto } = notaSchema.parse(req.body)
const plan = await cargarPlan(String(req.params['id']))
let nota
try {
nota = construirNota(texto)
} catch (err) {
throw new BadRequestError(err instanceof Error ? err.message : 'Nota inválida')
}
const creada = await prisma.notaPlan.create({
data: { planId: plan.id, texto: nota.texto },
})
res.status(201).json(creada)
}
/** POST /api/planes/:id/cancelar */
export async function cancelarPlan(req: Request, res: Response): Promise<void> {
const plan = await cargarPlan(String(req.params['id']))
await prisma.planPago.update({ where: { id: plan.id }, data: { status: 'CANCELADO' } })
const actualizado = await cargarPlan(plan.id)
res.json(conCalculados(aDominio(actualizado)))
}

View File

@@ -0,0 +1,17 @@
import type { Request, Response } from 'express'
import { mondayService } from '../services/monday.service.js'
import { NotFoundError } from '../utils/errors.js'
/** GET /api/unidades — ?disponibles=true filtra solo DISPONIBLE */
export async function listarUnidades(req: Request, res: Response): Promise<void> {
const unidades = await mondayService.getUnidades()
const disponibles = req.query['disponibles'] === 'true'
res.json(disponibles ? unidades.filter((u) => u.status === 'DISPONIBLE') : unidades)
}
/** GET /api/unidades/:codigo — 404 si no existe */
export async function obtenerUnidad(req: Request, res: Response): Promise<void> {
const unidad = await mondayService.getUnidad(String(req.params['codigo']))
if (!unidad) throw new NotFoundError(`Unidad ${String(req.params['codigo'])} no encontrada`)
res.json(unidad)
}

View File

@@ -0,0 +1,36 @@
import type { Request, Response } from 'express'
import { z } from 'zod'
const eventoSchema = z
.object({
type: z.string().optional(),
boardId: z.union([z.string(), z.number()]).optional(),
itemId: z.union([z.string(), z.number()]).optional(),
})
.passthrough()
/**
* POST /api/webhooks/monday
* Handshake: Monday envía { challenge } al registrar el webhook y exige que
* se responda tal cual. Para eventos reales solo se loggea y se responde 200.
*
* OJO: Monday NO firma sus webhooks → el payload no es confiable. Antes de
* actuar sobre un evento hay que re-consultar el estado vía API (por ahora
* solo se registra el evento).
*/
export function webhookMonday(req: Request, res: Response): void {
const body = req.body as Record<string, unknown>
if (typeof body?.['challenge'] === 'string') {
res.json({ challenge: body['challenge'] })
return
}
const parsed = eventoSchema.safeParse(body)
if (parsed.success) {
const { type, boardId, itemId } = parsed.data
// eslint-disable-next-line no-console
console.log(`[webhook monday] tipo=${type ?? 'desconocido'} board=${boardId ?? '?'} item=${itemId ?? '?'}`)
}
res.sendStatus(200)
}

9
api-gateway/src/index.ts Normal file
View File

@@ -0,0 +1,9 @@
import { crearApp } from './app.js'
import { config } from './config/index.js'
const app = crearApp()
app.listen(config.port, () => {
// eslint-disable-next-line no-console
console.log(`API Gateway FRASA escuchando en http://localhost:${config.port} (${config.nodeEnv})`)
})

View File

@@ -0,0 +1,23 @@
import type { NextFunction, Request, Response } from 'express'
import { ZodError } from 'zod'
import { MulterError } from 'multer'
import { AppError } from '../utils/errors.js'
/** Error handler central: zod → 400 con issues, AppError → su status, resto → 500 */
export function errorHandler(err: unknown, req: Request, res: Response, _next: NextFunction): void {
if (err instanceof ZodError) {
res.status(400).json({ error: 'Validación fallida', issues: err.issues })
return
}
if (err instanceof MulterError) {
res.status(400).json({ error: `Error de carga de archivos: ${err.message}` })
return
}
if (err instanceof AppError) {
res.status(err.status).json({ error: err.message })
return
}
// eslint-disable-next-line no-console
console.error(`[500] ${req.method} ${req.originalUrl}:`, err)
res.status(500).json({ error: 'Error interno del servidor' })
}

View File

@@ -0,0 +1,11 @@
import type { NextFunction, Request, Response } from 'express'
/** Logger de requests simple: método, ruta, status y duración */
export function requestLogger(req: Request, res: Response, next: NextFunction): void {
const inicio = Date.now()
res.on('finish', () => {
// eslint-disable-next-line no-console
console.log(`${req.method} ${req.originalUrl} ${res.statusCode} ${Date.now() - inicio}ms`)
})
next()
}

View File

@@ -0,0 +1,51 @@
import { Router } from 'express'
import { health } from '../controllers/health.controller.js'
import { listarUnidades, obtenerUnidad } from '../controllers/unidades.controller.js'
import {
crearCotizacion,
listarCotizaciones,
obtenerCotizacion,
} from '../controllers/cotizaciones.controller.js'
import {
crearCliente,
listarDocumentos,
obtenerCliente,
subirDocumentos,
uploadDocumentos,
} from '../controllers/clientes.controller.js'
import {
agregarNota,
cancelarPlan,
cobranzaHoy,
crearPlan,
listarPlanes,
obtenerPlan,
registrarAbonoEndpoint,
} from '../controllers/planes.controller.js'
import { webhookMonday } from '../controllers/webhooks.controller.js'
export const apiRouter = Router()
apiRouter.get('/health', health)
apiRouter.get('/unidades', listarUnidades)
apiRouter.get('/unidades/:codigo', obtenerUnidad)
apiRouter.post('/cotizaciones', crearCotizacion)
apiRouter.get('/cotizaciones', listarCotizaciones)
apiRouter.get('/cotizaciones/:id', obtenerCotizacion)
apiRouter.post('/clientes', crearCliente)
apiRouter.get('/clientes/:id', obtenerCliente)
apiRouter.post('/clientes/:id/documentos', uploadDocumentos.array('archivos', 10), subirDocumentos)
apiRouter.get('/clientes/:id/documentos', listarDocumentos)
apiRouter.post('/planes', crearPlan)
apiRouter.get('/planes', listarPlanes)
apiRouter.get('/planes/cobranza-del-dia', cobranzaHoy)
apiRouter.get('/planes/:id', obtenerPlan)
apiRouter.post('/planes/:id/abonos', registrarAbonoEndpoint)
apiRouter.post('/planes/:id/notas', agregarNota)
apiRouter.post('/planes/:id/cancelar', cancelarPlan)
apiRouter.post('/webhooks/monday', webhookMonday)

View File

@@ -0,0 +1,310 @@
import { describe, expect, it } from 'vitest'
import {
alCorriente,
avancePlan,
cobranzaDelDia,
construirNota,
diasAtraso,
generarCalendario,
moraDePago,
pagadoTotal,
proximoVencimiento,
round2,
saldoPlan,
statusPago,
statusPlan,
sumarMeses,
totalMora,
validarAbono,
type AbonoInput,
type Pago,
type PlanPago,
} from './cobranza.service.js'
const pagoBase = (override: Partial<Pago> = {}): Pago => ({
id: 'p1',
numPago: 1,
fechaVencimiento: '2026-02-15',
monto: 10000,
montoPagado: 0,
aplicaciones: [],
...override,
})
/**
* Helper de test: valida con `validarAbono` (la función real del servicio) y
* devuelve una copia del plan con el abono aplicado — el equivalente en
* memoria de lo que el controller persiste en Prisma.
*/
function aplicarAbono(plan: PlanPago, abono: AbonoInput): PlanPago {
const { pago, aplicacion } = validarAbono(plan, abono)
return {
...plan,
pagos: plan.pagos.map((p) =>
p.id === pago.id
? { ...p, montoPagado: round2(p.montoPagado + aplicacion.monto), aplicaciones: [...p.aplicaciones, aplicacion] }
: p,
),
}
}
function planDePrueba(): PlanPago {
const { mensualidad, pagos } = generarCalendario('2026-01-10', 30000, 3)
return {
id: 'plan-1',
ventaRef: 'VTA-001',
clienteNombre: 'Cliente Prueba',
unidadCodigo: 'U-1',
proyecto: 'SENDA',
fechaInicio: '2026-01-10',
engancheTotal: 30000,
numPagos: 3,
mensualidad,
status: 'ACTIVO',
pagos,
notas: [],
createdAt: '2026-01-01T00:00:00.000Z',
}
}
describe('generarCalendario', () => {
it('genera 12 pagos cuya suma es exactamente el enganche', () => {
const { mensualidad, pagos } = generarCalendario('2026-01-15', 180000, 12)
expect(pagos).toHaveLength(12)
expect(mensualidad).toBe(15000)
expect(round2(pagos.reduce((acc, p) => acc + p.monto, 0))).toBe(180000)
expect(pagos[0]?.numPago).toBe(1)
expect(pagos[11]?.numPago).toBe(12)
expect(pagos[0]?.fechaVencimiento).toBe('2026-01-15')
expect(pagos[11]?.fechaVencimiento).toBe('2026-12-15')
})
it('el último pago absorbe el residuo del redondeo (100000 / 3)', () => {
const { mensualidad, pagos } = generarCalendario('2026-03-01', 100000, 3)
expect(mensualidad).toBe(33333.33)
expect(pagos.map((p) => p.monto)).toEqual([33333.33, 33333.33, 33333.34])
expect(round2(pagos.reduce((acc, p) => acc + p.monto, 0))).toBe(100000)
})
it('ajusta el día cuando el mes no lo tiene (31 de enero → 28 de febrero)', () => {
const { pagos } = generarCalendario('2025-01-31', 30000, 3)
expect(pagos[0]?.fechaVencimiento).toBe('2025-01-31')
expect(pagos[1]?.fechaVencimiento).toBe('2025-02-28')
expect(pagos[2]?.fechaVencimiento).toBe('2025-03-31')
})
it('respeta año bisiesto (31 de enero 2024 → 29 de febrero)', () => {
expect(sumarMeses(new Date(2024, 0, 31), 1)).toEqual(new Date(2024, 1, 29))
})
it('rechaza parámetros inválidos', () => {
expect(() => generarCalendario('2026-01-01', 0, 6)).toThrow()
expect(() => generarCalendario('2026-01-01', 1000, 0)).toThrow()
})
})
describe('statusPago', () => {
const hoy = new Date('2026-02-16T00:00:00')
it('PAGADO cuando montoPagado cubre el monto', () => {
expect(statusPago(pagoBase({ montoPagado: 10000 }), hoy)).toBe('PAGADO')
})
it('PARCIAL con abono menor al monto, aunque ya venció', () => {
expect(statusPago(pagoBase({ montoPagado: 4000 }), hoy)).toBe('PARCIAL')
})
it('VENCIDO sin abono y hoy posterior al vencimiento', () => {
expect(statusPago(pagoBase(), hoy)).toBe('VENCIDO')
})
it('PENDIENTE sin abono y hoy en o antes del vencimiento', () => {
expect(statusPago(pagoBase(), new Date('2026-02-15T00:00:00'))).toBe('PENDIENTE')
expect(statusPago(pagoBase(), new Date('2026-02-01T00:00:00'))).toBe('PENDIENTE')
})
})
describe('validarAbono', () => {
it('aplica un abono parcial y lo agrega al historial', () => {
const plan = planDePrueba()
const actualizado = aplicarAbono(plan, {
pagoId: plan.pagos[0]!.id,
fechaPago: '2026-01-10',
monto: 4000,
referencia: 'SPEI-123',
})
expect(actualizado.pagos[0]?.montoPagado).toBe(4000)
expect(actualizado.pagos[0]?.aplicaciones).toHaveLength(1)
expect(actualizado.pagos[0]?.aplicaciones[0]?.referencia).toBe('SPEI-123')
expect(plan.pagos[0]?.montoPagado).toBe(0) // no muta el original
})
it('permite completar un pago con varios abonos', () => {
let plan = planDePrueba()
plan = aplicarAbono(plan, { pagoId: plan.pagos[0]!.id, fechaPago: '2026-01-10', monto: 4000, referencia: 'A1' })
plan = aplicarAbono(plan, { pagoId: plan.pagos[0]!.id, fechaPago: '2026-01-11', monto: 6000, referencia: 'A2' })
expect(plan.pagos[0]?.montoPagado).toBe(10000)
expect(plan.pagos[0]?.aplicaciones).toHaveLength(2)
expect(statusPago(plan.pagos[0]!, new Date('2026-01-12T00:00:00'))).toBe('PAGADO')
})
it('rechaza abonos que exceden el restante (estricto al centavo)', () => {
const plan = planDePrueba()
expect(() =>
validarAbono(plan, { pagoId: plan.pagos[0]!.id, fechaPago: '2026-01-10', monto: 10000.01, referencia: 'X1' }),
).toThrow(/excede el restante/)
})
it('rechaza abonos menores o iguales a 0', () => {
const plan = planDePrueba()
expect(() =>
validarAbono(plan, { pagoId: plan.pagos[0]!.id, fechaPago: '2026-01-10', monto: 0, referencia: 'X1' }),
).toThrow(/mayor a 0/)
expect(() =>
validarAbono(plan, { pagoId: plan.pagos[0]!.id, fechaPago: '2026-01-10', monto: -500, referencia: 'X1' }),
).toThrow(/mayor a 0/)
})
it('rechaza un pagoId inexistente', () => {
const plan = planDePrueba()
expect(() =>
validarAbono(plan, { pagoId: 'no-existe', fechaPago: '2026-01-10', monto: 100, referencia: 'X1' }),
).toThrow(/no encontrado/)
})
})
describe('saldo, avance y status del plan', () => {
it('saldoPlan y pagadoTotal reflejan los abonos', () => {
let plan = planDePrueba()
expect(saldoPlan(plan)).toBe(30000)
expect(pagadoTotal(plan)).toBe(0)
plan = aplicarAbono(plan, { pagoId: plan.pagos[0]!.id, fechaPago: '2026-01-10', monto: 10000, referencia: 'A1' })
expect(saldoPlan(plan)).toBe(20000)
expect(pagadoTotal(plan)).toBe(10000)
expect(avancePlan(plan)).toBeCloseTo(1 / 3, 5)
})
it('el plan queda LIQUIDADO al cubrir todo el enganche', () => {
let plan = planDePrueba()
for (const pago of plan.pagos) {
plan = aplicarAbono(plan, {
pagoId: pago.id,
fechaPago: '2026-03-10',
monto: pago.monto - pago.montoPagado,
referencia: `LIQ-${pago.numPago}`,
})
}
expect(saldoPlan(plan)).toBe(0)
expect(statusPlan(plan)).toBe('LIQUIDADO')
})
it('CANCELADO prevalece aunque tenga saldo', () => {
const plan = { ...planDePrueba(), status: 'CANCELADO' as const }
expect(statusPlan(plan)).toBe('CANCELADO')
})
it('alCorriente es falso cuando hay pagos vencidos', () => {
expect(alCorriente(planDePrueba(), new Date('2026-02-15T00:00:00'))).toBe(false)
expect(alCorriente(planDePrueba(), new Date('2026-01-05T00:00:00'))).toBe(true)
})
})
describe('proximoVencimiento y diasAtraso', () => {
it('devuelve el primer pago no cubierto con fecha >= hoy', () => {
const prox = proximoVencimiento(planDePrueba(), new Date('2026-01-20T00:00:00'))
expect(prox?.numPago).toBe(2)
})
it('si no hay futuros, devuelve el vencido más antiguo', () => {
const prox = proximoVencimiento(planDePrueba(), new Date('2026-04-01T00:00:00'))
expect(prox?.numPago).toBe(1)
})
it('devuelve null cuando todo está cubierto', () => {
let plan = planDePrueba()
for (const pago of plan.pagos) {
plan = aplicarAbono(plan, { pagoId: pago.id, fechaPago: '2026-01-10', monto: pago.monto, referencia: 'X' })
}
expect(proximoVencimiento(plan, new Date('2026-01-15T00:00:00'))).toBeNull()
})
it('diasAtraso cuenta desde el vencido más antiguo', () => {
expect(diasAtraso(planDePrueba(), new Date('2026-01-25T00:00:00'))).toBe(15)
expect(diasAtraso(planDePrueba(), new Date('2026-01-10T00:00:00'))).toBe(0)
})
it('un pago PARCIAL vencido no cuenta como atraso', () => {
let plan = planDePrueba()
plan = aplicarAbono(plan, { pagoId: plan.pagos[0]!.id, fechaPago: '2026-01-10', monto: 100, referencia: 'A' })
expect(diasAtraso(plan, new Date('2026-02-01T00:00:00'))).toBe(0)
})
})
describe('moraDePago y totalMora', () => {
const hoy = new Date('2026-03-17T00:00:00') // pago de 10000 vencido el 15-feb → 30 días
it('es 0 cuando la tasa es 0, cuando no está vencido o cuando ya está cubierto', () => {
expect(moraDePago(pagoBase(), 0, hoy)).toBe(0)
expect(moraDePago(pagoBase(), 2, new Date('2026-02-15T00:00:00'))).toBe(0)
expect(moraDePago(pagoBase({ montoPagado: 10000 }), 2, hoy)).toBe(0)
})
it('cálculo exacto: restante × (tasa/100) / 30 × días vencidos', () => {
expect(moraDePago(pagoBase(), 2, hoy)).toBe(200) // 10000 × 0.02 / 30 × 30
expect(moraDePago(pagoBase(), 3, new Date('2026-02-25T00:00:00'))).toBe(100) // × 0.03 / 30 × 10
})
it('un pago PARCIAL vencido genera mora sobre su restante', () => {
expect(moraDePago(pagoBase({ montoPagado: 4000 }), 2, hoy)).toBe(120)
})
it('totalMora suma la mora de todos los pagos y usa tasa 0 por defecto', () => {
const plan = planDePrueba() // vence 10-ene, 10-feb, 10-mar; hoy 20-mar
const hoyPlan = new Date('2026-03-20T00:00:00')
expect(totalMora(plan, hoyPlan)).toBe(0)
const conTasa: PlanPago = { ...plan, tasaMoraMensual: 3 }
expect(totalMora(conTasa, hoyPlan)).toBe(1170) // 690 + 380 + 100
})
})
describe('cobranzaDelDia', () => {
it('incluye vencidos y los que vencen en los próximos 7 días, ordenados por urgencia', () => {
const hoy = new Date('2026-03-05T00:00:00')
const items = cobranzaDelDia([planDePrueba()], hoy)
expect(items.map((i) => i.pago.numPago)).toEqual([1, 2, 3])
expect(items.map((i) => i.atraso)).toEqual([54, 23, 0])
})
it('excluye pagos cubiertos, planes no activos y vencimientos lejanos', () => {
const hoy = new Date('2026-01-05T00:00:00')
let plan = planDePrueba()
plan = aplicarAbono(plan, { pagoId: plan.pagos[0]!.id, fechaPago: '2026-01-05', monto: 10000, referencia: 'A' })
const cancelado: PlanPago = { ...planDePrueba(), status: 'CANCELADO' }
expect(cobranzaDelDia([plan, cancelado], hoy)).toHaveLength(0)
const items40 = cobranzaDelDia([plan, cancelado], hoy, 40)
expect(items40.map((i) => i.pago.numPago)).toEqual([2])
})
it('un PARCIAL vencido aparece con sus días de atraso', () => {
const hoy = new Date('2026-02-01T00:00:00')
let plan = planDePrueba()
plan = aplicarAbono(plan, { pagoId: plan.pagos[0]!.id, fechaPago: '2026-01-10', monto: 4000, referencia: 'A' })
const items = cobranzaDelDia([plan], hoy)
expect(items[0]?.pago.numPago).toBe(1)
expect(items[0]?.atraso).toBe(22)
})
})
describe('construirNota', () => {
it('construye la nota con texto limpio, id y fecha', () => {
const nota = construirNota(' Cliente promete pago el viernes ')
expect(nota.texto).toBe('Cliente promete pago el viernes')
expect(nota.id).toBeTruthy()
expect(nota.fecha).toBeTruthy()
})
it('rechaza notas de menos de 3 caracteres', () => {
expect(() => construirNota(' ok ')).toThrow(/al menos 3/)
expect(() => construirNota('')).toThrow(/al menos 3/)
})
})

View File

@@ -0,0 +1,316 @@
import { randomUUID } from 'node:crypto'
/**
* Lógica de cobranza espejo de apps/cobranza/src/lib/cobranza.ts.
* Son funciones puras: los controllers mapean los registros de Prisma a
* estas formas, ejecutan la lógica y persisten los cambios.
*/
export type StatusPago = 'PENDIENTE' | 'PAGADO' | 'VENCIDO' | 'PARCIAL'
export type StatusPlan = 'ACTIVO' | 'LIQUIDADO' | 'CANCELADO'
export interface AplicacionPago {
id: string
fechaPago: string // ISO
monto: number
referencia: string
}
export interface Pago {
id: string
numPago: number
fechaVencimiento: string // 'YYYY-MM-DD'
monto: number
montoPagado: number
aplicaciones: AplicacionPago[]
}
export interface NotaPlan {
id: string
fecha: string // ISO
texto: string
}
export interface PlanPago {
id: string
ventaRef?: string | null
clienteNombre: string
clienteTelefono?: string | null
unidadCodigo: string
proyecto: string
fechaInicio: string // 'YYYY-MM-DD'
engancheTotal: number
numPagos: number
mensualidad: number
tasaMoraMensual?: number
status: StatusPlan
pagos: Pago[]
notas: NotaPlan[]
createdAt: string // ISO
}
/** Redondeo monetario a 2 decimales */
export const round2 = (x: number): number => Math.round(x * 100) / 100
/** Tolerancia para comparaciones de dinero (centavos) */
export const EPS = 0.01
const gte = (a: number, b: number): boolean => a >= b - EPS
/** Parsea 'YYYY-MM-DD' como fecha LOCAL (nunca `new Date(iso)` a secas, que interpreta UTC) */
export function parseFechaLocal(iso: string): Date {
return new Date(`${iso}T00:00:00`)
}
function aISO(fecha: Date): string {
const y = fecha.getFullYear()
const m = String(fecha.getMonth() + 1).padStart(2, '0')
const d = String(fecha.getDate()).padStart(2, '0')
return `${y}-${m}-${d}`
}
/** Último día del mes (mes 0-based) */
function ultimoDiaDelMes(anio: number, mes: number): number {
return new Date(anio, mes + 1, 0).getDate()
}
/**
* Suma `meses` conservando el día del mes; si el mes destino no tiene ese día,
* usa su último día (p. ej. 31 de enero + 1 mes → 28/29 de febrero).
*/
export function sumarMeses(fecha: Date, meses: number): Date {
const totalMeses = fecha.getMonth() + meses
const anio = fecha.getFullYear() + Math.floor(totalMeses / 12)
const mes = ((totalMeses % 12) + 12) % 12
const dia = Math.min(fecha.getDate(), ultimoDiaDelMes(anio, mes))
return new Date(anio, mes, dia)
}
/** Compara solo la parte de fecha (ignora horas) */
function compararFechas(a: Date, b: Date): number {
const da = new Date(a.getFullYear(), a.getMonth(), a.getDate()).getTime()
const db = new Date(b.getFullYear(), b.getMonth(), b.getDate()).getTime()
return da - db
}
export interface CalendarioGenerado {
mensualidad: number
pagos: Pago[]
}
/**
* Genera N pagos con vencimiento mensual a partir de `fechaInicio`.
* mensualidad = round2(engancheTotal / numPagos); el ÚLTIMO pago absorbe
* el residuo del redondeo para que la suma sea exactamente `engancheTotal`.
*/
export function generarCalendario(
fechaInicio: string,
engancheTotal: number,
numPagos: number,
): CalendarioGenerado {
if (numPagos < 1) throw new Error('numPagos debe ser al menos 1')
if (engancheTotal <= 0) throw new Error('engancheTotal debe ser mayor a 0')
const inicio = parseFechaLocal(fechaInicio)
const mensualidad = round2(engancheTotal / numPagos)
const pagos: Pago[] = []
for (let i = 0; i < numPagos; i++) {
const monto =
i < numPagos - 1 ? mensualidad : round2(engancheTotal - mensualidad * (numPagos - 1))
pagos.push({
id: randomUUID(),
numPago: i + 1,
fechaVencimiento: aISO(sumarMeses(inicio, i)),
monto,
montoPagado: 0,
aplicaciones: [],
})
}
return { mensualidad, pagos }
}
/**
* Estado de un pago a la fecha `hoy`.
* PAGADO si cubrió el monto; PARCIAL si tiene abonos sin cubrir; sin abonos:
* VENCIDO si hoy > fechaVencimiento, si no PENDIENTE.
* Decisión de negocio: un pago PARCIAL cuya fecha ya venció sigue siendo
* PARCIAL (tiene abono aplicado); el atraso se señala vía `diasAtraso`.
*/
export function statusPago(pago: Pago, hoy: Date = new Date()): StatusPago {
if (gte(pago.montoPagado, pago.monto)) return 'PAGADO'
if (pago.montoPagado > EPS) return 'PARCIAL'
return compararFechas(hoy, parseFechaLocal(pago.fechaVencimiento)) > 0 ? 'VENCIDO' : 'PENDIENTE'
}
/** Saldo pendiente del plan: suma(monto - montoPagado) */
export function saldoPlan(plan: PlanPago): number {
return round2(plan.pagos.reduce((acc, p) => acc + (p.monto - p.montoPagado), 0))
}
/** Total cobrado (suma de lo aplicado a todos los pagos) */
export function pagadoTotal(plan: PlanPago): number {
return round2(plan.pagos.reduce((acc, p) => acc + p.montoPagado, 0))
}
/** Fracción de avance 0..1 (pagado / engancheTotal) */
export function avancePlan(plan: PlanPago): number {
return plan.engancheTotal > 0 ? pagadoTotal(plan) / plan.engancheTotal : 0
}
const restante = (p: Pago): number => round2(p.monto - p.montoPagado)
const estaCubierto = (p: Pago): boolean => gte(p.montoPagado, p.monto)
/**
* Primer pago no cubierto con fecha >= hoy; si no hay, el vencido más antiguo.
* Devuelve null si el plan está cubierto por completo.
*/
export function proximoVencimiento(plan: PlanPago, hoy: Date = new Date()): Pago | null {
const noCubiertos = plan.pagos.filter((p) => !estaCubierto(p))
const futuro = noCubiertos.find((p) => compararFechas(parseFechaLocal(p.fechaVencimiento), hoy) >= 0)
if (futuro) return futuro
const vencidos = noCubiertos.sort(
(a, b) => parseFechaLocal(a.fechaVencimiento).getTime() - parseFechaLocal(b.fechaVencimiento).getTime(),
)
return vencidos[0] ?? null
}
/** Días naturales hasta el próximo vencimiento; null si el plan está cubierto */
export function diasParaProximoVencimiento(plan: PlanPago, hoy: Date = new Date()): number | null {
const prox = proximoVencimiento(plan, hoy)
if (!prox) return null
return Math.round(compararFechas(parseFechaLocal(prox.fechaVencimiento), hoy) / 86400000)
}
/** Días de atraso del pago vencido más antiguo (0 si no hay vencidos sin abono) */
export function diasAtraso(plan: PlanPago, hoy: Date = new Date()): number {
const vencidos = plan.pagos
.filter((p) => statusPago(p, hoy) === 'VENCIDO')
.sort(
(a, b) =>
parseFechaLocal(a.fechaVencimiento).getTime() - parseFechaLocal(b.fechaVencimiento).getTime(),
)
const primero = vencidos[0]
if (!primero) return 0
return Math.round(compararFechas(hoy, parseFechaLocal(primero.fechaVencimiento)) / 86400000)
}
/** Status derivado: CANCELADO solo manual; LIQUIDADO si saldo == 0; si no, ACTIVO */
export function statusPlan(plan: PlanPago): StatusPlan {
if (plan.status === 'CANCELADO') return 'CANCELADO'
return saldoPlan(plan) <= EPS ? 'LIQUIDADO' : 'ACTIVO'
}
/** Al corriente = sin pagos VENCIDOS */
export function alCorriente(plan: PlanPago, hoy: Date = new Date()): boolean {
return !plan.pagos.some((p) => statusPago(p, hoy) === 'VENCIDO')
}
/**
* Mora simple de un pago: restante × (tasa% / 100) / 30 × días vencidos.
* Solo aplica a pagos vencidos con saldo; un PARCIAL vencido también genera
* mora sobre su restante. Tasa 0 (o negativa) = sin mora.
*/
export function moraDePago(pago: Pago, tasaMoraMensual: number, hoy: Date = new Date()): number {
if (tasaMoraMensual <= 0) return 0
const falta = restante(pago)
if (falta <= EPS) return 0
const diasVencido = Math.round(compararFechas(hoy, parseFechaLocal(pago.fechaVencimiento)) / 86400000)
if (diasVencido <= 0) return 0
return round2((falta * (tasaMoraMensual / 100) / 30) * diasVencido)
}
/** Mora acumulada del plan a la fecha `hoy` (usa plan.tasaMoraMensual ?? 0) */
export function totalMora(plan: PlanPago, hoy: Date = new Date()): number {
const tasa = plan.tasaMoraMensual ?? 0
return round2(plan.pagos.reduce((acc, p) => acc + moraDePago(p, tasa, hoy), 0))
}
export interface CobranzaItem {
plan: PlanPago
pago: Pago
/** Días de atraso (0 si aún no vence) */
atraso: number
}
/**
* Lista plana de "cobranza del día" a través de todos los planes ACTIVOS:
* pagos vencidos (incluye PARCIAL vencido) más los que vencen en los próximos
* `diasAnticipacion` días. Orden de urgencia: mayor atraso primero; a igual
* atraso, el vencimiento más próximo primero.
*/
export function cobranzaDelDia(
planes: PlanPago[],
hoy: Date = new Date(),
diasAnticipacion = 7,
): CobranzaItem[] {
const limite = new Date(hoy.getFullYear(), hoy.getMonth(), hoy.getDate() + diasAnticipacion)
const items: CobranzaItem[] = []
for (const plan of planes) {
if (statusPlan(plan) !== 'ACTIVO') continue
for (const pago of plan.pagos) {
if (estaCubierto(pago)) continue
if (compararFechas(parseFechaLocal(pago.fechaVencimiento), limite) > 0) continue
const atraso = Math.max(
0,
Math.round(compararFechas(hoy, parseFechaLocal(pago.fechaVencimiento)) / 86400000),
)
items.push({ plan, pago, atraso })
}
}
return items.sort((a, b) => {
if (a.atraso !== b.atraso) return b.atraso - a.atraso
return compararFechas(
parseFechaLocal(a.pago.fechaVencimiento),
parseFechaLocal(b.pago.fechaVencimiento),
)
})
}
/**
* Valida el texto de una nota y devuelve la nota construida.
* El texto es requerido (mín. 3 caracteres).
*/
export function construirNota(texto: string): NotaPlan {
const limpio = texto.trim()
if (limpio.length < 3) {
throw new Error('La nota debe tener al menos 3 caracteres')
}
return { id: randomUUID(), fecha: new Date().toISOString(), texto: limpio }
}
export interface AbonoInput {
pagoId: string
fechaPago: string // ISO
monto: number
referencia: string
}
/**
* Valida un abono contra el plan y devuelve la aplicación construida más el
* pago objetivo. Reglas (idénticas al frontend):
* - rechaza montos <= 0
* - el pago debe existir en el plan
* - estricto al centavo: el abono nunca puede exceder el restante del pago
*/
export function validarAbono(plan: PlanPago, abono: AbonoInput): { pago: Pago; aplicacion: AplicacionPago } {
if (round2(abono.monto) <= 0) {
throw new Error('El abono debe ser mayor a 0')
}
const pago = plan.pagos.find((p) => p.id === abono.pagoId)
if (!pago) throw new Error('Pago no encontrado en el plan')
const falta = restante(pago)
if (round2(abono.monto - falta) > 0) {
throw new Error(`El abono excede el restante del pago (${falta.toFixed(2)})`)
}
const aplicacion: AplicacionPago = {
id: randomUUID(),
fechaPago: abono.fechaPago,
monto: round2(abono.monto),
referencia: abono.referencia,
}
return { pago, aplicacion }
}
/** Restante de un pago (expuesto para los controllers) */
export function restantePago(p: Pago): number {
return restante(p)
}

View File

@@ -0,0 +1,141 @@
import { describe, it, expect } from 'vitest'
import {
APARTADO_DEFAULT,
calcularNumMensualidades,
calcularPropuesta,
calcularPropuestas,
indiceMenorTotal,
obtenerPrecioLista,
totalPagado,
type UnidadCotizable,
} from './cotizacion.service.js'
// Fixture validado contra el Excel real: U-101, con acabados, apartado 20000, N = 14
const U101: UnidadCotizable = {
codigo: 'U-101',
modelo: '1R',
fechaEntrega: '2027-10-08',
supEstacionamiento: 12.5,
supConstruccion: 69.485,
supExcedente: 0,
precioSinAcabados: 4415000,
precioConAcabados: 4579000,
status: 'DISPONIBLE',
desarrollo: 'SENDA',
}
const N = 14
function calcular(descuentos = { creditoBancario: 0, creditoInfonavit: 0, contado: 0.02, semiContado: 0.01 }) {
return calcularPropuestas({
unidad: U101,
acabados: true,
apartado: APARTADO_DEFAULT,
descuentos,
numMeses: N,
})
}
describe('fixture U-101 (validado contra Excel de producción)', () => {
it('precio de lista con acabados', () => {
expect(obtenerPrecioLista(U101, true)).toBe(4579000)
expect(obtenerPrecioLista(U101, false)).toBe(4415000)
})
it('Propuesta 1 — CRÉDITO BANCARIO (desc 0)', () => {
const [p] = calcular()
expect(p?.enganche).toBeCloseTo(94475, 2)
expect(p?.mensualidadesTotal).toBeCloseTo(343425, 2)
expect(p?.mensualidad).toBeCloseTo(24530.357142, 4)
expect(p?.pagoFinal).toBeCloseTo(4121100, 2)
})
it('Propuesta 2 — CRÉDITO INFONAVIT-COFINAVIT (desc 0)', () => {
const [, p] = calcular()
expect(p?.enganche).toBeCloseTo(94475, 2)
expect(p?.mensualidadesTotal).toBeCloseTo(114475, 2)
expect(p?.mensualidad).toBeCloseTo(8176.785714, 4)
expect(p?.pagoFinal).toBeCloseTo(4350050, 2)
})
it('Propuesta 3 — CONTADO (desc 2%)', () => {
const [, , p] = calcular()
expect(p?.montoDescuento).toBeCloseTo(91580, 2)
expect(p?.precioFinal).toBeCloseTo(4487420, 2)
expect(p?.enganche).toBeCloseTo(4018678, 2)
expect(p?.pagoFinal).toBeCloseTo(448742, 2)
expect(p?.mensualidad).toBeNull()
expect(p?.mensualidadesTotal).toBeNull()
})
it('Propuesta 4 — SEMI CONTADO (desc 1%)', () => {
const [, , , p] = calcular()
expect(p?.montoDescuento).toBeCloseTo(45790, 2)
expect(p?.precioFinal).toBeCloseTo(4533210, 2)
expect(p?.enganche).toBeCloseTo(2246605, 2)
expect(p?.mensualidadesTotal).toBeCloseTo(1813284, 2)
expect(p?.mensualidad).toBeCloseTo(129520.285714, 4)
expect(p?.pagoFinal).toBeCloseTo(453321, 2)
})
it('calcularPropuesta (una sola) reproduce CONTADO con descuento default', () => {
const p = calcularPropuesta(U101, 'CONTADO', true, APARTADO_DEFAULT, 0.02)
expect(p.montoDescuento).toBeCloseTo(91580, 2)
expect(p.precioFinal).toBeCloseTo(4487420, 2)
})
})
describe('comportamiento documentado del Excel', () => {
it('Bancario con descuento > 0: el enganche sigue calculándose sobre precio LISTA', () => {
const [p] = calcular({ creditoBancario: 0.05, creditoInfonavit: 0, contado: 0.02, semiContado: 0.01 })
expect(p?.enganche).toBeCloseTo(0.025 * 4579000 - APARTADO_DEFAULT, 2)
expect(p?.montoDescuento).toBeCloseTo(228950, 2)
expect(p?.precioFinal).toBeCloseTo(4350050, 2)
expect(p?.pagoFinal).toBeCloseTo(0.9 * 4350050, 2)
const total = 4350050 - APARTADO_DEFAULT - 94475 - 0.9 * 4350050
expect(p?.mensualidadesTotal).toBeCloseTo(total, 2)
})
it('Infonavit usa precioLista (no precioFinal) en el total de mensualidades', () => {
const [, p] = calcular({ creditoBancario: 0, creditoInfonavit: 0.05, contado: 0.02, semiContado: 0.01 })
const total = 4579000 - (p?.pagoFinal ?? 0) - APARTADO_DEFAULT - (p?.enganche ?? 0)
expect(p?.mensualidadesTotal).toBeCloseTo(total, 2)
})
})
describe('calcularNumMensualidades', () => {
it('calcula meses redondeados con 30.4 días por mes', () => {
const hoy = new Date('2026-08-20T00:00:00')
expect(calcularNumMensualidades('2027-10-08', hoy)).toBe(14)
})
it('nunca devuelve menos de 1', () => {
const hoy = new Date('2030-01-01T00:00:00')
expect(calcularNumMensualidades('2027-10-08', hoy)).toBe(1)
})
})
describe('totalPagado', () => {
it('bancario: coincide con el precio de lista (desc 0)', () => {
const [bancario] = calcular()
expect(totalPagado(bancario!, APARTADO_DEFAULT)).toBeCloseTo(4579000, 2)
})
it('contado y semi contado: coinciden con el precio final c/desc', () => {
const [, , contado, semi] = calcular()
expect(totalPagado(contado!, APARTADO_DEFAULT)).toBeCloseTo(4487420, 2)
expect(totalPagado(semi!, APARTADO_DEFAULT)).toBeCloseTo(4533210, 2)
})
})
describe('indiceMenorTotal', () => {
it('contado (2% desc) tiene el menor total pagado', () => {
const propuestas = calcular()
const totales = propuestas.map((p) => totalPagado(p, APARTADO_DEFAULT))
expect(indiceMenorTotal(totales)).toBe(2)
})
it('en empate gana el primero; lista vacía devuelve -1', () => {
expect(indiceMenorTotal([100, 100, 200])).toBe(0)
expect(indiceMenorTotal([])).toBe(-1)
})
})

View File

@@ -0,0 +1,245 @@
/**
* Cálculos replicados fielmente del Excel de producción "COTIZADOR" de
* Frasa Desarrollos (port de apps/cotizador/src/lib/calculations.ts).
* NO "corregir" las fórmulas: algunas usan precio de lista donde
* intuitivamente iría precio final; así está en el Excel y así debe quedar.
*/
export interface UnidadCotizable {
codigo: string
modelo: string
fechaEntrega: string // ISO 'YYYY-MM-DD' (terminación de obra)
supEstacionamiento: number
supConstruccion: number
supExcedente: number
precioSinAcabados: number
precioConAcabados: number
status: 'DISPONIBLE' | 'APARTADA' | 'VENDIDA'
desarrollo: string
}
export type TipoPropuesta =
| 'CREDITO_BANCARIO'
| 'CREDITO_INFONAVIT_COFINAVIT'
| 'CONTADO'
| 'SEMI_CONTADO'
export interface DescuentosPropuestas {
creditoBancario: number
creditoInfonavit: number
contado: number
semiContado: number
}
export const APARTADO_DEFAULT = 20000
// Descuentos por defecto de cada propuesta (fracción, no porcentaje)
export const DESCUENTOS_DEFAULT: DescuentosPropuestas = {
creditoBancario: 0,
creditoInfonavit: 0,
contado: 0.02,
semiContado: 0.01,
}
export interface PropuestaResultado {
tipo: TipoPropuesta
descuento: number // fracción (0.02 = 2%)
montoDescuento: number
precioFinal: number // precio lista - descuento
enganche: number // NETO del apartado (el apartado ya se pagó)
numMensualidades: number
mensualidad: number | null // null => N/A (Contado)
mensualidadesTotal: number | null // null => N/A (Contado)
pagoFinal: number
}
export interface CotizacionInput {
unidad: UnidadCotizable
acabados: boolean // true => incluye cocina y closet
apartado: number
descuentos: DescuentosPropuestas
/** Permite forzar N en tests; por defecto se calcula desde fechaEntrega */
numMeses?: number
}
const MS_POR_MES = 30.4 * 86400000
/** Meses (redondeados, mínimo 1) desde hoy hasta la terminación de obra */
export function calcularNumMensualidades(fechaEntrega: string, hoy: Date = new Date()): number {
const entrega = new Date(`${fechaEntrega}T00:00:00`)
return Math.max(1, Math.round((entrega.getTime() - hoy.getTime()) / MS_POR_MES))
}
function baseComun(precioLista: number, descuento: number) {
// Excel COTIZADOR: MONTO DE DESCUENTO y PRECIO FINAL C/DESC
const montoDescuento = precioLista * descuento
const precioFinal = precioLista - montoDescuento
return { montoDescuento, precioFinal }
}
/** PROPUESTA 1 — CRÉDITO BANCARIO */
function calcularBancario(precioLista: number, apartado: number, descuento: number, n: number): PropuestaResultado {
const { montoDescuento, precioFinal } = baseComun(precioLista, descuento)
// Excel COTIZADOR!M17 — enganche 2.5% sobre precio LISTA, neto del apartado
const enganche = 0.025 * precioLista - apartado
// Excel COTIZADOR!M19 — pago final 90% del precio final c/desc
const pagoFinal = 0.9 * precioFinal
// Excel COTIZADOR!M18 — remanente a mensualidades
const mensualidadesTotal = precioFinal - apartado - enganche - pagoFinal
return {
tipo: 'CREDITO_BANCARIO',
descuento,
montoDescuento,
precioFinal,
enganche,
numMensualidades: n,
mensualidad: mensualidadesTotal / n,
mensualidadesTotal,
pagoFinal,
}
}
/** PROPUESTA 2 — CRÉDITO INFONAVIT-COFINAVIT */
function calcularInfonavit(precioLista: number, apartado: number, descuento: number, n: number): PropuestaResultado {
const { montoDescuento, precioFinal } = baseComun(precioLista, descuento)
// Excel COTIZADOR!M26 — enganche 2.5% sobre precio LISTA, neto del apartado
const enganche = 0.025 * precioLista - apartado
// Excel COTIZADOR!M28 — pago final 95% del precio final c/desc
const pagoFinal = 0.95 * precioFinal
// Excel COTIZADOR!M27 — OJO: el Excel usa precioLista aquí, NO precioFinal. Replicar tal cual.
const mensualidadesTotal = precioLista - pagoFinal - apartado - enganche
return {
tipo: 'CREDITO_INFONAVIT_COFINAVIT',
descuento,
montoDescuento,
precioFinal,
enganche,
numMensualidades: n,
mensualidad: mensualidadesTotal / n,
mensualidadesTotal,
pagoFinal,
}
}
/** PROPUESTA 3 — CONTADO (sin mensualidades) */
function calcularContado(precioLista: number, apartado: number, descuento: number, n: number): PropuestaResultado {
const { montoDescuento, precioFinal } = baseComun(precioLista, descuento)
// Excel COTIZADOR!M36 — enganche 90% del precio final c/desc, neto del apartado
const enganche = 0.9 * precioFinal - apartado
// Excel COTIZADOR!M37 — pago final 10% del precio final c/desc
const pagoFinal = 0.1 * precioFinal
return {
tipo: 'CONTADO',
descuento,
montoDescuento,
precioFinal,
enganche,
numMensualidades: n,
mensualidad: null, // N/A
mensualidadesTotal: null, // N/A
pagoFinal,
}
}
/** PROPUESTA 4 — SEMI CONTADO */
function calcularSemiContado(precioLista: number, apartado: number, descuento: number, n: number): PropuestaResultado {
const { montoDescuento, precioFinal } = baseComun(precioLista, descuento)
// Excel COTIZADOR!M45 — enganche 50% del precio final c/desc, neto del apartado
const enganche = 0.5 * precioFinal - apartado
// Excel COTIZADOR!M46 — 40% del precio final a mensualidades (= 1 - 0.5 - 0.1)
const mensualidadesTotal = precioFinal * 0.4
// Excel COTIZADOR!M47 — pago final 10% del precio final c/desc
const pagoFinal = 0.1 * precioFinal
return {
tipo: 'SEMI_CONTADO',
descuento,
montoDescuento,
precioFinal,
enganche,
numMensualidades: n,
mensualidad: mensualidadesTotal / n,
mensualidadesTotal,
pagoFinal,
}
}
/** Precio de lista según si incluye cocina y closet (acabados) */
export function obtenerPrecioLista(unidad: UnidadCotizable, acabados: boolean): number {
return acabados ? unidad.precioConAcabados : unidad.precioSinAcabados
}
/**
* Total pagado por el cliente en una propuesta:
* apartado + enganche (neto del apartado) + mensualidades + pago final.
*/
export function totalPagado(p: PropuestaResultado, apartado: number): number {
return apartado + p.enganche + (p.mensualidadesTotal ?? 0) + p.pagoFinal
}
/** Índice del menor total pagado; en empate gana el primero. -1 si la lista está vacía */
export function indiceMenorTotal(totales: number[]): number {
if (totales.length === 0) return -1
let indice = 0
for (let i = 1; i < totales.length; i++) {
const actual = totales[i]
const mejor = totales[indice]
if (actual !== undefined && mejor !== undefined && actual < mejor) indice = i
}
return indice
}
/** Calcula las 4 propuestas comerciales */
export function calcularPropuestas(input: CotizacionInput): PropuestaResultado[] {
const { unidad, acabados, apartado, descuentos } = input
const precioLista = obtenerPrecioLista(unidad, acabados)
const n = input.numMeses ?? calcularNumMensualidades(unidad.fechaEntrega)
return [
calcularBancario(precioLista, apartado, descuentos.creditoBancario, n),
calcularInfonavit(precioLista, apartado, descuentos.creditoInfonavit, n),
calcularContado(precioLista, apartado, descuentos.contado, n),
calcularSemiContado(precioLista, apartado, descuentos.semiContado, n),
]
}
/** Mapa entre el nombre del API (body) y el nombre interno de la propuesta */
export const TIPO_PROPUESTA_API = {
BANCARIO: 'CREDITO_BANCARIO',
INFONAVIT: 'CREDITO_INFONAVIT_COFINAVIT',
CONTADO: 'CONTADO',
SEMI_CONTADO: 'SEMI_CONTADO',
} as const
export type TipoPropuestaApi = keyof typeof TIPO_PROPUESTA_API
const DESCUENTO_DEFAULT_POR_TIPO: Record<TipoPropuestaApi, number> = {
BANCARIO: DESCUENTOS_DEFAULT.creditoBancario,
INFONAVIT: DESCUENTOS_DEFAULT.creditoInfonavit,
CONTADO: DESCUENTOS_DEFAULT.contado,
SEMI_CONTADO: DESCUENTOS_DEFAULT.semiContado,
}
/**
* Calcula UNA propuesta (la elegida por el cliente) para persistir/cotizar.
* `descuento` opcional (fracción); si no viene, usa el default del tipo.
*/
export function calcularPropuesta(
unidad: UnidadCotizable,
tipo: TipoPropuestaApi,
acabados: boolean,
apartado: number = APARTADO_DEFAULT,
descuento?: number,
): PropuestaResultado {
const precioLista = obtenerPrecioLista(unidad, acabados)
const n = calcularNumMensualidades(unidad.fechaEntrega)
const d = descuento ?? DESCUENTO_DEFAULT_POR_TIPO[tipo]
switch (tipo) {
case 'BANCARIO':
return calcularBancario(precioLista, apartado, d, n)
case 'INFONAVIT':
return calcularInfonavit(precioLista, apartado, d, n)
case 'CONTADO':
return calcularContado(precioLista, apartado, d, n)
case 'SEMI_CONTADO':
return calcularSemiContado(precioLista, apartado, d, n)
}
}

View File

@@ -0,0 +1,235 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { MondayService } from './monday.service.js'
// Columnas con slugs autogenerados (como las reales de Monday); el servicio
// debe resolverlas por TÍTULO, nunca hardcodear el id.
const COLUMNAS_UNIDADES = [
{ id: 'text_mm6eaaa', title: 'Desarrollo', type: 'text' },
{ id: 'text_mm6ebbb', title: 'Modelo', type: 'text' },
{ id: 'date_mm6eccc', title: 'Fecha entrega', type: 'date' },
{ id: 'numeric_mm6eddd', title: 'Sup. construcción', type: 'numbers' },
{ id: 'numeric_mm6eeee', title: 'Sup. excedente', type: 'numbers' },
{ id: 'numeric_mm6efff', title: 'Sup. estacionamiento', type: 'numbers' },
{ id: 'numeric_mm6eggg', title: 'Precio sin acabados', type: 'numbers' },
{ id: 'numeric_mm6ehhh', title: 'Precio con acabados', type: 'numbers' },
{ id: 'color_mm6eiii', title: 'Disponibilidad', type: 'status' },
]
function itemUnidad(id: string, codigo: string, disponibilidad: string) {
return {
id,
name: codigo,
column_values: [
{ id: 'text_mm6eaaa', text: 'SENDA', value: null },
{ id: 'text_mm6ebbb', text: '1R', value: null },
{ id: 'date_mm6eccc', text: '2027-10-08', value: null },
{ id: 'numeric_mm6eddd', text: '69.485', value: null },
{ id: 'numeric_mm6eeee', text: '0', value: null },
{ id: 'numeric_mm6efff', text: '12.5', value: null },
{ id: 'numeric_mm6eggg', text: '4415000', value: null },
{ id: 'numeric_mm6ehhh', text: '4579000', value: null },
{ id: 'color_mm6eiii', text: disponibilidad, value: null },
],
}
}
const respuestaOk = (data: unknown): Response =>
new Response(JSON.stringify({ data }), { status: 200, headers: { 'Content-Type': 'application/json' } })
interface LlamadaFetch {
body: { query: string; variables: Record<string, unknown> }
}
/** Instala un fetch mockeado que responde según el query; devuelve las llamadas registradas */
function mockearFetch(handler: (body: LlamadaFetch['body']) => Response): LlamadaFetch[] {
const llamadas: LlamadaFetch[] = []
vi.stubGlobal('fetch', async (_url: unknown, init: { body: string }) => {
const body = JSON.parse(init.body) as LlamadaFetch['body']
llamadas.push({ body })
return handler(body)
})
return llamadas
}
const crearServicio = (): MondayService =>
new MondayService({ apiKey: 'test', apiUrl: 'https://api.test/v2', baseDelayMs: 1 })
describe('MondayService', () => {
beforeEach(() => vi.useRealTimers())
afterEach(() => vi.unstubAllGlobals())
it('resuelve column IDs por TÍTULO y mapea unidades al tipo del cotizador', async () => {
mockearFetch((body) => {
if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_UNIDADES }] })
return respuestaOk({
boards: [{ items_page: { cursor: null, items: [itemUnidad('1', 'U-101', 'DISPONIBLE')] } }],
})
})
const unidades = await crearServicio().getUnidades()
expect(unidades).toHaveLength(1)
expect(unidades[0]).toEqual({
codigo: 'U-101',
modelo: '1R',
fechaEntrega: '2027-10-08',
supConstruccion: 69.485,
supExcedente: 0,
supEstacionamiento: 12.5,
precioSinAcabados: 4415000,
precioConAcabados: 4579000,
status: 'DISPONIBLE',
desarrollo: 'SENDA',
})
})
it('excluye items placeholder sin datos (p. ej. "Task 1" default de Monday)', async () => {
const placeholder = {
id: '99',
name: 'Task 1',
column_values: COLUMNAS_UNIDADES.map((c) => ({ id: c.id, text: '', value: null })),
}
mockearFetch((body) => {
if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_UNIDADES }] })
return respuestaOk({
boards: [{ items_page: { cursor: null, items: [itemUnidad('1', 'U-101', 'DISPONIBLE'), placeholder] } }],
})
})
const unidades = await crearServicio().getUnidades()
expect(unidades.map((u) => u.codigo)).toEqual(['U-101'])
})
it('pagina con cursor hasta agotar las páginas', async () => {
const llamadas = mockearFetch((body) => {
if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_UNIDADES }] })
if (body.variables['cursor'] === 'pagina-2') {
return respuestaOk({
boards: [{ items_page: { cursor: null, items: [itemUnidad('2', 'U-102', 'APARTADA')] } }],
})
}
return respuestaOk({
boards: [{ items_page: { cursor: 'pagina-2', items: [itemUnidad('1', 'U-101', 'VENDIDA')] } }],
})
})
const unidades = await crearServicio().getUnidades()
expect(unidades.map((u) => u.codigo)).toEqual(['U-101', 'U-102'])
expect(unidades[0]?.status).toBe('VENDIDA')
expect(unidades[1]?.status).toBe('APARTADA')
// 1 llamada de columnas + 2 de items_page
expect(llamadas).toHaveLength(3)
expect(llamadas[2]?.body.variables['cursor']).toBe('pagina-2')
})
it('usa el cache de 5 minutos para no repetir llamadas', async () => {
const llamadas = mockearFetch((body) => {
if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_UNIDADES }] })
return respuestaOk({
boards: [{ items_page: { cursor: null, items: [itemUnidad('1', 'U-101', 'DISPONIBLE')] } }],
})
})
const svc = crearServicio()
await svc.getUnidades()
await svc.getUnidades()
expect(llamadas).toHaveLength(2) // columnas + una sola página de items
})
it('reintenta ante HTTP 429 con backoff y logra responder', async () => {
let intentos = 0
mockearFetch((body) => {
if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_UNIDADES }] })
intentos++
if (intentos === 1) return new Response('rate limited', { status: 429 })
return respuestaOk({
boards: [{ items_page: { cursor: null, items: [itemUnidad('1', 'U-101', 'DISPONIBLE')] } }],
})
})
const unidades = await crearServicio().getUnidades()
expect(unidades).toHaveLength(1)
expect(intentos).toBe(2)
})
it('reintenta ante error de complejidad (error_code COMPLEXITY_BUDGET_EXCEEDED)', async () => {
let intentos = 0
mockearFetch((body) => {
if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_UNIDADES }] })
intentos++
if (intentos < 3) {
return new Response(
JSON.stringify({ error_code: 'COMPLEXITY_BUDGET_EXCEEDED', error_message: 'Complexity budget exceeded' }),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
)
}
return respuestaOk({
boards: [{ items_page: { cursor: null, items: [itemUnidad('1', 'U-101', 'DISPONIBLE')] } }],
})
})
const unidades = await crearServicio().getUnidades()
expect(unidades).toHaveLength(1)
expect(intentos).toBe(3)
})
it('agota los reintentos y lanza error', async () => {
mockearFetch(() => new Response('rate limited', { status: 429 }))
await expect(crearServicio().getUnidades()).rejects.toThrow(/no respondió/)
})
it('lanza en errores GraphQL no retryables sin reintentar', async () => {
let llamadasTotal = 0
mockearFetch(() => {
llamadasTotal++
return new Response(JSON.stringify({ errors: [{ message: 'Invalid board ID' }] }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
})
await expect(crearServicio().getUnidades()).rejects.toThrow(/Invalid board ID/)
expect(llamadasTotal).toBe(1)
})
it('crearOportunidad envía column_values como JSON.stringify en variables', async () => {
const columnasOportunidades = [
{ id: 'text_u1', title: 'Unidad', type: 'text' },
{ id: 'text_e1', title: 'Cliente email', type: 'text' },
{ id: 'text_t1', title: 'Cliente teléfono', type: 'text' },
{ id: 'color_tp', title: 'Tipo propuesta', type: 'status' },
{ id: 'numeric_pl', title: 'Precio de lista', type: 'numbers' },
{ id: 'numeric_dp', title: 'Descuento %', type: 'numbers' },
{ id: 'numeric_pf', title: 'Precio final', type: 'numbers' },
{ id: 'date_fc', title: 'Fecha cotización', type: 'date' },
{ id: 'color_es', title: 'Estado', type: 'status' },
]
const llamadas = mockearFetch((body) => {
if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: columnasOportunidades }] })
return respuestaOk({ create_item: { id: '987654321' } })
})
const id = await crearServicio().crearOportunidad({
clienteNombre: 'Juan Pérez',
unidadCodigo: 'U-101',
tipoPropuestaLabel: 'BANCARIO',
precioLista: 4579000,
descuentoPct: 0,
precioFinal: 4579000,
fechaCotizacion: '2026-08-21',
estadoLabel: 'COTIZADA',
clienteEmail: 'juan@example.com',
})
expect(id).toBe('987654321')
const mutacion = llamadas.find((l) => l.body.query.includes('create_item'))
expect(mutacion).toBeDefined()
const columnValues = mutacion?.body.variables['columnValues']
expect(typeof columnValues).toBe('string') // JSON.stringify, nunca interpolado en el query
const parsed = JSON.parse(columnValues as string) as Record<string, unknown>
expect(parsed['text_u1']).toBe('U-101')
expect(parsed['text_e1']).toBe('juan@example.com')
expect(parsed['numeric_pl']).toBe(4579000)
expect(parsed['color_tp']).toEqual({ label: 'BANCARIO' })
expect(parsed['color_es']).toEqual({ label: 'COTIZADA' })
expect(parsed['date_fc']).toEqual({ date: '2026-08-21' })
expect(parsed['text_t1']).toBeUndefined() // no se envían columnas vacías
})
})

View File

@@ -0,0 +1,391 @@
import { config } from '../config/index.js'
import type { UnidadCotizable } from './cotizacion.service.js'
import { AppError } from '../utils/errors.js'
/** Unidad tal como la consume el API (y el frontend del cotizador) */
export type Unidad = UnidadCotizable
interface ColumnaMeta {
id: string
title: string
type: string
}
interface ColumnValue {
id: string
text: string | null
value: string | null
}
interface ItemMonday {
id: string
name: string
column_values: ColumnValue[]
}
interface GraphQLErrorItem {
message: string
extensions?: { code?: string }
}
interface GraphQLResponse<T> {
data?: T
errors?: GraphQLErrorItem[]
error_code?: string
error_message?: string
}
/** Error retryable de Monday (rate limit / presupuesto de complejidad / 5xx) */
class MondayRetryableError extends Error {}
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms))
const CACHE_UNIDADES_MS = 5 * 60 * 1000 // 5 minutos
export interface MondayServiceOptions {
apiKey?: string
apiUrl?: string
maxReintentos?: number
baseDelayMs?: number
}
export class MondayService {
private readonly apiKey: string
private readonly apiUrl: string
private readonly maxReintentos: number
private readonly baseDelayMs: number
// Cache de metadatos de columnas: boardId -> (título -> meta)
private readonly columnasCache = new Map<string, Map<string, ColumnaMeta>>()
// Cache de unidades + mapa codigo -> itemId (para escrituras)
private unidadesCache: { unidades: Unidad[]; itemIds: Map<string, string>; expira: number } | null = null
constructor(opts: MondayServiceOptions = {}) {
this.apiKey = opts.apiKey ?? config.monday.apiKey
this.apiUrl = opts.apiUrl ?? config.monday.apiUrl
this.maxReintentos = opts.maxReintentos ?? 3
this.baseDelayMs = opts.baseDelayMs ?? 500
}
/**
* Helper GraphQL contra la API de Monday.
* - Headers Authorization / API-Version.
* - Lanza en errores GraphQL.
* - Retry con backoff exponencial ante 429, errores de complejidad y 5xx
* (máx `maxReintentos` reintentos).
*/
async graphql<T>(query: string, variables: Record<string, unknown> = {}): Promise<T> {
let ultimoError: Error | null = null
for (let intento = 0; intento <= this.maxReintentos; intento++) {
if (intento > 0) await sleep(this.baseDelayMs * 2 ** (intento - 1))
try {
return await this.graphqlOnce<T>(query, variables)
} catch (err) {
if (!(err instanceof MondayRetryableError)) throw err
ultimoError = err
}
}
throw new AppError(502, `Monday no respondió tras ${this.maxReintentos + 1} intentos: ${ultimoError?.message}`)
}
private async graphqlOnce<T>(query: string, variables: Record<string, unknown>): Promise<T> {
const res = await fetch(this.apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: this.apiKey,
'API-Version': config.monday.apiVersion,
},
body: JSON.stringify({ query, variables }),
})
if (res.status === 429) throw new MondayRetryableError('HTTP 429 (rate limit)')
if (res.status >= 500) throw new MondayRetryableError(`HTTP ${res.status}`)
if (!res.ok) throw new AppError(502, `Monday respondió HTTP ${res.status}`)
const body = (await res.json()) as GraphQLResponse<T>
// Monday devuelve errores de complejidad como 200 con error_code
if (body.error_code) {
if (/complexity/i.test(body.error_code) || /complexity/i.test(body.error_message ?? '')) {
throw new MondayRetryableError(`Complejidad excedida: ${body.error_code}`)
}
throw new AppError(502, `Error de Monday: ${body.error_code} ${body.error_message ?? ''}`)
}
if (body.errors?.length) {
const msg = body.errors.map((e) => e.message).join('; ')
const esComplejidad = body.errors.some(
(e) => /complexity/i.test(e.message) || /complexity/i.test(e.extensions?.code ?? ''),
)
if (esComplejidad) throw new MondayRetryableError(`Complejidad excedida: ${msg}`)
throw new AppError(502, `Errores GraphQL de Monday: ${msg}`)
}
if (!body.data) throw new AppError(502, 'Monday respondió sin data')
return body.data
}
/** Metadatos de columnas de un tablero, mapeadas por TÍTULO (cache en memoria) */
private async columnasDe(boardId: string): Promise<Map<string, ColumnaMeta>> {
const cached = this.columnasCache.get(boardId)
if (cached) return cached
const data = await this.graphql<{ boards: { columns: ColumnaMeta[] }[] }>(
`query ($ids: [ID!]) { boards(ids: $ids) { columns { id title type } } }`,
{ ids: [boardId] },
)
const columnas = data.boards[0]?.columns ?? []
const mapa = new Map<string, ColumnaMeta>(columnas.map((c) => [c.title, c]))
this.columnasCache.set(boardId, mapa)
return mapa
}
/** Resuelve el column ID por título exacto; lanza si no existe en el tablero */
private async columnaId(boardId: string, titulo: string): Promise<ColumnaMeta> {
const cols = await this.columnasDe(boardId)
const col = cols.get(titulo)
if (!col) throw new AppError(500, `Columna "${titulo}" no existe en el tablero ${boardId}`)
return col
}
/** Ping barato para /api/health */
async ping(): Promise<boolean> {
try {
const data = await this.graphql<{ boards: { id: string }[] }>(
`query ($ids: [ID!]) { boards(ids: $ids) { id } }`,
{ ids: [config.monday.boards.unidades] },
)
return (data.boards?.length ?? 0) > 0
} catch {
return false
}
}
/** Invalida el cache de unidades (llamar tras cualquier escritura que las afecte) */
invalidarCacheUnidades(): void {
this.unidadesCache = null
}
/** Todas las unidades del tablero, con paginación por cursor y cache de 5 min */
async getUnidades(): Promise<Unidad[]> {
const ahora = Date.now()
if (this.unidadesCache && this.unidadesCache.expira > ahora) {
return this.unidadesCache.unidades
}
const boardId = config.monday.boards.unidades
const cols = await this.columnasDe(boardId)
const items: ItemMonday[] = []
let cursor: string | null = null
do {
// La primera página no lleva cursor; las siguientes sí (nunca asumir una sola página)
const data: { boards: { items_page: { cursor: string | null; items: ItemMonday[] } }[] } =
cursor === null
? await this.graphql(
`query ($ids: [ID!]) {
boards(ids: $ids) {
items_page(limit: 500) { cursor items { id name column_values { id text value } } }
}
}`,
{ ids: [boardId] },
)
: await this.graphql(
`query ($ids: [ID!], $cursor: String!) {
boards(ids: $ids) {
items_page(limit: 500, cursor: $cursor) { cursor items { id name column_values { id text value } } }
}
}`,
{ ids: [boardId], cursor },
)
const page = data.boards[0]?.items_page
if (!page) break
items.push(...page.items)
cursor = page.cursor
} while (cursor !== null)
const itemIds = new Map<string, string>()
const unidades: Unidad[] = []
for (const item of items) {
const unidad = this.mapearUnidad(item, cols)
// Items placeholder (sin modelo ni precios, p. ej. el "Task 1" default de
// Monday) no son inventario: se excluyen del listado
if (unidad.modelo === '' && unidad.precioSinAcabados === 0 && unidad.precioConAcabados === 0) continue
itemIds.set(item.name, item.id)
unidades.push(unidad)
}
this.unidadesCache = { unidades, itemIds, expira: ahora + CACHE_UNIDADES_MS }
return unidades
}
/** Una unidad por código (U-101, U-310...); null si no existe */
async getUnidad(codigo: string): Promise<Unidad | null> {
const unidades = await this.getUnidades()
return unidades.find((u) => u.codigo === codigo) ?? null
}
private mapearUnidad(item: ItemMonday, cols: Map<string, ColumnaMeta>): Unidad {
const porId = new Map(item.column_values.map((cv) => [cv.id, cv]))
const textoDe = (titulo: string): string => {
const col = cols.get(titulo)
if (!col) throw new AppError(500, `Columna "${titulo}" no existe en el tablero Unidades`)
return porId.get(col.id)?.text ?? ''
}
const numeroDe = (titulo: string): number => {
const n = Number(textoDe(titulo))
return Number.isFinite(n) ? n : 0
}
const status = textoDe('Disponibilidad').toUpperCase()
return {
codigo: item.name,
modelo: textoDe('Modelo'),
fechaEntrega: textoDe('Fecha entrega'),
supEstacionamiento: numeroDe('Sup. estacionamiento'),
supConstruccion: numeroDe('Sup. construcción'),
supExcedente: numeroDe('Sup. excedente'),
precioSinAcabados: numeroDe('Precio sin acabados'),
precioConAcabados: numeroDe('Precio con acabados'),
status: status === 'APARTADA' || status === 'VENDIDA' ? status : 'DISPONIBLE',
desarrollo: textoDe('Desarrollo'),
}
}
/**
* Serializa un valor crudo al formato que espera el tipo de columna de Monday.
* column_values SIEMPRE se envía con JSON.stringify en variables (nunca
* template literals dentro del query).
*/
private valorPorTipo(col: ColumnaMeta, crudo: string | number): unknown {
switch (col.type) {
case 'status':
return { label: String(crudo) }
case 'date':
return { date: String(crudo) }
case 'numbers':
return typeof crudo === 'number' ? crudo : Number(crudo)
case 'email':
return { email: String(crudo), text: String(crudo) }
case 'phone':
return { phone: String(crudo), countryShortName: 'MX' }
default:
return String(crudo)
}
}
private async construirColumnValues(
boardId: string,
valores: Record<string, string | number | undefined>,
): Promise<string> {
const out: Record<string, unknown> = {}
for (const [titulo, crudo] of Object.entries(valores)) {
if (crudo === undefined || crudo === '') continue
const col = await this.columnaId(boardId, titulo)
out[col.id] = this.valorPorTipo(col, crudo)
}
return JSON.stringify(out)
}
private async crearItem(boardId: string, nombre: string, columnValues: string): Promise<string> {
const data = await this.graphql<{ create_item: { id: string } }>(
`mutation ($boardId: ID!, $itemName: String!, $columnValues: JSON!) {
create_item(board_id: $boardId, item_name: $itemName, column_values: $columnValues) { id }
}`,
{ boardId, itemName: nombre, columnValues },
)
return data.create_item.id
}
// ---------- Escrituras de negocio ----------
async crearOportunidad(datos: DatosOportunidad): Promise<string> {
const boardId = config.monday.boards.oportunidades
const columnValues = await this.construirColumnValues(boardId, {
Unidad: datos.unidadCodigo,
'Cliente email': datos.clienteEmail,
'Cliente teléfono': datos.clienteTelefono,
'Tipo propuesta': datos.tipoPropuestaLabel,
'Precio de lista': datos.precioLista,
'Descuento %': datos.descuentoPct,
'Precio final': datos.precioFinal,
'Fecha cotización': datos.fechaCotizacion,
Estado: datos.estadoLabel,
})
return this.crearItem(boardId, datos.clienteNombre, columnValues)
}
async crearContacto(datos: DatosContacto): Promise<string> {
const boardId = config.monday.boards.contactos
const columnValues = await this.construirColumnValues(boardId, {
Teléfono: datos.telefono,
Email: datos.email,
CURP: datos.curp,
RFC: datos.rfc,
Dirección: datos.direccion,
Estado: datos.estadoLabel,
})
return this.crearItem(boardId, datos.nombre, columnValues)
}
async crearApartado(datos: DatosApartado): Promise<string> {
const boardId = config.monday.boards.apartado
const columnValues = await this.construirColumnValues(boardId, {
Unidad: datos.unidadCodigo,
'Monto apartado': datos.montoApartado,
'Fecha apartado': datos.fechaApartado,
'Fecha límite': datos.fechaLimite,
Estado: datos.estadoLabel,
})
return this.crearItem(boardId, datos.clienteNombre, columnValues)
}
async actualizarDisponibilidadUnidad(codigo: string, status: 'DISPONIBLE' | 'APARTADA' | 'VENDIDA'): Promise<void> {
const boardId = config.monday.boards.unidades
// Asegura cache fresco para resolver el itemId
await this.getUnidades()
const itemId = this.unidadesCache?.itemIds.get(codigo)
if (!itemId) throw new AppError(404, `Unidad ${codigo} no encontrada en Monday`)
const col = await this.columnaId(boardId, 'Disponibilidad')
await this.graphql(
`mutation ($boardId: ID!, $itemId: ID!, $columnId: String!, $value: JSON!) {
change_column_value(board_id: $boardId, item_id: $itemId, column_id: $columnId, value: $value) { id }
}`,
{ boardId, itemId, columnId: col.id, value: JSON.stringify({ label: status }) },
)
this.invalidarCacheUnidades()
}
}
// ---------- DTOs de escritura ----------
export interface DatosOportunidad {
clienteNombre: string
unidadCodigo: string
tipoPropuestaLabel: string
precioLista: number
descuentoPct: number // porcentaje (2 = 2%)
precioFinal: number
fechaCotizacion: string // 'YYYY-MM-DD'
estadoLabel: string // p. ej. 'COTIZADA'
clienteEmail?: string
clienteTelefono?: string
}
export interface DatosContacto {
nombre: string
telefono?: string
email?: string
curp?: string
rfc?: string
direccion?: string
estadoLabel: string // p. ej. 'CLIENTE'
}
export interface DatosApartado {
clienteNombre: string
unidadCodigo: string
montoApartado?: number
fechaApartado: string // 'YYYY-MM-DD'
fechaLimite: string // 'YYYY-MM-DD'
estadoLabel: string // p. ej. 'VIGENTE'
}
/** Singleton usado por los controllers (los tests instancian el suyo) */
export const mondayService = new MondayService()

View File

@@ -0,0 +1,4 @@
import { PrismaClient } from '@prisma/client'
// Cliente único compartido (Prisma gestiona el pool internamente)
export const prisma = new PrismaClient()

View File

@@ -0,0 +1,24 @@
/** Error con HTTP status para que el error handler lo traduzca a respuesta */
export class AppError extends Error {
constructor(
public readonly status: number,
message: string,
) {
super(message)
this.name = 'AppError'
}
}
export class NotFoundError extends AppError {
constructor(message = 'Recurso no encontrado') {
super(404, message)
this.name = 'NotFoundError'
}
}
export class BadRequestError extends AppError {
constructor(message: string) {
super(400, message)
this.name = 'BadRequestError'
}
}

23
api-gateway/tsconfig.json Normal file
View File

@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"outDir": "dist",
"rootDir": "src",
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"exactOptionalPropertyTypes": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"sourceMap": false,
"declaration": false
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "node_modules", "dist"]
}

View File

@@ -0,0 +1,17 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
env: {
// config/index.ts exige estas vars al importarse; los tests nunca llaman a Monday real
MONDAY_API_KEY: 'test-key',
MONDAY_BOARD_CONTACTOS: '111',
MONDAY_BOARD_DESARROLLOS: '222',
MONDAY_BOARD_UNIDADES: '333',
MONDAY_BOARD_LEADS: '444',
MONDAY_BOARD_OPORTUNIDADES: '555',
MONDAY_BOARD_APARTADO: '666',
MONDAY_BOARD_VENTAS: '777',
},
},
})

View File

@@ -10,19 +10,25 @@ import DocumentosForm from '../forms/DocumentosForm'
import FirmasForm from '../forms/FirmasForm' import FirmasForm from '../forms/FirmasForm'
import { useAltaCliente, PASOS } from '../hooks/useAltaCliente' import { useAltaCliente, PASOS } from '../hooks/useAltaCliente'
import { crearAltaCliente, subirDocumentos } from '../services/api' import { crearAltaCliente, subirDocumentos } from '../services/api'
import { enviarAMonday } from '../services/monday'
export default function AltaClientePage() { export default function AltaClientePage() {
const { paso, siguiente, anterior, irAPaso, datos, alta, actualizar, agregarDocumento, quitarDocumento } = const { paso, siguiente, anterior, irAPaso, datos, alta, actualizar, agregarDocumento, quitarDocumento } =
useAltaCliente() useAltaCliente()
const [registro, setRegistro] = useState<string>('') const [registro, setRegistro] = useState<string>('')
const [sinConexion, setSinConexion] = useState(false)
// Registra el alta (stub API + Monday) al llegar al paso de firmas // Registra el alta en el API al llegar al paso de firmas; si el API no
// responde, el flujo continúa solo local (los PDFs se generan igual)
const registrarAlta = async () => { const registrarAlta = async () => {
const { id } = await crearAltaCliente(alta) const creada = await crearAltaCliente(alta)
await subirDocumentos(id, alta) if (creada) {
await enviarAMonday(alta) await subirDocumentos(creada.id, alta)
setRegistro(id) setRegistro(creada.id)
setSinConexion(false)
} else {
setRegistro('')
setSinConexion(true)
}
} }
return ( return (
@@ -117,7 +123,15 @@ export default function AltaClientePage() {
<> <>
{registro && ( {registro && (
<p className="mb-8 border border-accent bg-white px-4 py-3 text-xs font-bold uppercase tracking-widest text-accent"> <p className="mb-8 border border-accent bg-white px-4 py-3 text-xs font-bold uppercase tracking-widest text-accent">
Alta registrada (stub API): {registro} Alta registrada en el servidor: {registro}
</p>
)}
{sinConexion && (
<p
role="alert"
className="mb-8 border border-amber-300 bg-amber-50 px-4 py-3 text-xs font-bold uppercase tracking-widest text-amber-700"
>
Sin conexión al servidor — el expediente se generó solo localmente
</p> </p>
)} )}
<FirmasForm <FirmasForm

View File

@@ -0,0 +1,133 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import type { AltaCliente } from '../types'
import { fixtureAlta } from '../lib/fixture'
import { crearAltaCliente, subirDocumentos } from './api'
type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
const docPrueba = (): AltaCliente['documentos'] => [
{
slot: 'INE',
nombre: 'ine.pdf',
tamano: 4,
tipoMime: 'application/pdf',
archivo: new File(['pdf!'], 'ine.pdf', { type: 'application/pdf' }),
},
{
slot: 'CURP',
nombre: 'curp.png',
tamano: 4,
tipoMime: 'image/png',
archivo: new File(['png!'], 'curp.png', { type: 'image/png' }),
},
]
const altaConDocumentos = (): AltaCliente => ({ ...fixtureAlta, documentos: docPrueba() })
describe('services/api', () => {
let fetchMock: ReturnType<typeof vi.fn<FetchFn>>
let warnSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
fetchMock = vi.fn<FetchFn>()
vi.stubGlobal('fetch', fetchMock)
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
})
afterEach(() => {
vi.unstubAllGlobals()
warnSpy.mockRestore()
})
describe('crearAltaCliente', () => {
it('hace POST JSON a /clientes y devuelve el id del servidor', async () => {
fetchMock.mockResolvedValue(
new Response(JSON.stringify({ id: 'srv-123' }), {
status: 201,
headers: { 'Content-Type': 'application/json' },
}),
)
const alta = altaConDocumentos()
const resultado = await crearAltaCliente(alta)
expect(resultado).toEqual({ id: 'srv-123' })
expect(fetchMock).toHaveBeenCalledTimes(1)
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]
expect(url).toBe('http://192.168.10.123:3000/api/clientes')
expect(init.method).toBe('POST')
expect(init.headers).toEqual({ 'Content-Type': 'application/json' })
// Los File no viajan en el JSON: solo metadatos; la firma va como string
const cuerpo = JSON.parse(String(init.body)) as Record<string, unknown>
const documentos = cuerpo['documentos'] as Record<string, unknown>[]
expect(documentos).toEqual([
{ tipo: 'INE', nombre: 'ine.pdf', tamano: 4, mime: 'application/pdf' },
{ tipo: 'CURP', nombre: 'curp.png', tamano: 4, mime: 'image/png' },
])
expect(JSON.stringify(cuerpo)).not.toContain('archivo')
expect((cuerpo['firma'] as Record<string, unknown>)['firmaDataUrl']).toBe(
fixtureAlta.firma.firmaDataUrl,
)
})
it('ante un fallo de red devuelve null y avisa con console.warn', async () => {
fetchMock.mockRejectedValue(new TypeError('fetch failed'))
const resultado = await crearAltaCliente(altaConDocumentos())
expect(resultado).toBeNull()
expect(warnSpy).toHaveBeenCalled()
})
it('ante una respuesta HTTP de error devuelve null y avisa', async () => {
fetchMock.mockResolvedValue(new Response('error', { status: 500 }))
const resultado = await crearAltaCliente(altaConDocumentos())
expect(resultado).toBeNull()
expect(warnSpy).toHaveBeenCalled()
})
})
describe('subirDocumentos', () => {
it('arma el multipart correcto: una petición por documento con tipo y archivos', async () => {
fetchMock.mockResolvedValue(new Response('[]', { status: 201 }))
await subirDocumentos('srv-123', altaConDocumentos())
expect(fetchMock).toHaveBeenCalledTimes(2)
const [url1, init1] = fetchMock.mock.calls[0] as [string, RequestInit]
expect(url1).toBe('http://192.168.10.123:3000/api/clientes/srv-123/documentos')
expect(init1.method).toBe('POST')
// Sin Content-Type manual: el navegador pone el boundary del multipart
expect(init1.headers).toBeUndefined()
const form1 = init1.body as FormData
expect(form1).toBeInstanceOf(FormData)
expect(form1.get('tipo')).toBe('INE')
const archivo1 = form1.get('archivos')
expect(archivo1).toBeInstanceOf(File)
expect((archivo1 as File).name).toBe('ine.pdf')
expect((archivo1 as File).type).toBe('application/pdf')
const [, init2] = fetchMock.mock.calls[1] as [string, RequestInit]
const form2 = init2.body as FormData
expect(form2.get('tipo')).toBe('CURP')
expect((form2.get('archivos') as File).name).toBe('curp.png')
})
it('no hace ninguna petición si no hay documentos', async () => {
await subirDocumentos('srv-123', fixtureAlta)
expect(fetchMock).not.toHaveBeenCalled()
})
it('ante un fallo de red avisa y continúa sin lanzar', async () => {
fetchMock.mockRejectedValue(new TypeError('fetch failed'))
await expect(subirDocumentos('srv-123', altaConDocumentos())).resolves.toBeUndefined()
expect(warnSpy).toHaveBeenCalledTimes(2)
})
})
})

View File

@@ -1,21 +1,71 @@
import type { AltaCliente } from '../types' import type { AltaCliente } from '../types'
import { uuid } from '../lib/uuid'
const API_URL = import.meta.env.VITE_API_URL ?? 'http://192.168.10.123:3000/api'
// TODO produccion: '/api' same-origin detras de NGINX
export interface AltaClienteCreada { export interface AltaClienteCreada {
id: string id: string
} }
/** Registra el alta de cliente en el backend */ interface DocumentoMeta {
export async function crearAltaCliente(payload: AltaCliente): Promise<AltaClienteCreada> { tipo: string
// TODO: POST /api/clientes nombre: string
await new Promise((resolve) => setTimeout(resolve, 400)) tamano: number
console.log('[api] crearAltaCliente (stub):', payload.datosGenerales.nombreCompleto) mime: string
return { id: uuid() }
} }
/** Sube los documentos del expediente del cliente */ /**
export async function subirDocumentos(clienteId: string, payload: AltaCliente): Promise<void> { * Serializa el alta para el POST JSON: los File no viajan en el cuerpo,
// TODO: POST multipart a /api/clientes/:id/documentos * solo sus metadatos; la firma ya es un string base64 (data URL).
await new Promise((resolve) => setTimeout(resolve, 200)) */
console.log(`[api] subirDocumentos (stub) cliente=${clienteId}:`, payload.documentos.length, 'archivos') const serializarAlta = (payload: AltaCliente) => ({
...payload,
documentos: payload.documentos.map(
(d): DocumentoMeta => ({ tipo: d.slot, nombre: d.nombre, tamano: d.tamano, mime: d.tipoMime }),
),
})
/**
* Registra el alta en el API Gateway (POST /clientes). El backend persiste en
* SQLite y replica en Monday. Si el API no responde devuelve null (con
* console.warn) y el flujo continúa solo local: los PDFs se generan igual.
*/
export async function crearAltaCliente(payload: AltaCliente): Promise<AltaClienteCreada | null> {
try {
const res = await fetch(`${API_URL}/clientes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(serializarAlta(payload)),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = (await res.json()) as { id: string }
return { id: data.id }
} catch (err) {
console.warn('[api] crearAltaCliente: sin conexión al servidor, el expediente se genera solo localmente', err)
return null
}
}
/**
* Sube los documentos reales del expediente (POST multipart
* /clientes/:id/documentos, campo 'archivos'). El backend acepta un solo
* `tipo` por petición, así que se envía una petición por documento con su
* slot como tipo. Los fallos se reportan con console.warn sin interrumpir
* el flujo local.
*/
export async function subirDocumentos(clienteId: string, payload: AltaCliente): Promise<void> {
for (const doc of payload.documentos) {
try {
const form = new FormData()
form.append('tipo', doc.slot)
form.append('archivos', doc.archivo, doc.nombre)
const res = await fetch(`${API_URL}/clientes/${clienteId}/documentos`, {
method: 'POST',
body: form,
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
} catch (err) {
console.warn(`[api] subirDocumentos: no se pudo subir ${doc.nombre} (${doc.slot})`, err)
}
}
} }

View File

@@ -1,10 +1,13 @@
import type { AltaCliente } from '../types' import type { AltaCliente } from '../types'
/** Envía el alta al tablero de Monday (Contactos + Apartado) */ /**
* OBSOLETO: la réplica en Monday (tableros Contactos y Apartado) ya la hace
* el API Gateway en POST /api/clientes (ver services/api.ts y
* api-gateway/src/controllers/clientes.controller.ts). Se conserva el
* archivo solo como referencia; no llamar desde el flujo del wizard.
*/
export async function enviarAMonday(payload: AltaCliente): Promise<void> { export async function enviarAMonday(payload: AltaCliente): Promise<void> {
// TODO: integración Monday — crear item en tablero "Contactos" y "Apartado" console.warn('[monday] enviarAMonday está obsoleto: el backend ya replica en Monday', {
// con nombre, teléfono, correo, proyecto y ubicación del cliente
console.log('[monday] enviarAMonday (stub):', {
nombre: payload.datosGenerales.nombreCompleto, nombre: payload.datosGenerales.nombreCompleto,
proyecto: payload.viviendaCredito.proyecto, proyecto: payload.viviendaCredito.proyecto,
ubicacion: payload.viviendaCredito.ubicacion, ubicacion: payload.viviendaCredito.ubicacion,

View File

@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom' import { Link, useSearchParams } from 'react-router-dom'
import { import {
Bar, Bar,
@@ -13,8 +13,8 @@ import {
XAxis, XAxis,
YAxis, YAxis,
} from 'recharts' } from 'recharts'
import type { PlanPago, StatusPlan } from '../types' import type { StatusPlan } from '../types'
import { cargarPlanes } from '../services/repository' import { listarPlanes, type ResultadoPlanes } from '../services/repository'
import { import {
alCorriente, alCorriente,
avancePlan, avancePlan,
@@ -45,7 +45,8 @@ function claveMes(fecha: Date): string {
} }
export default function DashboardPage() { export default function DashboardPage() {
const [planes] = useState<PlanPago[]>(() => cargarPlanes()) const [resultado, setResultado] = useState<ResultadoPlanes | null>(null)
const [errorCarga, setErrorCarga] = useState<string | null>(null)
// Filtros y ordenamiento persistidos en la URL para que sean compartibles // Filtros y ordenamiento persistidos en la URL para que sean compartibles
const [searchParams, setSearchParams] = useSearchParams() const [searchParams, setSearchParams] = useSearchParams()
const busqueda = searchParams.get('q') ?? '' const busqueda = searchParams.get('q') ?? ''
@@ -56,6 +57,22 @@ export default function DashboardPage() {
const dir: 'asc' | 'desc' = searchParams.get('dir') === 'desc' ? 'desc' : 'asc' const dir: 'asc' | 'desc' = searchParams.get('dir') === 'desc' ? 'desc' : 'asc'
const hoy = useMemo(() => new Date(), []) const hoy = useMemo(() => new Date(), [])
useEffect(() => {
let activo = true
listarPlanes()
.then((r) => {
if (activo) setResultado(r)
})
.catch((e: unknown) => {
if (activo) setErrorCarga(e instanceof Error ? e.message : 'Error al cargar los planes')
})
return () => {
activo = false
}
}, [])
const planes = useMemo(() => resultado?.planes ?? [], [resultado])
const actualizarParams = (cambios: Record<string, string | null>) => { const actualizarParams = (cambios: Record<string, string | null>) => {
setSearchParams( setSearchParams(
(prev) => { (prev) => {
@@ -134,6 +151,14 @@ export default function DashboardPage() {
})) }))
}, [planes, hoy]) }, [planes, hoy])
if (!resultado) {
return errorCarga ? (
<div className="border border-red-300 bg-white px-4 py-3 text-sm text-red-700">{errorCarga}</div>
) : (
<p className="text-sm text-neutral-400">Cargando...</p>
)
}
const filtrados = derivados.filter((d) => { const filtrados = derivados.filter((d) => {
if (filtroStatus !== 'TODOS' && d.status !== filtroStatus) return false if (filtroStatus !== 'TODOS' && d.status !== filtroStatus) return false
if (filtroProyecto !== 'TODOS' && d.plan.proyecto !== filtroProyecto) return false if (filtroProyecto !== 'TODOS' && d.plan.proyecto !== filtroProyecto) return false
@@ -193,6 +218,15 @@ export default function DashboardPage() {
</Link> </Link>
</div> </div>
{/* Aviso discreto cuando los datos no vienen del API */}
{resultado.origen === 'local' && (
<p className="text-xs text-amber-700">
{resultado.planesSoloLocales
? 'El API no tiene planes registrados. Los planes guardados en este navegador se conservan solo localmente (no se migraron automáticamente).'
: 'Modo offline: sin conexión al API; los cambios se guardan solo en este navegador.'}
</p>
)}
{/* KPIs */} {/* KPIs */}
<div className="grid grid-cols-2 gap-6 lg:grid-cols-4"> <div className="grid grid-cols-2 gap-6 lg:grid-cols-4">
<KpiCard titulo="Planes activos" valor={activos.length} /> <KpiCard titulo="Planes activos" valor={activos.length} />

View File

@@ -1,15 +1,31 @@
import { useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import type { PlanPago } from '../types' import { listarPlanes, type ResultadoPlanes } from '../services/repository'
import { cargarPlanes } from '../services/repository'
import { cobranzaDelDia, round2, statusPago } from '../lib/cobranza' import { cobranzaDelDia, round2, statusPago } from '../lib/cobranza'
import { formatoFechaCorta, formatoMoneda } from '../lib/formatters' import { formatoFechaCorta, formatoMoneda } from '../lib/formatters'
import KpiCard from '../components/KpiCard' import KpiCard from '../components/KpiCard'
/** Vista "Cobranza del día": vencidos + por vencer en 7 días, a través de todos los planes activos */ /** Vista "Cobranza del día": vencidos + por vencer en 7 días, a través de todos los planes activos */
export default function HoyPage() { export default function HoyPage() {
const [planes] = useState<PlanPago[]>(() => cargarPlanes()) const [resultado, setResultado] = useState<ResultadoPlanes | null>(null)
const [errorCarga, setErrorCarga] = useState<string | null>(null)
const hoy = useMemo(() => new Date(), []) const hoy = useMemo(() => new Date(), [])
useEffect(() => {
let activo = true
listarPlanes()
.then((r) => {
if (activo) setResultado(r)
})
.catch((e: unknown) => {
if (activo) setErrorCarga(e instanceof Error ? e.message : 'Error al cargar los planes')
})
return () => {
activo = false
}
}, [])
const planes = useMemo(() => resultado?.planes ?? [], [resultado])
const items = useMemo(() => cobranzaDelDia(planes, hoy), [planes, hoy]) const items = useMemo(() => cobranzaDelDia(planes, hoy), [planes, hoy])
const vencidos = items.filter((i) => i.atraso > 0) const vencidos = items.filter((i) => i.atraso > 0)
@@ -18,6 +34,14 @@ export default function HoyPage() {
items.reduce((acc, i) => acc + (i.pago.monto - i.pago.montoPagado), 0), items.reduce((acc, i) => acc + (i.pago.monto - i.pago.montoPagado), 0),
) )
if (!resultado) {
return errorCarga ? (
<div className="border border-red-300 bg-white px-4 py-3 text-sm text-red-700">{errorCarga}</div>
) : (
<p className="text-sm text-neutral-400">Cargando...</p>
)
}
return ( return (
<div className="space-y-16"> <div className="space-y-16">
<div> <div>
@@ -25,6 +49,11 @@ export default function HoyPage() {
<p className="mt-1 text-sm text-neutral-500"> <p className="mt-1 text-sm text-neutral-500">
Pagos vencidos y vencimientos de los próximos 7 días, en todos los planes activos. Pagos vencidos y vencimientos de los próximos 7 días, en todos los planes activos.
</p> </p>
{resultado.origen === 'local' && (
<p className="mt-2 text-xs text-amber-700">
Modo offline: sin conexión al API; mostrando datos guardados en este navegador.
</p>
)}
</div> </div>
<div className="grid grid-cols-2 gap-6 lg:grid-cols-3"> <div className="grid grid-cols-2 gap-6 lg:grid-cols-3">

View File

@@ -1,8 +1,9 @@
import { useState } from 'react'
import { useForm } from 'react-hook-form' import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod' import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod' import { z } from 'zod'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { crearPlan } from '../services/repository' import { crearPlanAsync } from '../services/repository'
import { generarCalendario } from '../lib/cobranza' import { generarCalendario } from '../lib/cobranza'
import { fechaISOHoy, formatoFechaCorta, formatoMoneda } from '../lib/formatters' import { fechaISOHoy, formatoFechaCorta, formatoMoneda } from '../lib/formatters'
@@ -42,6 +43,8 @@ const claseLabel = 'block text-xs font-bold uppercase tracking-widest text-neutr
export default function NuevoPlanPage() { export default function NuevoPlanPage() {
const navigate = useNavigate() const navigate = useNavigate()
const [guardando, setGuardando] = useState(false)
const [errorGuardado, setErrorGuardado] = useState<string | null>(null)
const { const {
register, register,
handleSubmit, handleSubmit,
@@ -71,9 +74,17 @@ export default function NuevoPlanPage() {
vistaPrevia = null vistaPrevia = null
} }
const guardar = (datos: NuevoPlanForm) => { const guardar = async (datos: NuevoPlanForm) => {
const plan = crearPlan(datos) setGuardando(true)
void navigate(`/planes/${plan.id}`) setErrorGuardado(null)
try {
const { plan } = await crearPlanAsync(datos)
void navigate(`/planes/${plan.id}`)
} catch (e) {
setErrorGuardado(e instanceof Error ? e.message : 'Error al guardar el plan')
} finally {
setGuardando(false)
}
} }
return ( return (
@@ -192,7 +203,8 @@ export default function NuevoPlanPage() {
</div> </div>
)} )}
<div className="mt-8 flex justify-end gap-3"> <div className="mt-8 flex items-center justify-end gap-3">
{errorGuardado && <p className="text-xs text-red-700">{errorGuardado}</p>}
<button <button
type="button" type="button"
onClick={() => void navigate('/')} onClick={() => void navigate('/')}
@@ -202,9 +214,10 @@ export default function NuevoPlanPage() {
</button> </button>
<button <button
type="submit" type="submit"
disabled={guardando}
className="bg-accent px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-white transition-colors duration-150 hover:opacity-90 disabled:opacity-40" className="bg-accent px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-white transition-colors duration-150 hover:opacity-90 disabled:opacity-40"
> >
Guardar plan {guardando ? 'Guardando…' : 'Guardar plan'}
</button> </button>
</div> </div>
</form> </form>

View File

@@ -1,7 +1,12 @@
import { Fragment, useMemo, useState } from 'react' import { Fragment, useEffect, useMemo, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom' import { Link, useNavigate, useParams } from 'react-router-dom'
import type { Pago, PlanPago } from '../types' import type { Pago, PlanPago } from '../types'
import { agregarNota, cancelarPlan, obtenerPlan, registrarAbonoEnPlan } from '../services/repository' import {
agregarNotaAsync,
cancelarPlanAsync,
obtenerPlanPorId,
registrarAbonoAsync,
} from '../services/repository'
import { import {
avancePlan, avancePlan,
diasAtraso, diasAtraso,
@@ -23,7 +28,10 @@ import ModalAbono from '../components/ModalAbono'
export default function PlanDetallePage() { export default function PlanDetallePage() {
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>()
const navigate = useNavigate() const navigate = useNavigate()
const [plan, setPlan] = useState<PlanPago | undefined>(() => (id ? obtenerPlan(id) : undefined)) const [plan, setPlan] = useState<PlanPago | undefined>(undefined)
const [cargando, setCargando] = useState(true)
const [offline, setOffline] = useState(false)
const [errorCarga, setErrorCarga] = useState<string | null>(null)
const [pagoAbono, setPagoAbono] = useState<Pago | null>(null) const [pagoAbono, setPagoAbono] = useState<Pago | null>(null)
const [expandidos, setExpandidos] = useState<Set<string>>(new Set()) const [expandidos, setExpandidos] = useState<Set<string>>(new Set())
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@@ -31,6 +39,37 @@ export default function PlanDetallePage() {
const [errorNota, setErrorNota] = useState<string | null>(null) const [errorNota, setErrorNota] = useState<string | null>(null)
const hoy = useMemo(() => new Date(), []) const hoy = useMemo(() => new Date(), [])
useEffect(() => {
if (!id) {
setCargando(false)
return
}
let activo = true
obtenerPlanPorId(id)
.then((r) => {
if (!activo) return
setPlan(r.plan)
setOffline(r.origen === 'local')
})
.catch((e: unknown) => {
if (activo) setErrorCarga(e instanceof Error ? e.message : 'Error al cargar el plan')
})
.finally(() => {
if (activo) setCargando(false)
})
return () => {
activo = false
}
}, [id])
if (cargando) {
return <p className="text-sm text-neutral-400">Cargando...</p>
}
if (errorCarga) {
return <div className="border border-red-300 bg-white px-4 py-3 text-sm text-red-700">{errorCarga}</div>
}
if (!plan) { if (!plan) {
return ( return (
<div className="border border-neutral-200 bg-white p-16 text-center"> <div className="border border-neutral-200 bg-white p-16 text-center">
@@ -57,11 +96,12 @@ export default function PlanDetallePage() {
}) })
} }
const guardarAbono = (datos: { monto: number; fechaPago: string; referencia: string }) => { const guardarAbono = async (datos: { monto: number; fechaPago: string; referencia: string }) => {
if (!pagoAbono) return if (!pagoAbono) return
try { try {
const actualizado = registrarAbonoEnPlan(plan.id, datos, pagoAbono.id) const r = await registrarAbonoAsync(plan.id, datos, pagoAbono.id)
setPlan(actualizado) setPlan(r.plan)
setOffline(r.origen === 'local')
setPagoAbono(null) setPagoAbono(null)
setError(null) setError(null)
} catch (e) { } catch (e) {
@@ -69,14 +109,23 @@ export default function PlanDetallePage() {
} }
} }
const cancelar = () => { const cancelar = async () => {
if (!window.confirm('¿Cancelar este plan? El saldo pendiente dejará de cobrarse.')) return if (!window.confirm('¿Cancelar este plan? El saldo pendiente dejará de cobrarse.')) return
setPlan(cancelarPlan(plan.id)) try {
const r = await cancelarPlanAsync(plan.id)
setPlan(r.plan)
setOffline(r.origen === 'local')
setError(null)
} catch (e) {
setError(e instanceof Error ? e.message : 'Error al cancelar el plan')
}
} }
const guardarNota = () => { const guardarNota = async () => {
try { try {
setPlan(agregarNota(plan.id, notaTexto)) const r = await agregarNotaAsync(plan, notaTexto)
setPlan(r.plan)
setOffline(r.origen === 'local')
setNotaTexto('') setNotaTexto('')
setErrorNota(null) setErrorNota(null)
} catch (e) { } catch (e) {
@@ -120,6 +169,12 @@ export default function PlanDetallePage() {
</div> </div>
</div> </div>
{offline && (
<p className="text-xs text-amber-700">
Modo offline: sin conexión al API; los cambios se guardan solo en este navegador.
</p>
)}
{error && ( {error && (
<div className="border border-red-300 bg-white px-4 py-3 text-sm text-red-700">{error}</div> <div className="border border-red-300 bg-white px-4 py-3 text-sm text-red-700">{error}</div>
)} )}
@@ -277,7 +332,7 @@ export default function PlanDetallePage() {
className="mt-8" className="mt-8"
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault() e.preventDefault()
guardarNota() void guardarNota()
}} }}
> >
<label <label

View File

@@ -1,11 +1,44 @@
import type { PlanPago } from '../types' // Cliente HTTP del API Gateway de FRASA (Express + Prisma)
import { cargarPlanes } from './repository' const API_URL = import.meta.env.VITE_API_URL ?? 'http://192.168.10.123:3000/api'
// TODO produccion: '/api' same-origin detras de NGINX
/** /**
* Devuelve los planes de pago registrados. * Error HTTP del API (respuesta con status >= 400, p. ej. validación o 404).
* TODO: reemplazar por fetch('/api/planes') cuando el API Gateway esté disponible * NO amerita fallback offline: el servidor respondió y su respuesta manda.
* TODO: cron de vencidos y envío WhatsApp van en el API Gateway (fase posterior)
*/ */
export async function getPlanes(): Promise<PlanPago[]> { export class ErrorApi extends Error {
return cargarPlanes() constructor(
public readonly status: number,
message: string,
) {
super(message)
this.name = 'ErrorApi'
}
}
/**
* fetch solo rechaza (TypeError) cuando falla la red; una respuesta HTTP de
* error resuelve normalmente. El fallback offline solo aplica al primer caso.
*/
export function esErrorDeRed(err: unknown): boolean {
return err instanceof TypeError
}
/** Llama al API Gateway; lanza ErrorApi con el mensaje del servidor si el status no es 2xx */
export async function request<T>(ruta: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${API_URL}${ruta}`, {
headers: { 'Content-Type': 'application/json' },
...init,
})
if (!res.ok) {
let mensaje = `Error ${res.status} al llamar ${ruta}`
try {
const cuerpo = (await res.json()) as { error?: string }
if (typeof cuerpo.error === 'string') mensaje = cuerpo.error
} catch {
// cuerpo no JSON: se queda el mensaje genérico
}
throw new ErrorApi(res.status, mensaje)
}
return (await res.json()) as T
} }

View File

@@ -0,0 +1,337 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
CLAVE_STORAGE,
agregarNotaAsync,
cancelarPlanAsync,
crearPlan,
crearPlanAsync,
listarPlanes,
obtenerPlanPorId,
registrarAbonoAsync,
type NuevoPlanInput,
type PlanApi,
type StorageLike,
} from './repository'
import { ErrorApi } from './api'
import type { PlanPago } from '../types'
/** Stub sencillo de localStorage para correr en entorno node */
function crearStorageStub(): StorageLike {
const mapa = new Map<string, string>()
return {
getItem: (k: string) => mapa.get(k) ?? null,
setItem: (k: string, v: string) => {
mapa.set(k, v)
},
}
}
function respuestaJson(valor: unknown, status = 200): Response {
return new Response(JSON.stringify(valor), {
status,
headers: { 'Content-Type': 'application/json' },
})
}
/** fetch mockeado con la firma tipada (url, init) para inspeccionar las llamadas */
function fetchQue(impl: () => Promise<Response>) {
return vi.fn<(url: string, init?: RequestInit) => Promise<Response>>(impl)
}
/** fetch que rechaza como cuando no hay red / el API está caído */
function fetchSinRed() {
return fetchQue(async () => {
throw new TypeError('fetch failed')
})
}
const INPUT_PLAN: NuevoPlanInput = {
clienteNombre: 'Cliente Prueba',
unidadCodigo: 'U-100',
proyecto: 'SENDA',
ventaRef: 'VTA-1',
fechaInicio: '2026-09-01',
engancheTotal: 60000,
numPagos: 6,
}
/** Plan con el shape del API Gateway (nulls de Prisma); los calculados extra se agregan inline */
function planApi(overrides: Partial<PlanApi> = {}): PlanApi {
return {
id: 'api-1',
ventaRef: null,
clienteNombre: 'Cliente API',
clienteTelefono: null,
unidadCodigo: 'U-100',
proyecto: 'SENDA',
fechaInicio: '2026-09-01',
engancheTotal: 60000,
numPagos: 6,
mensualidad: 10000,
tasaMoraMensual: null,
status: 'ACTIVO',
pagos: [],
notas: [],
createdAt: '2026-08-01T00:00:00.000Z',
...overrides,
}
}
function planLocal(overrides: Partial<PlanPago> = {}): PlanPago {
return {
id: 'local-1',
ventaRef: '',
clienteNombre: 'Cliente Local',
unidadCodigo: 'U-1',
proyecto: 'SENDA',
fechaInicio: '2026-09-01',
engancheTotal: 10000,
numPagos: 2,
mensualidad: 5000,
status: 'ACTIVO',
pagos: [],
notas: [],
createdAt: '2026-08-01T00:00:00.000Z',
...overrides,
}
}
afterEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
describe('repository async (API primero, fallback localStorage)', () => {
it('listarPlanes devuelve los planes del API normalizados y sin los calculados del servidor', async () => {
const fetchMock = fetchQue(async () =>
// El API manda campos calculados (saldo, avance…); el frontend los deriva por su cuenta
respuestaJson([{ ...planApi(), saldo: 60000, avance: 0 }]),
)
vi.stubGlobal('fetch', fetchMock)
const r = await listarPlanes(crearStorageStub())
expect(r.origen).toBe('api')
expect(r.planesSoloLocales).toBe(false)
expect(r.planes).toHaveLength(1)
const plan = r.planes[0]
expect(plan.ventaRef).toBe('') // null → ''
expect(plan.clienteTelefono).toBeUndefined() // null → undefined
expect(plan.tasaMoraMensual).toBeUndefined()
expect(plan).not.toHaveProperty('saldo')
expect(fetchMock).toHaveBeenCalledWith(
'http://192.168.10.123:3000/api/planes',
expect.objectContaining({ headers: { 'Content-Type': 'application/json' } }),
)
})
it('listarPlanes cae a localStorage (con seed) cuando fetch rechaza por red', async () => {
vi.stubGlobal('fetch', fetchSinRed())
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const storage = crearStorageStub()
const r = await listarPlanes(storage)
expect(r.origen).toBe('local')
expect(r.planesSoloLocales).toBe(false)
expect(r.planes).toHaveLength(4) // datos de ejemplo sembrados
expect(storage.getItem(CLAVE_STORAGE)).not.toBeNull()
expect(warn).toHaveBeenCalled()
})
it('listarPlanes NO cae a localStorage ante un error HTTP del API', async () => {
vi.stubGlobal(
'fetch',
fetchQue(async () => respuestaJson({ error: 'Error interno del servidor' }, 500)),
)
await expect(listarPlanes(crearStorageStub())).rejects.toBeInstanceOf(ErrorApi)
})
it('conserva los planes locales cuando el API responde vacío (sin migrar automáticamente)', async () => {
vi.stubGlobal('fetch', fetchQue(async () => respuestaJson([])))
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const storage = crearStorageStub()
storage.setItem(CLAVE_STORAGE, JSON.stringify([planLocal()]))
const r = await listarPlanes(storage)
expect(r.origen).toBe('local')
expect(r.planesSoloLocales).toBe(true)
expect(r.planes.map((p) => p.id)).toEqual(['local-1'])
expect(warn).toHaveBeenCalled()
})
it('API vacío sin planes locales → lista vacía con origen api', async () => {
vi.stubGlobal('fetch', fetchQue(async () => respuestaJson([])))
const r = await listarPlanes(crearStorageStub())
expect(r).toEqual({ planes: [], origen: 'api', planesSoloLocales: false })
})
it('obtenerPlanPorId devuelve undefined ante un 404 del API (sin fallback)', async () => {
vi.stubGlobal(
'fetch',
fetchQue(async () => respuestaJson({ error: 'Plan de pago no encontrado' }, 404)),
)
const r = await obtenerPlanPorId('no-existe', crearStorageStub())
expect(r.plan).toBeUndefined()
expect(r.origen).toBe('api')
})
it('obtenerPlanPorId cae a localStorage cuando falla la red', async () => {
vi.stubGlobal('fetch', fetchSinRed())
vi.spyOn(console, 'warn').mockImplementation(() => {})
const storage = crearStorageStub()
storage.setItem(CLAVE_STORAGE, JSON.stringify([planLocal()]))
const r = await obtenerPlanPorId('local-1', storage)
expect(r.origen).toBe('local')
expect(r.plan?.clienteNombre).toBe('Cliente Local')
})
it('crearPlanAsync hace POST al API y no escribe en localStorage', async () => {
const fetchMock = fetchQue(async () => respuestaJson(planApi({ id: 'api-nuevo' }), 201))
vi.stubGlobal('fetch', fetchMock)
const storage = crearStorageStub()
const r = await crearPlanAsync(INPUT_PLAN, storage)
expect(r.origen).toBe('api')
expect(r.plan.id).toBe('api-nuevo')
expect(storage.getItem(CLAVE_STORAGE)).toBeNull()
const [url, init] = fetchMock.mock.calls[0]
expect(url).toBe('http://192.168.10.123:3000/api/planes')
expect(init?.method).toBe('POST')
const body = JSON.parse(String(init?.body)) as Record<string, unknown>
expect(body.clienteNombre).toBe('Cliente Prueba')
expect(body.engancheTotal).toBe(60000)
expect(body.numPagos).toBe(6)
})
it('crearPlanAsync guarda el plan en localStorage cuando falla la red', async () => {
vi.stubGlobal('fetch', fetchSinRed())
vi.spyOn(console, 'warn').mockImplementation(() => {})
const storage = crearStorageStub()
const r = await crearPlanAsync(INPUT_PLAN, storage)
expect(r.origen).toBe('local')
expect(r.plan.mensualidad).toBe(10000)
const guardados = JSON.parse(storage.getItem(CLAVE_STORAGE) ?? '[]') as PlanPago[]
expect(guardados).toHaveLength(5) // 4 del seed + 1 nuevo
})
it('registrarAbonoAsync envía pagoId/fechaPago/monto/referencia al API', async () => {
const fetchMock = fetchQue(async () => respuestaJson(planApi(), 201))
vi.stubGlobal('fetch', fetchMock)
const r = await registrarAbonoAsync(
'api-1',
{ fechaPago: '2026-09-02', monto: 10000, referencia: 'SPEI-1' },
'pago-1',
crearStorageStub(),
)
expect(r.origen).toBe('api')
const [url, init] = fetchMock.mock.calls[0]
expect(url).toBe('http://192.168.10.123:3000/api/planes/api-1/abonos')
const body = JSON.parse(String(init?.body)) as Record<string, unknown>
expect(body).toEqual({ pagoId: 'pago-1', fechaPago: '2026-09-02', monto: 10000, referencia: 'SPEI-1' })
})
it('registrarAbonoAsync propaga el ErrorApi de validación sin caer a local', async () => {
vi.stubGlobal(
'fetch',
fetchQue(async () => respuestaJson({ error: 'El abono excede el restante' }, 400)),
)
const storage = crearStorageStub()
await expect(
registrarAbonoAsync('api-1', { fechaPago: '2026-09-02', monto: 999999, referencia: 'X1' }, 'pago-1', storage),
).rejects.toBeInstanceOf(ErrorApi)
expect(storage.getItem(CLAVE_STORAGE)).toBeNull()
})
it('registrarAbonoAsync aplica el abono en localStorage cuando falla la red', async () => {
vi.stubGlobal('fetch', fetchSinRed())
vi.spyOn(console, 'warn').mockImplementation(() => {})
const storage = crearStorageStub()
const plan = crearPlan({ ...INPUT_PLAN, engancheTotal: 20000, numPagos: 2 }, storage)
const r = await registrarAbonoAsync(
plan.id,
{ fechaPago: '2026-09-01', monto: 10000, referencia: 'A1' },
plan.pagos[0].id,
storage,
)
expect(r.origen).toBe('local')
expect(r.plan.pagos[0].montoPagado).toBe(10000)
})
it('agregarNotaAsync anexa al plan la nota devuelta por el API', async () => {
const fetchMock = fetchQue(async () =>
respuestaJson({ id: 'nota-1', planId: 'api-1', fecha: '2026-08-21T12:00:00.000Z', texto: 'Se habló con el cliente' }, 201),
)
vi.stubGlobal('fetch', fetchMock)
const plan = { ...planApi(), ventaRef: '', clienteTelefono: undefined, tasaMoraMensual: undefined }
const r = await agregarNotaAsync(plan, 'Se habló con el cliente', crearStorageStub())
expect(r.origen).toBe('api')
expect(r.plan.notas).toHaveLength(1)
expect(r.plan.notas[0]).toEqual({
id: 'nota-1',
fecha: '2026-08-21T12:00:00.000Z',
texto: 'Se habló con el cliente',
})
const [url] = fetchMock.mock.calls[0]
expect(url).toBe('http://192.168.10.123:3000/api/planes/api-1/notas')
})
it('agregarNotaAsync cae a localStorage cuando falla la red (conserva la validación local)', async () => {
vi.stubGlobal('fetch', fetchSinRed())
vi.spyOn(console, 'warn').mockImplementation(() => {})
const storage = crearStorageStub()
const plan = planLocal()
storage.setItem(CLAVE_STORAGE, JSON.stringify([plan]))
const r = await agregarNotaAsync(plan, 'Se habló con el cliente', storage)
expect(r.origen).toBe('local')
expect(r.plan.notas).toHaveLength(1)
await expect(agregarNotaAsync(plan, 'ok', storage)).rejects.toThrow(/al menos 3/)
})
it('cancelarPlanAsync cancela vía API', async () => {
const fetchMock = fetchQue(async () => respuestaJson(planApi({ status: 'CANCELADO' })))
vi.stubGlobal('fetch', fetchMock)
const r = await cancelarPlanAsync('api-1', crearStorageStub())
expect(r.origen).toBe('api')
expect(r.plan.status).toBe('CANCELADO')
const [url, init] = fetchMock.mock.calls[0]
expect(url).toBe('http://192.168.10.123:3000/api/planes/api-1/cancelar')
expect(init?.method).toBe('POST')
})
it('cancelarPlanAsync marca CANCELADO en localStorage cuando falla la red', async () => {
vi.stubGlobal('fetch', fetchSinRed())
vi.spyOn(console, 'warn').mockImplementation(() => {})
const storage = crearStorageStub()
storage.setItem(CLAVE_STORAGE, JSON.stringify([planLocal()]))
const r = await cancelarPlanAsync('local-1', storage)
expect(r.origen).toBe('local')
expect(r.plan.status).toBe('CANCELADO')
const guardados = JSON.parse(storage.getItem(CLAVE_STORAGE) ?? '[]') as PlanPago[]
expect(guardados[0].status).toBe('CANCELADO')
})
})

View File

@@ -1,12 +1,13 @@
import type { PlanPago } from '../types' import type { NotaPlan, PlanPago } from '../types'
import { generarSeed } from '../data/seed' import { generarSeed } from '../data/seed'
import { generarCalendario, registrarAbono, statusPlan, agregarNotaAPlan, type AbonoInput } from '../lib/cobranza' import { generarCalendario, registrarAbono, statusPlan, agregarNotaAPlan, type AbonoInput } from '../lib/cobranza'
import { uuid } from '../lib/uuid' import { uuid } from '../lib/uuid'
import { ErrorApi, esErrorDeRed, request } from './api'
export const CLAVE_STORAGE = 'frasa_cobranza_v1' export const CLAVE_STORAGE = 'frasa_cobranza_v1'
// Inyectable para pruebas (stub en vitest, donde no hay localStorage) // Inyectable para pruebas (stub en vitest, donde no hay localStorage)
type StorageLike = Pick<Storage, 'getItem' | 'setItem'> export type StorageLike = Pick<Storage, 'getItem' | 'setItem'>
function storageDefault(): StorageLike { function storageDefault(): StorageLike {
return globalThis.localStorage return globalThis.localStorage
@@ -15,7 +16,7 @@ function storageDefault(): StorageLike {
/** /**
* Carga los planes desde localStorage; si está vacío siembra los datos de ejemplo. * Carga los planes desde localStorage; si está vacío siembra los datos de ejemplo.
* Migra planes guardados con versiones anteriores del modelo (sin `notas`). * Migra planes guardados con versiones anteriores del modelo (sin `notas`).
* TODO: reemplazar por GET /api/planes cuando el API Gateway esté disponible. * Es el respaldo offline de `listarPlanes` cuando el API no es alcanzable.
*/ */
export function cargarPlanes(storage: StorageLike = storageDefault()): PlanPago[] { export function cargarPlanes(storage: StorageLike = storageDefault()): PlanPago[] {
const crudo = storage.getItem(CLAVE_STORAGE) const crudo = storage.getItem(CLAVE_STORAGE)
@@ -113,3 +114,175 @@ export function agregarNota(
if (!plan) throw new Error('Plan no encontrado') if (!plan) throw new Error('Plan no encontrado')
return actualizarPlan(agregarNotaAPlan(plan, texto), storage) return actualizarPlan(agregarNotaAPlan(plan, texto), storage)
} }
// ---------- Capa async: API primero, localStorage como respaldo offline ----------
/** De dónde salieron los datos de una operación */
export type OrigenDatos = 'api' | 'local'
export interface ResultadoPlanes {
planes: PlanPago[]
origen: OrigenDatos
/** true si el API respondió vacío pero hay planes locales: se conservan solo en este navegador */
planesSoloLocales: boolean
}
export interface ResultadoPlan {
plan: PlanPago
origen: OrigenDatos
}
/**
* Shape del plan tal como lo devuelve el API Gateway: los opcionales llegan como
* null (Prisma) y trae campos calculados extra (saldo, avance…) que aquí se
* ignoran porque el frontend los deriva con las funciones puras de lib/cobranza.
*/
export type PlanApi = Omit<PlanPago, 'ventaRef' | 'clienteTelefono' | 'tasaMoraMensual'> & {
ventaRef: string | null
clienteTelefono: string | null
tasaMoraMensual: number | null
}
/** Normaliza el plan del API al modelo del frontend (null → '' / undefined) */
function desdeApi(p: PlanApi): PlanPago {
return {
id: p.id,
ventaRef: p.ventaRef ?? '',
clienteNombre: p.clienteNombre,
clienteTelefono: p.clienteTelefono ?? undefined,
unidadCodigo: p.unidadCodigo,
proyecto: p.proyecto,
fechaInicio: p.fechaInicio,
engancheTotal: p.engancheTotal,
numPagos: p.numPagos,
mensualidad: p.mensualidad,
tasaMoraMensual: p.tasaMoraMensual ?? undefined,
status: p.status,
pagos: p.pagos,
notas: p.notas ?? [],
createdAt: p.createdAt,
}
}
/** Lee los planes locales sin sembrar, para detectar datos que solo existen en este navegador */
function planesLocalesSinSembrar(storage: StorageLike): PlanPago[] {
const crudo = storage.getItem(CLAVE_STORAGE)
if (!crudo) return []
const planes = JSON.parse(crudo) as PlanPago[]
return planes.map((p) => (p.notas ? p : { ...p, notas: [] }))
}
/**
* GET /planes. Ante error de red cae a localStorage (sembrando si está vacío).
* Si el API responde vacío pero hay planes locales, los conserva solo localmente
* (NO se migran automáticamente) y lo señala en `planesSoloLocales`.
*/
export async function listarPlanes(storage: StorageLike = storageDefault()): Promise<ResultadoPlanes> {
try {
const planes = (await request<PlanApi[]>('/planes')).map(desdeApi)
if (planes.length === 0) {
const locales = planesLocalesSinSembrar(storage)
if (locales.length > 0) {
console.warn('[cobranza] El API no tiene planes; se conservan los planes locales sin migrarlos')
return { planes: locales, origen: 'local', planesSoloLocales: true }
}
}
return { planes, origen: 'api', planesSoloLocales: false }
} catch (err) {
if (!esErrorDeRed(err)) throw err
console.warn('[cobranza] API no disponible; usando localStorage (modo offline):', err)
return { planes: cargarPlanes(storage), origen: 'local', planesSoloLocales: false }
}
}
/** GET /planes/:id. 404 del API → plan undefined (respuesta definitiva, sin fallback) */
export async function obtenerPlanPorId(
id: string,
storage: StorageLike = storageDefault(),
): Promise<{ plan: PlanPago | undefined; origen: OrigenDatos }> {
try {
const plan = await request<PlanApi>(`/planes/${encodeURIComponent(id)}`)
return { plan: desdeApi(plan), origen: 'api' }
} catch (err) {
if (err instanceof ErrorApi && err.status === 404) return { plan: undefined, origen: 'api' }
if (!esErrorDeRed(err)) throw err
console.warn('[cobranza] API no disponible; leyendo el plan desde localStorage:', err)
return { plan: obtenerPlan(id, storage), origen: 'local' }
}
}
/** POST /planes. Errores HTTP (validación) se propagan; solo la red cae a local */
export async function crearPlanAsync(
input: NuevoPlanInput,
storage: StorageLike = storageDefault(),
): Promise<ResultadoPlan> {
try {
const plan = await request<PlanApi>('/planes', { method: 'POST', body: JSON.stringify(input) })
return { plan: desdeApi(plan), origen: 'api' }
} catch (err) {
if (!esErrorDeRed(err)) throw err
console.warn('[cobranza] API no disponible; el plan se guardó solo en localStorage:', err)
return { plan: crearPlan(input, storage), origen: 'local' }
}
}
/** POST /planes/:id/abonos (estricto al centavo en el servidor) */
export async function registrarAbonoAsync(
planId: string,
abono: Omit<AbonoInput, 'pagoId'>,
pagoId: string,
storage: StorageLike = storageDefault(),
): Promise<ResultadoPlan> {
try {
const plan = await request<PlanApi>(`/planes/${encodeURIComponent(planId)}/abonos`, {
method: 'POST',
body: JSON.stringify({ ...abono, pagoId }),
})
return { plan: desdeApi(plan), origen: 'api' }
} catch (err) {
if (!esErrorDeRed(err)) throw err
console.warn('[cobranza] API no disponible; el abono se registró solo en localStorage:', err)
return { plan: registrarAbonoEnPlan(planId, abono, pagoId, storage), origen: 'local' }
}
}
/**
* POST /planes/:id/notas. El API devuelve solo la nota creada; se anexa al plan
* en memoria (las notas no afectan status, saldo ni calendario).
*/
export async function agregarNotaAsync(
plan: PlanPago,
texto: string,
storage: StorageLike = storageDefault(),
): Promise<ResultadoPlan> {
try {
const nota = await request<NotaPlan>(`/planes/${encodeURIComponent(plan.id)}/notas`, {
method: 'POST',
body: JSON.stringify({ texto }),
})
const actualizado: PlanPago = {
...plan,
notas: [...plan.notas, { id: nota.id, fecha: nota.fecha, texto: nota.texto }],
}
return { plan: actualizado, origen: 'api' }
} catch (err) {
if (!esErrorDeRed(err)) throw err
console.warn('[cobranza] API no disponible; la nota se guardó solo en localStorage:', err)
return { plan: agregarNota(plan.id, texto, storage), origen: 'local' }
}
}
/** POST /planes/:id/cancelar */
export async function cancelarPlanAsync(
id: string,
storage: StorageLike = storageDefault(),
): Promise<ResultadoPlan> {
try {
const plan = await request<PlanApi>(`/planes/${encodeURIComponent(id)}/cancelar`, { method: 'POST' })
return { plan: desdeApi(plan), origen: 'api' }
} catch (err) {
if (!esErrorDeRed(err)) throw err
console.warn('[cobranza] API no disponible; el plan se canceló solo en localStorage:', err)
return { plan: cancelarPlan(id, storage), origen: 'local' }
}
}

View File

@@ -6,20 +6,24 @@ interface UseUnidadesResult {
unidades: Unidad[] unidades: Unidad[]
cargando: boolean cargando: boolean
error: string | null error: string | null
/** true cuando el inventario viene del seed local (sin conexión al API) */
usandoSeed: boolean
} }
/** Carga el inventario de unidades (hoy desde seed; mañana desde el API Gateway) */ /** Carga el inventario de unidades desde el API Gateway, con fallback al seed local */
export function useUnidades(): UseUnidadesResult { export function useUnidades(): UseUnidadesResult {
const [unidades, setUnidades] = useState<Unidad[]>([]) const [unidades, setUnidades] = useState<Unidad[]>([])
const [cargando, setCargando] = useState(true) const [cargando, setCargando] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [usandoSeed, setUsandoSeed] = useState(false)
useEffect(() => { useEffect(() => {
let activo = true let activo = true
getUnidades() getUnidades()
.then((data) => { .then(({ unidades, usandoSeed }) => {
if (activo) { if (activo) {
setUnidades(data) setUnidades(unidades)
setUsandoSeed(usandoSeed)
setCargando(false) setCargando(false)
} }
}) })
@@ -34,5 +38,5 @@ export function useUnidades(): UseUnidadesResult {
} }
}, []) }, [])
return { unidades, cargando, error } return { unidades, cargando, error, usandoSeed }
} }

View File

@@ -37,7 +37,7 @@ const campoInputError = `${campoInputBase} border-red-400`
const etiquetaCampo = 'mb-2 block text-xs font-bold uppercase tracking-widest text-neutral-500' const etiquetaCampo = 'mb-2 block text-xs font-bold uppercase tracking-widest text-neutral-500'
export default function CotizadorPage() { export default function CotizadorPage() {
const { unidades, cargando, error } = useUnidades() const { unidades, cargando, error, usandoSeed } = useUnidades()
const cotizacion = useCotizacion() const cotizacion = useCotizacion()
const { const {
unidad, unidad,
@@ -99,6 +99,9 @@ export default function CotizadorPage() {
<aside className="space-y-16"> <aside className="space-y-16">
<section className="space-y-8 border border-neutral-200 bg-white p-6"> <section className="space-y-8 border border-neutral-200 bg-white p-6">
{error && <p className="text-xs text-red-700">{error}</p>} {error && <p className="text-xs text-red-700">{error}</p>}
{usandoSeed && !cargando && (
<p className="text-xs text-amber-700">Datos de demostración (sin conexión al servidor)</p>
)}
{cargando ? ( {cargando ? (
<p className="text-sm text-neutral-500">Cargando unidades…</p> <p className="text-sm text-neutral-500">Cargando unidades…</p>
) : ( ) : (

View File

@@ -0,0 +1,67 @@
import { describe, it, expect, vi, afterEach } from 'vitest'
import { getUnidades } from './api'
import { UNIDADES_SEED } from '../data/unidades'
import type { Unidad } from '../types'
const unidadApi: Unidad = {
codigo: 'U-101',
modelo: '2RA',
fechaEntrega: '2027-10-08',
supEstacionamiento: 28.62,
supConstruccion: 69.485,
supExcedente: 21.336,
precioSinAcabados: 4415000,
precioConAcabados: 4579000,
status: 'DISPONIBLE',
desarrollo: 'DIAGONAL',
}
function respuestaOk(body: unknown): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}
afterEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
describe('getUnidades', () => {
it('devuelve las unidades del API cuando la respuesta es OK', async () => {
const fetchMock = vi.fn().mockResolvedValue(respuestaOk([unidadApi]))
vi.stubGlobal('fetch', fetchMock)
const resultado = await getUnidades()
expect(fetchMock).toHaveBeenCalledWith('http://192.168.10.123:3000/api/unidades')
expect(resultado.usandoSeed).toBe(false)
expect(resultado.unidades).toEqual([unidadApi])
})
it('hace fallback al seed local cuando la respuesta no es OK', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(new Response('error', { status: 500 })),
)
const resultado = await getUnidades()
expect(resultado.usandoSeed).toBe(true)
expect(resultado.unidades).toEqual(UNIDADES_SEED)
expect(warn).toHaveBeenCalledOnce()
})
it('hace fallback al seed local cuando falla la red', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('fetch failed')))
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const resultado = await getUnidades()
expect(resultado.usandoSeed).toBe(true)
expect(resultado.unidades).toEqual(UNIDADES_SEED)
expect(warn).toHaveBeenCalledOnce()
})
})

View File

@@ -1,10 +1,28 @@
import type { Unidad } from '../types' import type { Unidad } from '../types'
import { UNIDADES_SEED } from '../data/unidades' import { UNIDADES_SEED } from '../data/unidades'
// TODO produccion: '/api' same-origin detras de NGINX
const API_URL = import.meta.env.VITE_API_URL ?? 'http://192.168.10.123:3000/api'
export interface GetUnidadesResult {
unidades: Unidad[]
/** true cuando se usó el seed local por no poder contactar el API */
usandoSeed: boolean
}
/** /**
* Devuelve el inventario de unidades. * Devuelve el inventario de unidades.
* TODO: reemplazar por fetch('/api/unidades') cuando el API Gateway esté disponible * Intenta GET {API_URL}/unidades; si falla (red o respuesta no OK),
* hace fallback al seed local con console.warn.
*/ */
export async function getUnidades(): Promise<Unidad[]> { export async function getUnidades(): Promise<GetUnidadesResult> {
return UNIDADES_SEED try {
const resp = await fetch(`${API_URL}/unidades`)
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const unidades = (await resp.json()) as Unidad[]
return { unidades, usandoSeed: false }
} catch (err) {
console.warn('[api] No se pudo obtener el inventario del API; usando seed local.', err)
return { unidades: UNIDADES_SEED, usandoSeed: true }
}
} }

View File

@@ -8,6 +8,7 @@ export interface Unidad {
precioSinAcabados: number // MXN precioSinAcabados: number // MXN
precioConAcabados: number // MXN precioConAcabados: number // MXN
status: 'DISPONIBLE' | 'APARTADA' | 'VENDIDA' status: 'DISPONIBLE' | 'APARTADA' | 'VENDIDA'
desarrollo?: string // lo agrega el API Gateway (Monday); el seed local no lo trae
} }
export interface Cliente { export interface Cliente {