Saltearse al contenido

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:

  1. Cliente — paga dinero a EMCALI como parte de su factura de servicios públicos.
  2. EMCALI — recauda del cliente, pasa la parte de Mi Plante a Mi Plante.
  3. Mi Plante — se queda con el margen de interés + seguro + fee de estudio + fianza, paga al aliado el monto original de la compra.
  4. 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):

CampoQué esFórmula
montoMonto total adeudado por esta cuotasuma de todos los componentes abajo
interesInterés sobre capital remanenteprincipalRestante × tasa_nominal
monto_seguro_vidaSeguro de vida mensualprincipal × seguro_vida (fijo sobre el monto inicial)
monto_fianzaComponente de fianza/garantía(principal × porcentaje_fianza) / cuartoDeCuotas para el primer cuarto de cuotas; 0 en lo demás
monto_pagadoCuánto se ha pagado hasta ahoraempieza en 0
fecha_vencimientoFecha de vencimiento para esta cuotafecha de la venta + N meses
(capital)Porción de repago del capitalderivado: pmt - interes, o principal_restante en la última cuota
(estudio)Fee único de estudio de créditomonto_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 > 0
PMT = P / n si r = 0

Dentro de cada cuota:

interes_cuota_i = saldo_inicial_i × r
capital_cuota_i = PMT - interes_cuota_i
saldo_final_i = saldo_inicial_i - capital_cuota_i

En 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_vida

Fianza:

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 cuartoDeCuotas
fianza_por_cuota = 0 en lo demás

Cuidado: 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_credito
estudio_cuota_i = 0 para i > 1

Monto total de la cuota i:

monto_i = capital_i + interes_i + seguro_i + estudio_i + fianza_i

Referencia 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
}) -> CreditResult

La 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 es pages/Simulator/).
  • Ruta: GET /simulador-cuotas (técnicamente dentro del grupo de middleware auth pero usa ->withoutMiddleware('auth'), así que funcionalmente es pública).
  • Llamadas: calculateCredit() desde resources/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),
],
ConstanteVariable de entornoDefault de .env.exampleQué controla
tasa_nominalTASA_NOMINAL0.01914 (1.914% mensual)Tasa nominal de interés mensual
seguro_vidaSEGURO_VIDA0.01 (1% del capital mensual)Factor de seguro de vida aplicado a cada cuota
monto_estudio_creditoMONTO_ESTUDIO_CREDITO25000 (COP)Fee único de estudio de crédito, agregado a la cuota 1
porcentaje_fianzaPORCENTAJE_FIANZAsin definir (default a null → 0)Factor de fianza/garantía
dias_desfaseDIAS_DESFASE0Desfase 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.


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\ValidarPagareDigitalApp\Jobs\ProcesarPagareDigitalApp\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_URL desde env).
  • Se invoca durante completar-registro para 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 Venta cuando un cliente compra.
  • La Venta tiene un enum estado que progresa PENDIENTE → APROBADA → ENTREGADA → LEGALIZADA → COMPLETADA (ver App\Enum\Facturacion\EstadoVenta).
  • La Venta pertenece a una Sucursal, que pertenece a una Empresa (el aliado).
  • No hay un modelo Pago a Aliado, no hay un PagoAliadoService, 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 LEGALIZADA y COMPLETADA podrían ser el trigger del payout, pero se setean sin triggers explícitos de código en el repo actual (por docs/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 ruta GET /checkout si el cliente está en mora. Implementado como un guard sobre cliente.estado_mora o 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 mes

Paso 2 — Seguro

seguro_cuota = 1,500,000 × 0.01 = 15,000 COP por mes

Paso 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 COP

Paso 5 — Total sobre 12 meses

total_pagado ≈ 11 × 156,165 + 181,165
≈ 1,717,815 + 181,165
≈ 1,898,980 COP

Paso 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 COP
seguro_recaudado = 12 × 15,000 = 180,000 COP
fee_estudio = 25,000 COP

Ingreso 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_vida recaudado 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 veces cantidad × 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. Si porcentaje_fianza se 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.

RutaRol
app/Services/CreditoService.phpCrea 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.phpCreació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 644generarCuotasPorOrden() — genera cuotas “maestras” a nivel de orden para órdenes multi-aliado.
app/Jobs/GenerarCreditoDeVenta.phpJob asíncrono disparado después de la firma del pagaré. Llama a CreditoService, decrementa Cliente.cupo_disponible, setea Venta.estado = APROBADA.
app/Jobs/ProcesarPagareDigital.phpDisparado por el webhook de Certicámara. Re-genera cuotas (esta es la fuente del bug L-10 de duplicación).
app/Jobs/ValidarPagareDigital.phpHace polling a Certicámara para confirmar el estado de la firma. La idempotencia vive aquí.
app/Models/Facturacion/Cuota.phpEl modelo Cuota con las columnas reales y métodos accesor.
app/Models/Facturacion/Venta.phpEl modelo Venta. Tiene total, subtotal, numero_cuotas, estado, FK a OrdenCompra y Sucursal.
app/Models/Cliente.phpTiene 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.phpVerificación de EMCALI (no facturación — ver advertencia en la Sección 6).
app/Services/CerticamaraService.phpCreación del pagaré. verificarPagare() existe pero no está cableado al flujo de producción.
app/Services/AprobarCupoService.phpChequeo final de aprobación: >= 5 ProcessType con FINISH_SUCCESS en el mes actual.
resources/js/lib/credit.tsEspejo 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.tscalculateCredit() en TypeScript.
  • Backend: app/Services/VentaService.phpgenerarCuotas() en PHP.

Deberían producir números idénticos para los mismos inputs. Hoy no lo hacen, en dos lugares que conocemos:

  1. Cálculo de fianza: el frontend divide la fianza total por monthsWithBail (correcto). El backend en VentaService.php:579 se salta la división (incorrecto).
  2. 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 toma amount. Así que un cliente usando el simulador con 1.000.000 COP × 2 unidades = 2.000.000 COP vería números correctos para amount = 2,000,000 pero el backend le cobraría sobre 4,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.md y 17-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:

  1. Venta.subtotal — lo que costaron los productos (o el doble, si L-15 muerde).
  2. Venta.total — igual al subtotal en casos de un solo detalle, o la suma en multi-detalle.
  3. Cuota.monto — lo que el cliente paga por cuota.
  4. Cliente.cupo_disponible — cuánto headroom de crédito le queda al cliente.
  5. EMCALI — donde el dinero se recauda realmente (fuera del repo).
  6. 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.