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

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 {
Bar,
@@ -13,8 +13,8 @@ import {
XAxis,
YAxis,
} from 'recharts'
import type { PlanPago, StatusPlan } from '../types'
import { cargarPlanes } from '../services/repository'
import type { StatusPlan } from '../types'
import { listarPlanes, type ResultadoPlanes } from '../services/repository'
import {
alCorriente,
avancePlan,
@@ -45,7 +45,8 @@ function claveMes(fecha: Date): string {
}
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
const [searchParams, setSearchParams] = useSearchParams()
const busqueda = searchParams.get('q') ?? ''
@@ -56,6 +57,22 @@ export default function DashboardPage() {
const dir: 'asc' | 'desc' = searchParams.get('dir') === 'desc' ? 'desc' : 'asc'
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>) => {
setSearchParams(
(prev) => {
@@ -134,6 +151,14 @@ export default function DashboardPage() {
}))
}, [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) => {
if (filtroStatus !== 'TODOS' && d.status !== filtroStatus) return false
if (filtroProyecto !== 'TODOS' && d.plan.proyecto !== filtroProyecto) return false
@@ -193,6 +218,15 @@ export default function DashboardPage() {
</Link>
</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 */}
<div className="grid grid-cols-2 gap-6 lg:grid-cols-4">
<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 type { PlanPago } from '../types'
import { cargarPlanes } from '../services/repository'
import { listarPlanes, type ResultadoPlanes } from '../services/repository'
import { cobranzaDelDia, round2, statusPago } from '../lib/cobranza'
import { formatoFechaCorta, formatoMoneda } from '../lib/formatters'
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 */
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(), [])
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 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),
)
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 (
<div className="space-y-16">
<div>
@@ -25,6 +49,11 @@ export default function HoyPage() {
<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.
</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 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 { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { useNavigate } from 'react-router-dom'
import { crearPlan } from '../services/repository'
import { crearPlanAsync } from '../services/repository'
import { generarCalendario } from '../lib/cobranza'
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() {
const navigate = useNavigate()
const [guardando, setGuardando] = useState(false)
const [errorGuardado, setErrorGuardado] = useState<string | null>(null)
const {
register,
handleSubmit,
@@ -71,9 +74,17 @@ export default function NuevoPlanPage() {
vistaPrevia = null
}
const guardar = (datos: NuevoPlanForm) => {
const plan = crearPlan(datos)
void navigate(`/planes/${plan.id}`)
const guardar = async (datos: NuevoPlanForm) => {
setGuardando(true)
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 (
@@ -192,7 +203,8 @@ export default function NuevoPlanPage() {
</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
type="button"
onClick={() => void navigate('/')}
@@ -202,9 +214,10 @@ export default function NuevoPlanPage() {
</button>
<button
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"
>
Guardar plan
{guardando ? 'Guardando…' : 'Guardar plan'}
</button>
</div>
</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 type { Pago, PlanPago } from '../types'
import { agregarNota, cancelarPlan, obtenerPlan, registrarAbonoEnPlan } from '../services/repository'
import {
agregarNotaAsync,
cancelarPlanAsync,
obtenerPlanPorId,
registrarAbonoAsync,
} from '../services/repository'
import {
avancePlan,
diasAtraso,
@@ -23,7 +28,10 @@ import ModalAbono from '../components/ModalAbono'
export default function PlanDetallePage() {
const { id } = useParams<{ id: string }>()
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 [expandidos, setExpandidos] = useState<Set<string>>(new Set())
const [error, setError] = useState<string | null>(null)
@@ -31,6 +39,37 @@ export default function PlanDetallePage() {
const [errorNota, setErrorNota] = useState<string | null>(null)
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) {
return (
<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
try {
const actualizado = registrarAbonoEnPlan(plan.id, datos, pagoAbono.id)
setPlan(actualizado)
const r = await registrarAbonoAsync(plan.id, datos, pagoAbono.id)
setPlan(r.plan)
setOffline(r.origen === 'local')
setPagoAbono(null)
setError(null)
} 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
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 {
setPlan(agregarNota(plan.id, notaTexto))
const r = await agregarNotaAsync(plan, notaTexto)
setPlan(r.plan)
setOffline(r.origen === 'local')
setNotaTexto('')
setErrorNota(null)
} catch (e) {
@@ -120,6 +169,12 @@ export default function PlanDetallePage() {
</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 && (
<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"
onSubmit={(e) => {
e.preventDefault()
guardarNota()
void guardarNota()
}}
>
<label

View File

@@ -1,11 +1,44 @@
import type { PlanPago } from '../types'
import { cargarPlanes } from './repository'
// Cliente HTTP del API Gateway de FRASA (Express + Prisma)
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.
* TODO: reemplazar por fetch('/api/planes') cuando el API Gateway esté disponible
* TODO: cron de vencidos y envío WhatsApp van en el API Gateway (fase posterior)
* Error HTTP del API (respuesta con status >= 400, p. ej. validación o 404).
* NO amerita fallback offline: el servidor respondió y su respuesta manda.
*/
export async function getPlanes(): Promise<PlanPago[]> {
return cargarPlanes()
export class ErrorApi extends Error {
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 { generarCalendario, registrarAbono, statusPlan, agregarNotaAPlan, type AbonoInput } from '../lib/cobranza'
import { uuid } from '../lib/uuid'
import { ErrorApi, esErrorDeRed, request } from './api'
export const CLAVE_STORAGE = 'frasa_cobranza_v1'
// 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 {
return globalThis.localStorage
@@ -15,7 +16,7 @@ function storageDefault(): StorageLike {
/**
* Carga los planes desde localStorage; si está vacío siembra los datos de ejemplo.
* 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[] {
const crudo = storage.getItem(CLAVE_STORAGE)
@@ -113,3 +114,175 @@ export function agregarNota(
if (!plan) throw new Error('Plan no encontrado')
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' }
}
}