El Flujo del Dinero
Sigue un peso desde el bolsillo del cliente hasta la cuenta bancaria del aliado. Este documento es el complemento financiero de 01-what-is-mi-plante.md. Si ese documento explica el producto, este explica la economía.
Tiempo de lectura: ~45 minutos. Pesado en diagramas Mermaid. Pausa y vuelve a leer las fórmulas.
1. El TL;DR
Solo hay cuatro actores de “dinero” en Mi Plante:
- Cliente — paga dinero a EMCALI como parte de su factura de servicios públicos.
- EMCALI — recauda del cliente, pasa la parte de Mi Plante a Mi Plante.
- Mi Plante — se queda con el margen de interés + seguro + fee de estudio + fianza, paga al aliado el monto original de la compra.
- Aliado — recibe el monto original de la compra por los productos entregados.
flowchart LR
C[Cliente] -->|Paga factura de servicios<br/>incl. cuota| EM[EMCALI]
EM -->|Reenvía porción<br/>de la cuota| MP[Mi Plante]
MP -->|Paga monto de compra| AL[Aliado]
MP -.->|Se queda con interés + seguro<br/>+ fee de estudio + fianza| MP
AL -->|Entrega producto| C
style C fill:#fef3c7,stroke:#b45309
style EM fill:#dbeafe,stroke:#1e40af
style MP fill:#dcfce7,stroke:#166534
style AL fill:#fce7f3,stroke:#9d174d
La pregunta interesante es qué hay realmente dentro de una cuota y cómo Mi Plante calcula cada pieza.
2. Lo que paga el cliente — anatomía de una cuota
Una fila Cuota en la base de datos tiene estos campos (database/migrations/2025_11_06_140606_create_cuotas_table.php y 2025_11_20_000002_add_interes_seguro_to_cuotas_table.php):
| Campo | Qué es | Fórmula |
|---|---|---|
monto | Monto total adeudado por esta cuota | suma de todos los componentes abajo |
interes | Interés sobre capital remanente | principalRestante × tasa_nominal |
monto_seguro_vida | Seguro de vida mensual | principal × seguro_vida (fijo sobre el monto inicial) |
monto_fianza | Componente de fianza/garantía | (principal × porcentaje_fianza) / cuartoDeCuotas para el primer cuarto de cuotas; 0 en lo demás |
monto_pagado | Cuánto se ha pagado hasta ahora | empieza en 0 |
fecha_vencimiento | Fecha de vencimiento para esta cuota | fecha de la venta + N meses |
| (capital) | Porción de repago del capital | derivado: pmt - interes, o principal_restante en la última cuota |
| (estudio) | Fee único de estudio de crédito | monto_estudio_credito solo en la cuota 1, 0 en lo demás |
Nota: capital y estudio no se almacenan como columnas separadas; están plegadas en monto. El desglose es visible en App\Services\VentaService::generarCuotas() en app/Services/VentaService.php:562-632.
La fórmula (el sistema de amortización francés)
Mi Plante usa amortización estándar del sistema francés con una tasa mensual fija. Cada mes el cliente paga un pago base constante (capital + interés), más los componentes variables (seguro, fee de estudio en el mes 1, fianza en el primer cuarto).
Pago base (capital + interés), constante a través de las cuotas:
Para un capital P, tasa mensual r, y n cuotas totales:
PMT = P × ( r × (1 + r)^n ) / ( (1 + r)^n - 1 ) si r > 0PMT = P / n si r = 0Dentro de cada cuota:
interes_cuota_i = saldo_inicial_i × rcapital_cuota_i = PMT - interes_cuota_isaldo_final_i = saldo_inicial_i - capital_cuota_iEn la última cuota, el capital se fuerza a igualar el saldo remanente para cerrar residuales.
Seguro (seguro_vida):
Constante a través de todas las cuotas. Calculado sobre el capital inicial, no sobre el saldo decreciente.
seguro_cuota = principal × seguro_vidaFianza:
La fianza total se calcula como principal × porcentaje_fianza. Repartida a través del primer cuarto de cuotas (cuartoDeCuotas = floor(numero_cuotas / 4)):
fianza_por_cuota = (principal × porcentaje_fianza) / cuartoDeCuotas para cuotas 1 a cuartoDeCuotasfianza_por_cuota = 0 en lo demásCuidado: App\Services\VentaService::generarCuotas() en app/Services/VentaService.php:579 calcula $fianzaPorCuotas = round(($principal * $porcentajeFianza), 2); y nunca divide por cuartoDeCuotas antes de aplicar. Este es el hallazgo crítico F-FIN-2 de docs/audit/17-critical-additional-findings.md — el resultado es un sobrecargo del 200-300% en la fianza. Trátalo como un bug real, no como una peculiaridad de documentación.
Fee de estudio:
Único, cobrado solo en la cuota 1:
estudio_cuota_1 = monto_estudio_creditoestudio_cuota_i = 0 para i > 1Monto total de la cuota i:
monto_i = capital_i + interes_i + seguro_i + estudio_i + fianza_iReferencia en el frontend
La misma matemática vive en TypeScript en resources/js/lib/credit.ts como calculateCredit(). El simulador en /simulador-cuotas llama a esto. La interfaz:
calculateCredit({ amount, // principal months, // numero_cuotas monthlyRate, // tasa_nominal lifeInsuranceRate, // seguro_vida studyFee, // monto_estudio_credito bailRate // porcentaje_fianza}) -> CreditResultLa matemática del frontend debería reflejar exactamente la del backend. En gran medida lo hace, excepto que la lógica de fianza del frontend está más cerca de ser “correcta” que la del backend — credit.ts:71-73 calcula monthlyBail = totalBailCalculated / monthsWithBail, que es la fórmula correcta. El backend en VentaService.php:579 se salta esa división. Por lo tanto, el simulador muestra una cuota más pequeña que la que el cliente realmente recibe en su factura. Este es uno de los temas recurrentes de soporte al cliente mencionados en la Persona M-2 (Esteban).
3. El simulador
Mi Plante expone un simulador público para que los prospectos puedan jugar con las cuotas antes de registrarse.
- Página:
resources/js/pages/Simulator/Page.vue(o similar; la convención espages/Simulator/). - Ruta:
GET /simulador-cuotas(técnicamente dentro del grupo de middlewareauthpero usa->withoutMiddleware('auth'), así que funcionalmente es pública). - Llamadas:
calculateCredit()desderesources/js/lib/credit.ts, sin roundtrip al backend.
Los inputs del simulador están expuestos como shared props de Inertia (HandleInertiaRequests::share() → credito.tasa_nominal, credito.seguro_vida, etc.), así que las constantes vienen directo de config/app.php.
Esto es genial para marketing — los clientes ven un pago mensual pequeño y convierten. Es malo cuando la matemática del simulador diverge de la generación real de cuotas, porque el cliente se registra esperando el número del simulador y le facturan el número más grande del backend.
Acción para un nuevo desarrollador: cuando cambies VentaService::generarCuotas(), cambia resources/js/lib/credit.ts en el mismo PR. Deben mantenerse sincronizados.
4. La configuración de crédito (shared props)
Los cuatro números clave son variables de entorno, leídas por config/app.php, y compartidas con cada página de Inertia a través de HandleInertiaRequests::share() en app/Http/Middleware/HandleInertiaRequests.php:94-100:
'credito' => [ 'dias_desfase' => config('app.dias_desfase', 0), 'tasa_nominal' => config('app.tasa_nominal', 0), 'seguro_vida' => config('app.seguro_vida', 0), 'porcentaje_fianza' => config('app.porcentaje_fianza', 0), 'monto_estudio_credito'=> config('app.monto_estudio_credito', 0),],| Constante | Variable de entorno | Default de .env.example | Qué controla |
|---|---|---|---|
tasa_nominal | TASA_NOMINAL | 0.01914 (1.914% mensual) | Tasa nominal de interés mensual |
seguro_vida | SEGURO_VIDA | 0.01 (1% del capital mensual) | Factor de seguro de vida aplicado a cada cuota |
monto_estudio_credito | MONTO_ESTUDIO_CREDITO | 25000 (COP) | Fee único de estudio de crédito, agregado a la cuota 1 |
porcentaje_fianza | PORCENTAJE_FIANZA | sin definir (default a null → 0) | Factor de fianza/garantía |
dias_desfase | DIAS_DESFASE | 0 | Desfase en días al programar las fechas de vencimiento de las cuotas (hoy es 0) |
Quién puede cambiarlas: nadie desde el lado del cliente. Viven en variables de entorno de producción y requieren un deploy (o php artisan config:clear) para tomar efecto.
Por qué están en shared props: para que el simulador y el resumen de cuotas en la pantalla de checkout puedan renderizar los números correctos sin una llamada extra a la API. Si las quitas de shared props, espera que el frontend muestre silenciosamente tasas del 0%.
Advertencia sobre prod: .env.example muestra defaults pero producción puede usar números diferentes. Solo podemos hablar de lo que está en el repo. Si el equipo de prod quiere cambiar una tasa, la práctica operativa es actualizar la variable de entorno y correr php artisan config:clear en el servidor de prod.
5. El Pagaré — el derecho legal de recaudo
Una vez que el cliente es aprobado y tiene un cronograma de cuotas, firma un pagaré digital a través de Certicámara. Este es el instrumento legal que hace ejecutable el derecho de cobro de Mi Plante.
En la ley colombiana, un pagaré:
- Es un título valor (instrumento negociable) bajo el Código de Comercio.
- Le da al tenedor (Mi Plante) un reclamo directamente ejecutable contra el firmante (el cliente) sin primero probar la transacción subyacente.
- Una vez firmado digitalmente vía Certicámara (una Entidad de Certificación Digital reconocida legalmente), es admisible como evidencia en cortes colombianas.
Quién firma: el Cliente. La identidad es verificada por Certicámara a través de su propio flujo de validación de identidad (el mismo ecosistema Experian/DataCrédito, por separado).
Qué almacena Mi Plante: Cliente.certicamara_uuid (el identificador de pagaré de Certicámara) y Cliente.pagare_firmado_en (cuándo ocurrió la firma).
Por qué importa operativamente:
- Sin un pagaré firmado, Mi Plante no puede perseguir legalmente el cobro si el cliente entra en default. El cupo podría estar asignado, pero la palanca legal está ausente.
- El pagaré se firma una vez por cliente (cubre el cupo, no cada venta individual). Las ventas posteriores reutilizan el mismo pagaré.
Código de integración: App\Services\CerticamaraService::crearPagare(). Retorna el UUID. La firma real ocurre fuera de banda del lado de Certicámara; el cliente recibe un enlace por email desde Certicámara.
El webhook: Certicámara envía un webhook a Mi Plante cuando el cliente termina de firmar. El webhook dispara App\Jobs\ValidarPagareDigital → App\Jobs\ProcesarPagareDigital → App\Jobs\GenerarCreditoDeVenta. Ver docs/audit/05-queue-catalog.md para la cadena.
Bug conocido: VentaService::registrarEnCerticamara() siempre retorna false en app/Services/VentaService.php:499, incluso cuando el pagaré fue creado exitosamente y Cliente.certicamara_uuid fue actualizado. Esto rompe el condicional que despacharía la generación inmediata de crédito. Ver docs/onboarding/04-the-landmines/ para el catálogo completo de landmines.
6. La integración con EMCALI
App\Services\EmcaliMembresiaService existe en el repo (app/Services/EmcaliMembresiaService.php). Lo que podemos confirmar desde el código:
- Llama a una API de membresía de EMCALI (
EMCALI_MEMBRESIAS_API_URLdesde env). - Se invoca durante
completar-registropara verificar que el cliente es el titular de la cuenta EMCALI en la dirección dada y que los números recientes de factura que proporcionó coinciden. - Se invoca durante la fase de extensión de cupo (Fase 7) para considerar el estatus de la cuenta EMCALI en la decisión del cupo.
- Las respuestas se cachean por 24h (por convención del servicio).
Lo que no podemos confirmar solo desde el código:
- El mecanismo por el cual las cuotas de Mi Plante son realmente agregadas a la factura mensual de EMCALI del cliente. No hay un
EmcaliBillingService::addCuotaToBill()en el repo. No hay una llamada saliente documentada de Mi Plante → EMCALI en ningún lado que empuje una cuota para facturación. - El mecanismo por el cual EMCALI le pasa el dinero recaudado de la cuota de vuelta a Mi Plante.
- El flujo de conciliación si el recaudo de EMCALI difiere de la facturación esperada de Mi Plante.
Evaluación honesta: La historia de “EMCALI es el rail de recaudo” es la narrativa del producto. En el código, EMCALI hoy es principalmente un rail de verificación (identidad + decisión de cupo). El mecanismo real de recaudo es opaco desde el repo y probablemente sea una conciliación contable manual hoy.
Esto vale la pena marcarlo como un área de Tier-1 a investigar con tu tech lead. Brecha de cobertura: docs/audit/10-external-integrations-map.md confirma que EMCALI se trata como una integración solo entrante en el código de hoy.
7. El flujo de payout al aliado
Cómo y cuándo un aliado realmente recibe pago de Mi Plante por una venta entregada.
Lo que podemos ver en el codebase:
- Se crea una fila
Ventacuando un cliente compra. - La
Ventatiene un enumestadoque progresaPENDIENTE → APROBADA → ENTREGADA → LEGALIZADA → COMPLETADA(verApp\Enum\Facturacion\EstadoVenta). - La
Ventapertenece a unaSucursal, que pertenece a unaEmpresa(el aliado). - No hay un modelo
Pago a Aliado, no hay unPagoAliadoService, no hay un job de cola de payout, no hay un flujo automatizado de transferencia bancaria.
Lo que inferimos:
- El payout al aliado es actualmente un proceso manual o externo. El equipo financiero de Mi Plante probablemente concilia las ventas entregadas por mes, corre un cálculo de payout en hojas de cálculo, y dispara transferencias bancarias manualmente.
- Los estados
LEGALIZADAyCOMPLETADApodrían ser el trigger del payout, pero se setean sin triggers explícitos de código en el repo actual (pordocs/audit/08-sale-lifecycle-state-machine.md).
Acción para el nuevo equipo de desarrollo:
- Trata el flujo de payout al aliado como una caja negra por ahora. No asumas que el código lo hace. No escribas funcionalidades que dependan de que esté automatizado.
- Marca esto como una investigación de Tier-2: “¿cuál es la cadencia real del payout y hay una oportunidad de traerlo dentro de la aplicación?”.
Esta brecha es una fuente probable de dolor operativo al escalar: hacer payouts por aliado en hoja de cálculo no sobrevive más allá de 50-100 aliados.
8. Escenarios de default — qué ocurre cuando un cliente no paga
El recorrido por defecto: el cliente no paga su factura de servicios públicos de EMCALI completa, incluyendo la porción de cuota de Mi Plante.
sequenceDiagram
autonumber
actor C as Cliente
participant EM as Factura EMCALI
participant MP as Mi Plante
participant L as Legal / Cobro
C-)EM: No paga
EM->>MP: Reporta cuota impaga
MP->>MP: Cliente.estado_mora actualizado<br/>(o flag equivalente)
MP->>MP: Middleware VerificarClientePresentaMora<br/>bloquea nuevas compras
Note over MP: El cliente no puede iniciar<br/>nuevas ventas hasta ponerse al día
MP->>C: Notificaciones (recordatorios de mora)
Note over MP,L: Si la mora persiste,<br/>Mi Plante invoca el Pagaré
MP->>L: Persigue cobro legal<br/>usando el Pagaré firmado como evidencia
En el código de hoy:
- Middleware
VerificarClientePresentaMora(app/Http/Middleware/): bloquea la rutaGET /checkoutsi el cliente está en mora. Implementado como un guard sobrecliente.estado_morao flag equivalente. - No hay cálculo automatizado de mora en el repo que hayamos encontrado. La marca de mora del cliente debe setearse externamente (probablemente por un proceso de operaciones que lee el estado de pago de EMCALI y actualiza la base de datos).
- No hay secuencia automatizada de dunning / cobro en el codebase. No hay emails de recordatorio programados, no hay flujo de escalamiento.
Trata el manejo de mora, como el flujo de payout, como actualmente externo. El middleware es la única enforcement que vemos, y depende de que alguien (o algún job programado que no está en el repo) mantenga la marca de mora fresca.
El pagaré es la palanca legal si el cliente realmente entra en default: le da a Mi Plante un reclamo directamente ejecutable a través de la ley comercial colombiana. Pero invocar esa palanca es un proceso legal manual, no algo automatizado en la app.
9. Unit economics — una venta, end-to-end
Tracemos una venta específica para ver cómo se mueve el dinero y cómo Mi Plante gana.
Escenario:
- Cliente: María (Persona C-1)
- Compra: Una lavadora, 1.500.000 COP, de aliado “ElectroExpress”
- Plazo: 12 cuotas
- Constantes (defaults de
.env.example):tasa_nominal: 0.01914 (1.914% mensual)seguro_vida: 0.01 (1% mensual)monto_estudio_credito: 25.000 COP (único, cuota 1)porcentaje_fianza: asumido 0 para esta ilustración (frecuentemente sin configurar)
Paso 1 — PMT (pago base, capital + interés)
PMT = 1,500,000 × (0.01914 × 1.01914^12) / (1.01914^12 - 1) = 1,500,000 × (0.01914 × 1.25530) / (0.25530) ≈ 141,165 COP por mesPaso 2 — Seguro
seguro_cuota = 1,500,000 × 0.01 = 15,000 COP por mesPaso 3 — Total por cuota (cuotas 2-12)
cuota_i (i > 1) = capital_i + interes_i + seguro ≈ 141,165 + 15,000 ≈ 156,165 COP(El capital y el interés se desplazan a través de las cuotas; su suma iguala PMT hasta el ajuste de redondeo de la última cuota.)
Paso 4 — Primera cuota (incluye fee de estudio)
cuota_1 ≈ 141,165 + 15,000 + 25,000 ≈ 181,165 COPPaso 5 — Total sobre 12 meses
total_pagado ≈ 11 × 156,165 + 181,165 ≈ 1,717,815 + 181,165 ≈ 1,898,980 COPPaso 6 — Desglose del margen
capital_recuperado = 1,500,000 (devuelto al aliado en la entrega)interes_recaudado ≈ 1,898,980 - 1,500,000 - 25,000 - (12 × 15,000) ≈ 1,898,980 - 1,500,000 - 25,000 - 180,000 ≈ 193,980 COPseguro_recaudado = 12 × 15,000 = 180,000 COPfee_estudio = 25,000 COPIngreso bruto de Mi Plante en esta venta: ~398.980 COP (interés + seguro + fee de estudio), asumiendo que el cliente paga cada cuota completa.
De este bruto, Mi Plante tiene que restar:
- Costo de fondear el préstamo de 1.500.000 COP (costo de capital).
- Reaseguro / payout real al asegurador de vida (el
seguro_vidarecaudado no es margen puro — cubre la responsabilidad real del seguro de vida). - Reserva de default (algún % de los préstamos entra en default, comiéndose el capital + interés recaudado).
- Costos operativos (el pipeline de crédito cuesta dinero por aprobación: fees de TransUnion, fees de Experian, fees de DataCrédito, Certicámara por firma, costos de integración con EMCALI).
Margen neto por venta: esto depende de la tasa de default y del costo de fondos. Ambos están fuera del scope del repo. Si quieres modelarlo, toma una tasa de default del 5-10% como supuesto de planeación y costo neto de capital de ~10-15% efectivo.
Importante: esta es la matemática cuando el código está correcto
Los números anteriores asumen que VentaService::generarCuotas() está haciendo lo correcto. Por docs/audit/16-deep-validation-study.md y 17-critical-additional-findings.md, hay al menos dos bugs que distorsionan esto:
- L-15 (F-001):
procesarCarrito()multiplica dos vecescantidad × precio. Si María compra 2 unidades, el subtotal se vuelve 2 × 2 = 4 × precio unitario. Las cuotas salen ~2× lo que deberían. - L-08 (F-FIN-2): El cálculo de fianza olvida dividir por
cuartoDeCuotas. Siporcentaje_fianzase establece en un valor distinto de cero, el primer cuarto de cuotas sobrecarga 2-3x.
Si estás razonando sobre facturación de producción real hoy, asume que el cliente está pagando 0-200% más que lo que sugiere la matemática anterior, dependiendo de la cantidad y la configuración de la fianza.
10. Dónde se mueve el dinero en el código — mapa de archivos
Referencia rápida para la próxima vez que necesites rastrear un peso.
| Ruta | Rol |
|---|---|
app/Services/CreditoService.php | Crea la línea de crédito en el sistema bancario back-office Core Crédito (SHIVAM). Se llama cuando una Venta es aprobada. |
app/Services/VentaService.php | Creación de venta; crearVenta() calcula subtotal (la línea 152-154 es donde vive el bug L-15), generarCuotas() en la línea 562 produce el cronograma de cuotas. |
app/Services/VentaService.php línea 644 | generarCuotasPorOrden() — genera cuotas “maestras” a nivel de orden para órdenes multi-aliado. |
app/Jobs/GenerarCreditoDeVenta.php | Job asíncrono disparado después de la firma del pagaré. Llama a CreditoService, decrementa Cliente.cupo_disponible, setea Venta.estado = APROBADA. |
app/Jobs/ProcesarPagareDigital.php | Disparado por el webhook de Certicámara. Re-genera cuotas (esta es la fuente del bug L-10 de duplicación). |
app/Jobs/ValidarPagareDigital.php | Hace polling a Certicámara para confirmar el estado de la firma. La idempotencia vive aquí. |
app/Models/Facturacion/Cuota.php | El modelo Cuota con las columnas reales y métodos accesor. |
app/Models/Facturacion/Venta.php | El modelo Venta. Tiene total, subtotal, numero_cuotas, estado, FK a OrdenCompra y Sucursal. |
app/Models/Cliente.php | Tiene cupo_asignado, cupo_disponible, pagare_firmado_en, certicamara_uuid. La exposición de mass assignment es F-002 en el audit doc 17. |
app/Services/EmcaliMembresiaService.php | Verificación de EMCALI (no facturación — ver advertencia en la Sección 6). |
app/Services/CerticamaraService.php | Creación del pagaré. verificarPagare() existe pero no está cableado al flujo de producción. |
app/Services/AprobarCupoService.php | Chequeo final de aprobación: >= 5 ProcessType con FINISH_SUCCESS en el mes actual. |
resources/js/lib/credit.ts | Espejo en TypeScript de la matemática de cuotas. Usado por el simulador. Debe permanecer sincronizado con VentaService::generarCuotas(). |
resources/js/pages/Simulator/Page.vue (aprox) | La página del simulador cara al cliente. |
resources/js/pages/Checkout/... | Donde los clientes ven su cronograma real de cuotas antes de que se confirme la compra. |
11. Matemática del frontend vs. matemática del backend — el contrato
El simulador (frontend) y la creación real de venta (backend) calculan las cuotas en dos lenguajes diferentes:
- Frontend:
resources/js/lib/credit.ts→calculateCredit()en TypeScript. - Backend:
app/Services/VentaService.php→generarCuotas()en PHP.
Deberían producir números idénticos para los mismos inputs. Hoy no lo hacen, en dos lugares que conocemos:
- Cálculo de fianza: el frontend divide la fianza total por
monthsWithBail(correcto). El backend enVentaService.php:579se salta la división (incorrecto). - Multiplicación de cantidad: el backend tiene el bug de doble multiplicación en
procesarCarrito(). El simulador del frontend no tiene un concepto de “cantidad” para nada — solo tomaamount. Así que un cliente usando el simulador con1.000.000 COP × 2 unidades = 2.000.000 COPvería números correctos paraamount = 2,000,000pero el backend le cobraría sobre4,000,000.
Regla para cualquier cambio en la matemática de crédito: cambia ambos archivos en el mismo PR. Agrega un test que asegure que están de acuerdo en un conjunto fijo de inputs. El test debería fallar hoy (lo cual está bien — documenta la divergencia).
Un esbozo inicial de test:
// tests/Feature/Market/CreditoMathConsistencyTest.php (nombre sugerido)public function test_simulator_and_venta_service_agree_on_simple_case(){ $principal = 1_500_000; $months = 12; $rate = 0.01914; $insurance = 0.01; $studyFee = 25_000;
// Backend $venta = Venta::factory()->create([ 'total' => $principal, 'numero_cuotas' => $months, ]); $service = app(VentaService::class); $service->generarCuotas($venta); $cuotas = $venta->cuotas->sortBy('numero_cuota')->values();
// Frontend (espejado en PHP para el test) $expected = $this->calculateCreditMirror($principal, $months, $rate, $insurance, $studyFee);
// Aserta por cuota y total $this->assertEquals($expected['totalPayment'], $cuotas->sum('monto'), '', 1.0);}Esta es una tarea candidata para primera contribución: escribe ese test de consistencia y déjalo fallar, luego arregla las divergencias una a una.
12. Los dos desfases de tiempo que muerden
Dos cosas específicas de tiempo para tener en la cabeza.
dias_desfase
DIAS_DESFASE desplaza las fechas de vencimiento de cuotas en N días desde la fecha de creación de la venta. Hoy por defecto es 0, lo que significa que las cuotas vencen exactamente un mes después de la fecha de venta. Si Mi Plante empieza a alinear con los ciclos de facturación de EMCALI (e.g. todas las cuotas vencen el 15 de cada mes), esto se volvería distinto de cero. A la fecha de hoy, ninguna lógica en el repo lo usa excepto como valor de config.
creado_en vs created_at
La clase base App\Models\Modelo usa columnas de timestamp en español (creado_en, actualizado_en). La convención del framework es created_at / updated_at. Cuando VentaService::generarCuotas() hace:
$fechaVencimiento = $venta->created_at ?? now();…y el modelo Venta hereda de Modelo (que renombra la columna de timestamp), $venta->created_at es null en tiempo de ejecución, así que el fallback now() se dispara. Esto significa que las fechas de vencimiento de cuotas se programan desde el momento en que corre el job, no desde el tiempo real de creación de la venta.
Este es el hallazgo #9 en docs/audit/16-deep-validation-study.md. También es la razón por la que la persona de soporte al cliente Esteban recibe tickets donde las fechas de cuotas no coinciden con lo que el cliente esperaba.
Dirección del fix: cambiar $venta->created_at por $venta->creado_en (o mapear correctamente el timestamp en el Modelo base). Coordina con el fix de L-15 porque ambos viven en el mismo camino.
13. Orden de lectura desde aquí
Ya has visto el producto (01-what-is-mi-plante.md), los usuarios (02-who-are-the-users.md), y el dinero (este doc).
Sigue, dirígete a:
docs/onboarding/02-codebase-tour/— Día 1 tarde. El recorrido real del codebase. Estás listo para leer código ahora que entiendes el dominio.docs/onboarding/03-development-setup/01-local-environment.md— Día 2 mañana. Pon la app a correr.docs/audit/12-credit-approval-workflow-diagram.md— cuando quieras el diagrama Mermaid completo de las 7 fases.docs/audit/16-deep-validation-study.mdy17-critical-additional-findings.md— cuando estés listo para enfrentar los landmines de frente.
Apéndice A — Mermaid: la cadena orden-a-cuota
Para referencia, esta es la secuencia de eventos desde “cliente hace click en checkout” hasta “las cuotas existen en la DB”.
sequenceDiagram
autonumber
actor C as Cliente
participant FE as Vue / Inertia
participant VC as VentaController
participant VS as VentaService
participant CER as Certicamara
participant Q as Queue
participant CR as CreditoService
participant DB as MySQL
C->>FE: Click "Confirmar compra"
FE->>VC: POST /mis-compras
VC->>VS: crearVenta(dto)
VS->>DB: INSERT venta, venta_detalles, orden_compra
VS->>VS: generarCuotas(venta) <br/>(primera generación - fuente del bug L-10)
VS->>DB: INSERT cuotas
VS->>CER: crearPagare(cliente, dto)
Note over VS: registrarEnCerticamara()<br/>siempre retorna false (L-08)
CER-->>VS: pagare_uuid
VS-->>VC: Venta con cuotas
VC-->>FE: 200 OK
Note over C,CER: El cliente firma el pagaré<br/>a través del enlace de email de Certicamara
CER->>VC: Webhook: pagare_signed
VC->>Q: dispatch(ValidarPagareDigital)
Q->>Q: ValidarPagareDigital corre
Q->>Q: ProcesarPagareDigital corre<br/>(regenera cuotas - bug L-10)
Q->>DB: INSERT cuotas (¡duplicados!)
Q->>Q: GenerarCreditoDeVenta corre
Q->>CR: crearCredito(venta)
CR->>DB: UPDATE cliente cupo_disponible<br/>(update no-atómico - L-12)
Q->>DB: UPDATE venta estado = APROBADA
Note over Q,DB: El cliente ya puede usar el cupo;<br/>las cuotas aparecen en la factura de EMCALI
Las anotaciones L-08, L-10, L-12 son los landmines que te encontrarás en docs/onboarding/04-the-landmines/.
Apéndice B — Hoja rápida del “mapa del dinero”
Cuando necesites responder la pregunta “¿dónde está el dinero en esta venta?”, escanea en este orden:
Venta.subtotal— lo que costaron los productos (o el doble, si L-15 muerde).Venta.total— igual al subtotal en casos de un solo detalle, o la suma en multi-detalle.Cuota.monto— lo que el cliente paga por cuota.Cliente.cupo_disponible— cuánto headroom de crédito le queda al cliente.- EMCALI — donde el dinero se recauda realmente (fuera del repo).
- Rails bancarios externos — donde reposa antes de ser pagado al aliado (también fuera del repo).
Si puedes responder “¿en qué parte de esta cadena está el bug?” antes de abrir el debugger, vas a ser dos veces más rápido que alguien pescando a ciegas.