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:
11
apps/cobranza/src/services/api.ts
Normal file
11
apps/cobranza/src/services/api.ts
Normal 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()
|
||||
}
|
||||
116
apps/cobranza/src/services/repository.test.ts
Normal file
116
apps/cobranza/src/services/repository.test.ts
Normal 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/)
|
||||
})
|
||||
})
|
||||
115
apps/cobranza/src/services/repository.ts
Normal file
115
apps/cobranza/src/services/repository.ts
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user