feat: ecosistema FRASA — apps Cotizador, Alta Clientes, Cobranza y Landing

- Cotizador (/quote): 4 propuestas con formulas validadas del Excel, comparador,
  combobox de unidades, vigencia, PDF ANEXO A
- Alta Clientes (/client): wizard 8 pasos, validacion CURP RENAPO, OCR de INE,
  checklist de expediente, firma digital, 5 PDFs legales
- Cobranza (/pay): planes de enganche, mora, vista cobranza del dia, recibos PDF,
  bitacora, dashboard con filtros
- Landing (/): entrada al ecosistema
- Design system StudioBlank (monocromo, acento #1B4332) en las 4 apps
- 123 tests vitest
This commit is contained in:
2026-08-21 22:27:36 +00:00
commit ecd4edac02
147 changed files with 27345 additions and 0 deletions

View File

@@ -0,0 +1,11 @@
import type { PlanPago } from '../types'
import { cargarPlanes } from './repository'
/**
* 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)
*/
export async function getPlanes(): Promise<PlanPago[]> {
return cargarPlanes()
}

View File

@@ -0,0 +1,116 @@
import { describe, expect, it } from 'vitest'
import {
CLAVE_STORAGE,
actualizarPlan,
agregarNota,
cargarPlanes,
cancelarPlan,
crearPlan,
guardarPlanes,
obtenerPlan,
registrarAbonoEnPlan,
} from './repository'
import type { PlanPago } from '../types'
/** Stub sencillo de localStorage para correr en entorno node */
function crearStorageStub(): Pick<Storage, 'getItem' | 'setItem'> {
const mapa = new Map<string, string>()
return {
getItem: (k: string) => mapa.get(k) ?? null,
setItem: (k: string, v: string) => {
mapa.set(k, v)
},
}
}
describe('repository (localStorage)', () => {
it('siembra datos de ejemplo cuando el storage está vacío', () => {
const storage = crearStorageStub()
const planes = cargarPlanes(storage)
expect(planes.length).toBe(4)
expect(storage.getItem(CLAVE_STORAGE)).not.toBeNull()
// segunda carga lee del storage, no vuelve a sembrar
expect(cargarPlanes(storage)).toEqual(planes)
})
it('round-trip: guardarPlanes → cargarPlanes devuelve lo mismo', () => {
const storage = crearStorageStub()
const planes = cargarPlanes(storage)
const modificados = planes.map((p) => ({ ...p, clienteNombre: `${p.clienteNombre} X` }))
guardarPlanes(modificados, storage)
expect(cargarPlanes(storage)).toEqual(modificados)
})
it('crearPlan persiste y obtiene el plan por id', () => {
const storage = crearStorageStub()
cargarPlanes(storage)
const plan = crearPlan(
{
clienteNombre: 'Nuevo Cliente',
unidadCodigo: 'U-999',
proyecto: 'DIAGONAL',
ventaRef: '',
fechaInicio: '2026-09-01',
engancheTotal: 60000,
numPagos: 6,
},
storage,
)
expect(plan.mensualidad).toBe(10000)
expect(plan.pagos).toHaveLength(6)
expect(obtenerPlan(plan.id, storage)).toEqual(plan)
expect(cargarPlanes(storage)).toHaveLength(5)
})
it('registrarAbonoEnPlan aplica el abono y deriva LIQUIDADO al cubrir todo', () => {
const storage = crearStorageStub()
cargarPlanes(storage)
let plan = crearPlan(
{
clienteNombre: 'Cliente Liquida',
unidadCodigo: 'U-500',
proyecto: 'SENDA',
ventaRef: '',
fechaInicio: '2026-09-01',
engancheTotal: 20000,
numPagos: 2,
},
storage,
)
plan = registrarAbonoEnPlan(plan.id, { fechaPago: '2026-09-01', monto: 10000, referencia: 'A1' }, plan.pagos[0].id, storage)
expect(plan.status).toBe('ACTIVO')
plan = registrarAbonoEnPlan(plan.id, { fechaPago: '2026-10-01', monto: 10000, referencia: 'A2' }, plan.pagos[1].id, storage)
expect(plan.status).toBe('LIQUIDADO')
expect(obtenerPlan(plan.id, storage)?.status).toBe('LIQUIDADO')
})
it('actualizarPlan y cancelarPlan persisten los cambios', () => {
const storage = crearStorageStub()
const [primero] = cargarPlanes(storage)
actualizarPlan({ ...primero, ventaRef: 'VTA-EDIT' }, storage)
expect(obtenerPlan(primero.id, storage)?.ventaRef).toBe('VTA-EDIT')
cancelarPlan(primero.id, storage)
expect(obtenerPlan(primero.id, storage)?.status).toBe('CANCELADO')
})
it('migra planes viejos sin notas agregando notas: []', () => {
const storage = crearStorageStub()
const [primero] = cargarPlanes(storage)
// Simula un registro guardado con una versión anterior del modelo
const viejo = { ...primero } as Partial<PlanPago>
delete viejo.notas
storage.setItem(CLAVE_STORAGE, JSON.stringify([viejo]))
const [migrado] = cargarPlanes(storage)
expect(migrado.notas).toEqual([])
})
it('agregarNota persiste la nota en la bitácora del plan', () => {
const storage = crearStorageStub()
const [primero] = cargarPlanes(storage)
const actualizado = agregarNota(primero.id, 'Se habló con el cliente', storage)
expect(actualizado.notas).toHaveLength(1)
expect(actualizado.notas[0].texto).toBe('Se habló con el cliente')
expect(obtenerPlan(primero.id, storage)?.notas).toHaveLength(1)
expect(() => agregarNota(primero.id, 'ok', storage)).toThrow(/al menos 3/)
})
})

View File

@@ -0,0 +1,115 @@
import type { PlanPago } from '../types'
import { generarSeed } from '../data/seed'
import { generarCalendario, registrarAbono, statusPlan, agregarNotaAPlan, type AbonoInput } from '../lib/cobranza'
import { uuid } from '../lib/uuid'
export const CLAVE_STORAGE = 'frasa_cobranza_v1'
// Inyectable para pruebas (stub en vitest, donde no hay localStorage)
type StorageLike = Pick<Storage, 'getItem' | 'setItem'>
function storageDefault(): StorageLike {
return globalThis.localStorage
}
/**
* 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.
*/
export function cargarPlanes(storage: StorageLike = storageDefault()): PlanPago[] {
const crudo = storage.getItem(CLAVE_STORAGE)
if (crudo) {
const planes = JSON.parse(crudo) as PlanPago[]
return planes.map((p) => (p.notas ? p : { ...p, notas: [] }))
}
const planes = generarSeed()
guardarPlanes(planes, storage)
return planes
}
export function guardarPlanes(planes: PlanPago[], storage: StorageLike = storageDefault()): void {
storage.setItem(CLAVE_STORAGE, JSON.stringify(planes))
}
export function obtenerPlan(id: string, storage: StorageLike = storageDefault()): PlanPago | undefined {
return cargarPlanes(storage).find((p) => p.id === id)
}
export interface NuevoPlanInput {
clienteNombre: string
clienteTelefono?: string
unidadCodigo: string
proyecto: string
ventaRef: string
fechaInicio: string
engancheTotal: number
numPagos: number
tasaMoraMensual?: number
}
export function crearPlan(input: NuevoPlanInput, storage: StorageLike = storageDefault()): PlanPago {
const { mensualidad, pagos } = generarCalendario(input.fechaInicio, input.engancheTotal, input.numPagos)
const plan: PlanPago = {
id: uuid(),
ventaRef: input.ventaRef,
clienteNombre: input.clienteNombre,
clienteTelefono: input.clienteTelefono || undefined,
unidadCodigo: input.unidadCodigo,
proyecto: input.proyecto,
fechaInicio: input.fechaInicio,
engancheTotal: input.engancheTotal,
numPagos: input.numPagos,
mensualidad,
tasaMoraMensual: input.tasaMoraMensual,
status: 'ACTIVO',
pagos,
notas: [],
createdAt: new Date().toISOString(),
}
guardarPlanes([...cargarPlanes(storage), plan], storage)
return plan
}
export function actualizarPlan(plan: PlanPago, storage: StorageLike = storageDefault()): PlanPago {
// El status operativo (ACTIVO/LIQUIDADO) se deriva del saldo; CANCELADO es manual
const actualizado: PlanPago = { ...plan, status: statusPlan(plan) }
guardarPlanes(
cargarPlanes(storage).map((p) => (p.id === actualizado.id ? actualizado : p)),
storage,
)
return actualizado
}
export function cancelarPlan(id: string, storage: StorageLike = storageDefault()): PlanPago {
const plan = obtenerPlan(id, storage)
if (!plan) throw new Error('Plan no encontrado')
const cancelado: PlanPago = { ...plan, status: 'CANCELADO' }
guardarPlanes(
cargarPlanes(storage).map((p) => (p.id === id ? cancelado : p)),
storage,
)
return cancelado
}
export function registrarAbonoEnPlan(
planId: string,
abono: Omit<AbonoInput, 'pagoId'>,
pagoId: string,
storage: StorageLike = storageDefault(),
): PlanPago {
const plan = obtenerPlan(planId, storage)
if (!plan) throw new Error('Plan no encontrado')
return actualizarPlan(registrarAbono(plan, { ...abono, pagoId }), storage)
}
/** Agrega una nota a la bitácora del plan y persiste el cambio */
export function agregarNota(
planId: string,
texto: string,
storage: StorageLike = storageDefault(),
): PlanPago {
const plan = obtenerPlan(planId, storage)
if (!plan) throw new Error('Plan no encontrado')
return actualizarPlan(agregarNotaAPlan(plan, texto), storage)
}