Saltearse al contenido

El Recetario de prompts de Mi Plante

Treinta prompts listos para copiar y pegar, validados contra este código. Úsalos con Claude Code o Cursor. Cada prompt es específico de Mi Plante y apunta a archivos reales, clases reales y términos de dominio reales en español.

Los prompts genéricos de “mejor práctica” son inútiles. Estos son los que de verdad rinden.

Para cada prompt obtienes:

  • El texto exacto para pegar
  • Una o dos oraciones de cuándo usarlo
  • Un esbozo de salida esperada de 3-5 líneas para que puedas saber cuándo la IA alucinó

Lee los tips del final de este documento. El recetario es más poderoso cuando se acompaña con los hábitos de calibración del doc vecino, 04-when-not-to-trust-the-ai.md.


A. Prompts de onboarding y exploración

Los primeros cinco prompts son para tu primera semana. Cargan grandes trozos de contexto.

Prompt 1: Mapear el pipeline de aprobación crediticia

Cuándo usar: Día 1 o Día 2, antes de tocar cualquier cosa en app/Http/Controllers/AprobarCliente/ o app/Services/AprobarCupoService.php. Esta es el área de más alto riesgo del sistema. Cargar el mapa por adelantado te ahorra muchos errores chicos después.

Prompt:

Explica el flujo de aprobación de crédito de Mi Plante en detalle. Referencia docs/audit/12-credit-approval-workflow-diagram.md y el código real en app/Http/Controllers/AprobarCliente/.
Para cada una de las 7 fases, lista:
1. La ruta
2. El método de servicio llamado
3. La API externa golpeada (TransUnion / Experian / DataCredito / EMCALI / SHIVAM)
4. La cache key seteada o leída
Luego lista cada middleware en la cadena de rutas /usuario/cupo/* y qué exige cada uno. Según docs/audit/16-deep-validation-study.md, marca cualquier lugar donde la intención documentada difiera del comportamiento garantizado.

Esbozo esperado:

Fase 0 — Completar registro: POST /usuario/completar-registro, ClienteService::completarRegistro(), EMCALI Membresias, sin cache.
Fase 1 — Legal check: GET /usuario/cupo/legal-check, LegalCheckService::manejar(), TransUnion, loggea a aprobar_cupo_eventos.
Fase 2 — Validación de identidad: ... Experian CrossCore, cachea `{userId}.validacion` 60min.
Fase 3 — Generación OTP: ... cachea `{userId}.transaccion_otp_id` y `{userId}.requiere_cuestionario` 30min.
... (5 fases más)
Cadena de middleware en /usuario/cupo/*: auth (guard=web) -> check_intentos_limite_diarios -> controller.
Brecha entre docs y código:
- El pipeline NO se garantiza como secuencia estricta. AprobarCupoService::validarSiElClienteTieneSuCupoAprobado en :17 requiere >= 5 ProcessTypes con FINISH_SUCCESS este mes, no los 7 en orden.
- El cuestionario no está protegido por el OTP verificado — depende solo del flag requiere_cuestionario en cache desde la fase 3.

Si la respuesta omite la regla de “5 de 7”, la IA no leyó realmente docs/audit/16. Pregunta de nuevo citando ese doc explícitamente.

Prompt 2: Trazar un checkout end-to-end

Cuándo usar: Necesitas entender el flujo de venta en vivo. Aunque hayas leído la auditoría, ver a Claude reconstruirlo desde el código revela qué caminos los docs pasan por alto.

Prompt:

Camíname qué pasa cuando un cliente completa el checkout en Mi Plante. Usa docs/audit/08-sale-lifecycle-state-machine.md y app/Http/Controllers/Market/VentaController.php como fuentes.
Identifica cada lugar donde se crea una Cuota, en orden. Incluye tanto el camino sincrónico (VentaService::generarCuotas durante la creación de venta) como el asíncrono (ProcesarPagareDigital después que el webhook de Certicámara se dispara).
Resalta el landmine de cuotas duplicadas y cita los números de línea.

Esbozo esperado:

1. GET /checkout — VentaController@checkoutIndex
2. POST /mis-compras — VentaController@procesarCarrito (bypassa cliente_registro_completo + mora — LANDMINE)
3. VentaService::crearVenta dentro de DB::transaction
- OrdenCompra::create
- registrarEnCerticamara() — crea pagaré, retorna false (LANDMINE en :499)
- Venta(s), VentaDetalle, decrementa precios.inventario
- generarCuotas() en :294 — primer set de cuotas escrito
4. Cliente firma pagaré en el flujo web de Certicámara
5. Webhook de Certicámara POST /webhooks/certicamara/pagare-firmado
6. dispatch(new ValidarPagareDigital($uuid))
7. dispatch(new ProcesarPagareDigital($ordenCompra))
8. ProcesarPagareDigital::handle en :80 llama a generarCuotas DE NUEVO — LANDMINE: cuotas duplicadas
9. Por venta dispatch(new GenerarCreditoDeVenta(...))->onQueue('creditos')
10. GenerarCreditoDeVenta::handle en :91 marca OrdenCompra PROCESADA por-venta, decrementa cupo no-atómicamente en :93

Prompt 3: Listar las partes más frágiles del sistema

Cuándo usar: Final del Día 1. Antes de tocar algo significativo, obtén una re-lectura fresca del catálogo de landmines.

Prompt:

¿Cuáles son las partes más frágiles de este código que NO debería tocar en mi primera semana? Referencia docs/onboarding/04-the-landmines/01-known-bugs.md y la sección "Do Not Refactor Without Asking" del CLAUDE.md raíz.
Agrupa tu respuesta por:
1. Landmines de crédito / ventas
2. Landmines de seguridad
3. Mismatches de contrato frontend-backend
4. Landmines de cache / integridad de datos
Para cada ítem, cita el archivo y número de línea, y describe el síntoma en una oración.

Esbozo esperado:

Crédito / ventas:
- VentaService.php:499 — registrarEnCerticamara siempre retorna false; el dispatch sincrónico de GenerarCreditoDeVenta nunca se dispara
- VentaService.php:582/657/704 — lee $venta->created_at, pero Venta extiende Modelo (creado_en); la matemática de fechas corre desde now()
- VentaService.php:293 + ProcesarPagareDigital.php:80 — cuotas generadas dos veces, sin chequeo de idempotencia
- GenerarCreditoDeVenta.php:93 — decremento de cupo no atómico, actualizaciones perdidas bajo concurrencia
- GenerarCreditoDeVenta.php:91 — marca OrdenCompra PROCESADA por-venta; ventas hermanas pueden quedar pendientes/fallidas
- GenerarCreditoDeVenta.php — sin handler failed()
Seguridad:
- routes/web.php:115-116 — POST /mis-compras y POST /mis-compras/procesar-carrito bypassan cliente_registro_completo + mora
- routes/web.php:29 — Marca apiResource público, sin auth
- routes/api.php:25 — GET /api/v1/datacredito/historial público
Frontend-backend:
- resources/js/composables/useWishlist.ts:29 — espera array, backend retorna paginador
- resources/js/pages/settings/Profile.vue:32 — envía {name,email}, backend espera {nombres,apellidos}
- resources/js/pages/ally/ventas/Page.vue:88 — maneja `cancelada`, el enum real es RECHAZADA/ABANDONADA
Cache / datos:
- DataCreditoService.php:141 — la cache key omite apellido
- DataCreditoService.php:185 — requestUUID hardcodeado

Si la IA inventa nuevos landmines que no puedes encontrar en docs/onboarding/04-the-landmines/01-known-bugs.md, empuja de vuelta. La lista es lo suficientemente exhaustiva como para que los hallazgos completamente nuevos sean raros en este prompt.

Prompt 4: Mapa de servicios por dominio

Cuándo usar: Has oído sobre “29 servicios” pero no puedes recordar cuál hace qué. Este prompt te da un overview de una pantalla.

Prompt:

Dame un mapa de los 29 servicios en app/Services/ agrupados por dominio. Usa app/Services/CLAUDE.md como fuente. Los grupos son: crédito/aprobación/buró, ventas/carrito/órdenes, catálogo, partner/identidad y misceláneos.
Para cada servicio, una oración sobre su propósito. Marca cualquier servicio que sea no operativo (p. ej. PlanCreditoService).

Esbozo esperado:

Crédito / aprobación / buró:
- AprobarCupoService — limitador de intentos diarios + chequeo final de aprobación (>= 5 ProcessTypes con FINISH_SUCCESS este mes)
- LegalCheckService — Fase 1, legal check de TransUnion
- IdentityValidationService — Fases 2-5, identidad / OTP / cuestionario de Experian
- HDCValidationService — Fase 6, historia de crédito DataCredito
- DataCreditoService — cliente HTTP DataCredito (bug de cache key, UUID hardcodeado)
- ExtenderCupoService — Fase 7, extensión de cupo vía score + EMCALI
- CreditoService / CoreCreditoService — bridge SOAP de SHIVAM
- PlanCreditoService — NO OPERATIVO, modelo/migración no existen
Ventas / carrito / órdenes:
- VentaService — creación de venta, matemática de cuotas (servicio más grande, varios landmines)
- OrdenCompraService — ciclo de vida de la orden de compra
- CarritoService — CRUD del carrito
- ListaDeseoService — wishlist (mismatch de contrato paginador vs array)
- VentaReporteService — reportes comerciales
Catálogo:
- ProductoService, MarcaService, LineaService, PrecioService, PrecioImagenService, SpreadSheetService
Partner / identidad:
- EmpresaService, SucursalService, PersonaService, PostulacionService, EmcaliMembresiaService, UserService, ClienteService, CerticamaraService
Misceláneos:
- DashboardService

Prompt 5: Mapa de guards de ruta

Cuándo usar: Ves routes/web.php, routes/ally/web.php, routes/api.php, routes/auth.php, routes/ally/auth.php, routes/settings.php y quieres saber quién está permitido dónde.

Prompt:

Muéstrame cómo un dev nuevo debería entender la diferencia entre routes/web.php, routes/ally/web.php y routes/api.php en Mi Plante. ¿Qué guard de auth aplica a cada uno? ¿Cuál es la URL de login para cada uno? ¿Qué tipo de páginas sirven (Inertia, JSON, ambos)?
Cruza referencia con docs/audit/07-auth-authorization-map.md.

Esbozo esperado:

routes/web.php — marketplace del cliente, guard=web, login vía /login (estilo modal desde home?login=true). Mayormente respuestas Inertia. Páginas públicas (home, product, ConsultarCupo, simulator) son no-auth o usan el middleware consultar_cupo_cliente. Las páginas autenticadas del cliente requieren el middleware `auth` y varias usan cliente_registro_completo + verificar_cliente_presenta_mora.
routes/ally/web.php — portal partner bajo prefijo /aliados/*, guard=app, login vía /aliados/ingresar. Mayormente respuestas Inertia. Autenticado vía middleware auth:app. Autorización vía chequeos inline in_array($rol, [...]) ya que Spatie Permission está instalado pero no cableado.
routes/api.php — endpoints JSON bajo /api/v1/*. La mayoría son públicos (CartaPaz, DataCredito historial, Carrito public). Algunos endpoints de webhook bajo /webhooks/* reciben callbacks de Certicámara y EMCALI.
routes/auth.php / routes/ally/auth.php — flujos auth solo-guest (login, register, password reset). El auth.php del cliente es de Laravel Breeze; el ally/auth.php está escrito a mano.
routes/settings.php — rutas del panel de settings, guard default web. Usa Inertia.
Aviso: GET /api/v1/datacredito/historial es público a pesar de retornar datos sensibles de crédito. Este es un landmine de seguridad conocido.

B. Prompts de comprensión de código

Los siguientes cinco prompts profundizan tu comprensión de archivos específicos. Úsalos cuando estás por editar algo y quieres asegurarte de saber con qué estás lidiando.

Prompt 6: Superficie externa de VentaService

Cuándo usar: Antes de tocar cualquier cosa en app/Services/VentaService.php. Este archivo es el servicio más grande en el proyecto y tiene varios landmines.

Prompt:

Lee app/Services/VentaService.php y dime cada sistema externo que toca y cada escritura a base de datos que realiza.
Agrupa las escrituras a BD por tabla. Para cada sistema externo, lista la clase de servicio por la que va (p. ej. CerticamaraService, CreditoService).
Luego lista cada landmine en este archivo con número de línea y síntoma en una oración.

Esbozo esperado:

Sistemas externos:
- Certicámara — vía CerticamaraService::crearPagare() en registrarEnCerticamara()
- SHIVAM Core Crédito — indirectamente vía despacho de GenerarCreditoDeVenta que llama a CreditoService
Escrituras a BD:
- orden_compras — OrdenCompra::create en :320 (rama multi-empresa) y dentro de crearVentaUnica
- ventas — Venta::create
- venta_detalles — VentaDetalle::create
- cuotas — Cuota::create dentro de generarCuotas y generarCuotasPorOrden
- beneficiario — Beneficiario::create
- precios.inventario — decremento directo en :262 y :421-422 (NO atómico, usa save())
- clientes.certicamara_uuid — $cliente->update dentro de registrarEnCerticamara
Landmines:
- :499 registrarEnCerticamara retorna false incondicionalmente
- :582/:657/:704 lee $venta->created_at; Venta extiende Modelo así que la columna es creado_en
- :293/:441-442 generarCuotas corre en la creación de venta, ProcesarPagareDigital la corre de nuevo — cuotas duplicadas
- :262/:421-422 decremento de inventario no atómico — condición de carrera bajo compras concurrentes

Prompt 7: Comparar los caminos de venta cliente vs aliado

Cuándo usar: Estás trabajando en app/Http/Controllers/Aliado/ o app/Http/Controllers/Market/ y quieres saber cómo difieren los dos caminos.

Prompt:

Compara el flujo de venta del camino cliente (app/Http/Controllers/Market/VentaController.php) con el flujo de venta del camino aliado (app/Http/Controllers/Aliado/VentaController.php — si existe; si no, busca el equivalente en routes/ally/web.php y encuentra el controller).
Diferencias que me importan:
1. Qué DTO construye cada camino
2. Si numero_cuotas está hardcodeado o viene del input del request
3. Cuándo se generan las cuotas (sync en creación vs async vía ProcesarPagareDigital)
4. Si el flujo del pagaré de Certicámara corre
Cita los números de línea.

Esbozo esperado:

Camino cliente (Market/VentaController):
- procesarCarrito() en :202 hardcodea numero_cuotas = 6 (LANDMINE según el catálogo) — NO respeta lineas.plazo_minimo/plazo_maximo
- Construye CrearVentaDTO sin sucursal_id — VentaService::crearVenta enruta a crearVentasPorEmpresa
- Cuotas generadas sincrónicamente en crearVentasPorEmpresa en :441-442 (vía generarCuotasPorOrden), MÁS de nuevo async vía ProcesarPagareDigital — duplicado
- El flujo de pagaré SIEMPRE corre (registrarEnCerticamara invocado)
Camino aliado (Aliado/VentaController):
- Lee numero_cuotas del input del request (NO hardcodeado), debería respetar lineas.plazo_minimo/plazo_maximo
- Construye CrearVentaDTO CON sucursal_id — enruta a crearVentaUnica
- Cuotas generadas en crearVentaUnica en :294 solo sync — async ProcesarPagareDigital aún corre si el camino del pagaré se dispara
- El flujo del pagaré corre igual que el camino cliente

Prompt 8: Ciclo de vida de un pagaré

Cuándo usar: Estás debugueando un problema relacionado con el pagaré o estás por tocar la cadena del webhook de Certicámara. Este es el flujo más sutil en el código.

Prompt:

Traza el ciclo de vida de un Pagaré en Mi Plante.
Empieza desde VentaService::registrarEnCerticamara() (que llama a CerticamaraService::crearPagare()), a través del webhook handler de Certicámara en app/Http/Controllers/Webhooks/CerticamaraController.php, hacia el job ValidarPagareDigital, luego a ProcesarPagareDigital y finalmente a GenerarCreditoDeVenta.
Para cada paso, dime:
1. Qué estado cambia en qué tabla (clientes.certicamara_uuid, clientes.pagare_firmado_en, ventas.estado, orden_compras.estado, clientes.cupo_disponible)
2. En qué cola corre el job
3. Qué landmines existen en ese paso

Esbozo esperado:

Paso 1 — VentaService::registrarEnCerticamara():
- POST a Certicámara vía CerticamaraService::crearPagare
- Actualiza clientes.certicamara_uuid con el uuid retornado
- LANDMINE: siempre retorna false en :499
Paso 2 — El cliente firma el pagaré en el flujo web de Certicámara (externo)
- Setea clientes.pagare_firmado_en cuando el Cliente regresa y el webhook confirma
Paso 3 — El webhook de Certicámara pega POST /webhooks/certicamara/pagare-firmado
- CerticamaraController despacha ValidarPagareDigital a la cola `pagares`
Paso 4 — Job ValidarPagareDigital
- Llama a CerticamaraService::verificarPagare para confirmar firma
- Si es válido, despacha ProcesarPagareDigital
- Si es inválido, marca OrdenCompra ABANDONADA y Venta(s) RECHAZADA/ABANDONADA
Paso 5 — Job ProcesarPagareDigital (cola `pagares`)
- Llama a VentaService::generarCuotas o generarCuotasPorOrden — LANDMINE: duplica cuotas ya creadas en tiempo de venta
- Despacha GenerarCreditoDeVenta por venta a la cola `creditos`
Paso 6 — Job GenerarCreditoDeVenta (cola `creditos`)
- Llama a CreditoService::crearClienteEnCredito (SHIVAM SOAP)
- Llama a CreditoService::generarCredito (SHIVAM SOAP)
- Actualiza orden_compras.estado = PROCESADA (LANDMINE en :91: por-venta, no por-orden)
- Actualiza ventas.estado = APROBADA
- Actualiza clientes.cupo_disponible (LANDMINE en :93: update no atómico, riesgo de lost-write)
- LANDMINE: sin handler failed(). Si los 3 reintentos fallan, las ventas se quedan PENDIENTE para siempre y el inventario queda decrementado.

Prompt 9: Tabla aprobar_cupo_eventos

Cuándo usar: Estás trabajando en el pipeline de aprobación crediticia o necesitas consultar el log de eventos para un ejercicio de debugging.

Prompt:

Explica la tabla aprobar_cupo_eventos en Mi Plante. Usa la migración en database/migrations/ para encontrar las columnas, y usa app/Services/AprobarCupoService.php en :17 para encontrar cómo se consulta.
Específicamente dime:
1. Qué va en la columna `tipo_proceso` (qué enum)
2. Qué va en la columna `evento` (qué enum)
3. Qué se almacena en la columna JSON `contexto`
4. Cómo el limitador de intentos diarios (middleware CheckIntentosLimiteDiarios) lee esta tabla
5. Cómo AprobarCupoService::validarSiElClienteTieneSuCupoAprobado la usa para la compuerta final de aprobación

Esbozo esperado:

Columnas:
- id (UUID, llave primaria)
- cliente_id (foreign key)
- tipo_proceso (string) — valores del enum AprobarCupo\ProcessType: LEGAL_CHECK, IDENTITY_VALIDATION, IV_OTP_GENERATION, IV_OTP_VERIFICATION, IV_QUESTION_GENERATION, IV_QUESTION_VERIFICATION, HDC_VALIDATION
- evento (string) — valores del enum AprobarCupo\EventType: validation_start (START), validation_finish_successfull (FINISH_SUCCESS), validation_finish_unsuccessfull (FINISH_UNSUCCESS)
- contexto (JSON) — payload específico del servicio, p. ej. cuerpo de respuesta TransUnion, respuestas Experian, razones de error
- creado_en / actualizado_en
Cómo se popula:
- El evento ValidationLog se dispara desde cada fase; el listener StoreValidationLog llama a AprobarCupoService::crear()
- Cada fila representa un START o FINISH en una fase para un cliente
Limitador de intentos diarios:
- CheckIntentosLimiteDiarios cuenta filas donde tipo_proceso=LEGAL_CHECK AND evento=START AND date(creado_en)=hoy
- Bloquea en > 2 intentos (es decir, el 3er intento está bloqueado)
Compuerta final de aprobación (AprobarCupoService::validarSiElClienteTieneSuCupoAprobado en :17):
- Consulta filas para este cliente donde date(creado_en) >= startOfMonth
- Agrupa por tipo_proceso, elige el evento MÁS RECIENTE por tipo de proceso
- Cuenta cuántos tienen evento=FINISH_SUCCESS
- Retorna true si count >= 5
Por esto el pipeline no se garantiza como secuencia estricta — 5 de 7 tipos de proceso pasados este mes es suficiente.

Prompt 10: La clase base Modelo

Cuándo usar: Cada vez que escribes matemática de fechas, ordenamiento o filtrado por timestamps en este código. Crítico saber qué modelos extienden Modelo y cuáles usan defaults de Laravel.

Prompt:

Lee app/Models/Modelo.php y dime qué tiene de especial este modelo base.
Luego lista cada modelo en app/Models/ (y subdirectorios) que extiende Modelo, y cada modelo que NO (es decir, extiende Illuminate\Database\Eloquent\Model directamente).
Para cada modelo que extiende Modelo, quiero saber si la migración usa creado_en/actualizado_en o el default Laravel created_at/updated_at. Detecta cualquier mismatch.

Esbozo esperado:

Modelo (app/Models/Modelo.php):
- Extiende Illuminate\Database\Eloquent\Model
- Sobrescribe CREATED_AT = 'creado_en' y UPDATED_AT = 'actualizado_en'
Modelos que extienden Modelo (usan creado_en/actualizado_en):
- Cliente, Persona, Empresa, Sucursal, Producto, Precio, Marca, Linea, Carrito, ListaDeseo, PostulacionAliado, AprobarCupoEvento, Facturacion/OrdenCompra, Facturacion/Venta, Facturacion/VentaDetalle, Facturacion/Beneficiario
Modelos que extienden Model directamente (usan created_at/updated_at):
- User (defaults de Laravel auth)
- Facturacion/Cuota (NOTA: la tabla cuotas usa created_at/updated_at, no creado_en)
- BackendRequestLog
Mismatches a verificar:
- Las columnas de migración coinciden con la expectativa del modelo. Corre: php artisan tinker >>> Schema::hasColumn('ventas', 'creado_en') (debería ser true)
- Área de riesgo: cuando el código lee $venta->created_at retorna null silenciosamente porque la columna no existe — landmine documentado en VentaService.php:582/657/704.

C. Prompts de edición segura y cambios

Estos prompts son para cuando tienes un cambio real que hacer. Úsalos para planear el cambio ANTES de hacerlo.

Prompt 11: Agregar rate limiting a DataCredito historial

Cuándo usar: Se te ha pedido endurecer el endpoint GET /api/v1/datacredito/historial que actualmente es público.

Prompt:

Quiero agregar rate limiting a GET /api/v1/datacredito/historial. La ruta está definida en routes/api.php en la línea 25 y apunta a app/Http/Controllers/Api/DataCreditoController.
¿Dónde debería ir el middleware throttle? Muéstrame el registro exacto en routes/api.php y explica por qué los endpoints GET necesitan rate limiting en este caso (según docs/audit/16-deep-validation-study.md — este es actualmente un endpoint público que expone historia de crédito).
Restricciones:
- No romper el acceso público existente (el equipo no ha decidido hacerlo solo-auth aún)
- Limitar por IP, no por usuario (ya que es público)
- 10 requests por minuto, con respuesta 429 en overflow
- Usar la sintaxis del middleware throttle de Laravel 12

Esbozo esperado:

Agrega un limitador nombrado en app/Providers/AppServiceProvider.php boot():
RateLimiter::for('datacredito-historial', function (Request $request) {
return Limit::perMinute(10)->by($request->ip())->response(function () {
return response()->json([
'error' => 'Demasiadas consultas. Intente de nuevo en un minuto.',
], 429);
});
});
Luego en routes/api.php en la línea 25, aplícalo:
Route::get('/datacredito/historial', [DataCreditoController::class, 'historial'])
->middleware('throttle:datacredito-historial');
Por qué los endpoints GET necesitan esto aquí:
- El endpoint acepta tipo_documento, numero_documento, apellido y retorna el reporte de historia de crédito de DataCredito
- Sin auth, un atacante puede enumerar documentos
- Sin rate limiting, el costo de llamadas a la API de DataCredito explota y los resultados cacheados en Redis/DB pueden ser desalojados forzadamente
Aviso: el cache key subyacente en DataCreditoService.php:141 omite apellido (LANDMINE). Dos consultas con mismo documento pero apellido diferente retornan el mismo payload cacheado. El rate limiting no arregla eso — abre un cambio aparte para el cache key.

Prompt 12: Trade-offs para arreglar la generación duplicada de cuotas

Cuándo usar: Te asignaron el ticket L-10 (cuotas duplicadas). NO cambies código aún — usa este prompt para planear.

Prompt:

Necesito arreglar la generación duplicada de cuotas en app/Services/VentaService.php (llamada en :294 y :441-442 durante creación de venta) y app/Jobs/ProcesarPagareDigital.php (llamada en :80 y :119 después que el webhook de Certicámara se dispara).
Propón dos enfoques y no cambies ningún código aún:
Enfoque A: Hacer VentaService::generarCuotas y generarCuotasPorOrden idempotentes (guarda al inicio: si Cuota::where('venta_id', ...)->exists(), retornar temprano).
Enfoque B: Elegir un solo punto de disparo. Quitar la generación de cuotas de la creación de venta (VentaService en :294/:441) y mantenerla solo en ProcesarPagareDigital, ya que el camino en vivo siempre va por el webhook de todos modos.
Para cada enfoque muestra:
1. El cambio exacto de código (qué líneas agregar o quitar)
2. Tests que deben actualizarse (probablemente en tests/Feature/Market/VentaTest.php)
3. Riesgos
4. Qué enfoque debería preferir el equipo

Esbozo esperado:

Enfoque A (idempotente):
Cambia generarCuotas (en :562) para empezar con:
if (Cuota::where('venta_id', $venta->id)->exists()) {
Log::warning('Cuotas ya generadas, omitiendo', ['venta_id' => $venta->id]);
return;
}
Misma guarda en generarCuotasPorOrden (en :644), llaveada por orden_compra_id.
Pros: cambio mínimo, seguro bajo reintentos, idempotente bajo replay de webhook.
Contras: no arregla la confusión conceptual (las cuotas deberían generarse una vez, deterministicamente); mantiene dos puntos de disparo.
Tests: tests/Feature/Market/VentaTest.php — agrega aserción de que llamar generarCuotas dos veces produce exactamente numero_cuotas filas, no 2*numero_cuotas.
Enfoque B (disparo único):
Quita la llamada a $this->generarCuotas($venta) en :294 y la llamada a $this->generarCuotasPorOrden(...) en :441-442. Mantenlas solo en ProcesarPagareDigital.
Pros: conceptualmente limpio, las cuotas existen solo después que el pagaré es firmado.
Contras: cambio de comportamiento — entre la creación de venta y la llegada del webhook, la Venta existe sin cuotas. El código UI que lee $venta->cuotas durante esa ventana verá vacío.
Recomendación: Enfoque A primero (defensivo, se despacha rápido), luego un follow-up para migrar al Enfoque B una vez que el equipo tenga guardas UI para el estado "cuotas no generadas aún".
Riesgos para ambos:
- El landmine de procesarCarrito() donde numero_cuotas está hardcodeado a 6 (VentaController.php:202) es aguas arriba e independiente. No acoples este arreglo a aquel.
- El landmine de created_at vs creado_en en VentaService.php:582 también es independiente. Arréglalo en un PR aparte.

Prompt 13: Handler failed() para GenerarCreditoDeVenta

Cuándo usar: Te asignaron agregar un handler failed(Throwable $e) a GenerarCreditoDeVenta.

Prompt:

Necesito agregar un handler failed(Throwable $e) a app/Jobs/GenerarCreditoDeVenta.php. Actualmente el job tiene $tries=3, $backoff=60, $timeout=900 pero sin método failed().
Muéstrame el patrón canónico de Laravel 12 y propón qué estado debería setear el handler failed en:
1. La Venta (actualmente se queda PENDIENTE para siempre después de 3 reintentos fallidos)
2. La OrdenCompra (actualmente el update por-venta en :91 significa que puede haberse marcado erróneamente PROCESADA en un éxito hermano)
3. El Cliente (cupo_disponible puede haberse decrementado erróneamente; si la creación del crédito tuvo éxito pero la transacción DB en :88-95 falló, el lado SHIVAM está desincronizado)
4. El precios.inventario (que fue decrementado al momento de creación de venta y necesita ser repuesto)
También dime qué loggear y dónde (qué canal de log).

Esbozo esperado:

public function failed(Throwable $e): void
{
Log::channel('database_backend_request')->error('GenerarCreditoDeVenta: agotados los reintentos', [
'venta_id' => $this->venta->id,
'cliente_id' => $this->cliente->id,
'empresa_id' => $this->empresa->id,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
DB::transaction(function () {
// Refrescar desde BD en caso de que el estado haya cambiado entre handle() y failed()
$venta = Venta::find($this->venta->id);
if (!$venta) return;
if ($venta->estado === EstadoVenta::PENDIENTE) {
// Reponer inventario
$venta->loadMissing('detalles.precio');
foreach ($venta->detalles as $detalle) {
if ($detalle->precio) {
$detalle->precio->increment('inventario', $detalle->cantidad);
}
}
$venta->update([
'estado' => EstadoVenta::RECHAZADA,
'causal' => 'CC:JOB_FAILED:' . substr($e->getMessage(), 0, 200),
]);
}
// NO tocar OrdenCompra aquí — ventas hermanas pueden haber tenido éxito
// NO tocar Cliente.cupo_disponible — solo se decrementó en el camino de éxito (LANDMINE :93 es un issue aparte)
});
// Opcional: notificar admins vía el mismo canal de notificación usado en otros lugares
// (ver app/Notifications/) — solo si el equipo quiere una alerta Slack/email
}
Resumen de estado:
- Venta: solo actualizar si sigue PENDIENTE (no sobrescribir un estado exitoso de un camino de código anterior)
- OrdenCompra: dejar en paz (el bug L-31 del update por-venta necesita su propio arreglo)
- Cliente.cupo_disponible: dejar en paz (solo se decrementó cuando la creación del crédito tuvo éxito; si llegamos a failed() no debería haberse tocado)
- Inventario: reponer — espeja lo que hace rechazarVenta

Prompt 14: Andamio para una nueva integración externa

Cuándo usar: Se está integrando un nuevo buró crediticio o proveedor de identidad. Usa este prompt para planear la lista de archivos antes de escribir cualquier código.

Prompt:

Necesito agregar una nueva integración externa de buró crediticio. Usa app/Services/DataCreditoService.php como plantilla (es el ajuste más cercano — tokens OAuth, respuestas cacheadas, request UUID).
Lista cada archivo que necesito crear o modificar, en orden. Agrupa por:
1. Servicio + DTO + macro (el servicio en sí)
2. Config + env (config/services.php, .env.example)
3. Integración del llamador (cualesquiera servicios o jobs que llamarán este nuevo servicio)
4. Tests
Para cada archivo, una oración sobre qué va en él. Cita líneas de ejemplo de DataCreditoService donde sea útil.
Restricciones:
- Usar el patrón Http::macro registrado en app/Providers/AppServiceProvider.php (ver los macros existentes transunion, datacredito, certicamara, expirianCrossCore)
- Cachear el token con TTL justo bajo el expiry real (DataCredito usa 590s para un expiry de 600s)
- Cachear respuestas con una key que incluya CADA input que diferencie una respuesta (DataCredito tiene un bug donde la cache key omite apellido — no repetir)
- Capturar y loggear cada excepción
- requestUUID debe generarse por-request (DataCredito hardcodea uno — no repetir)

Esbozo esperado:

1. Servicio + DTO + macro:
- app/Services/NewBureauService.php — generarToken(), obtenerToken(), consultarReporte(...)
- app/DTOs/NewBureau/NewBureauResultDTO.php — envolver respuesta con hasError/getError/getData usando ResultTrait
- app/Providers/AppServiceProvider.php — registrar macro Http::newBureau() cerca del registro existente de Http::datacredito()
2. Config + env:
- config/services.php — agregar bloque new_bureau con url, user_id, client_id, client_secret, etc.
- .env.example — agregar NEW_BUREAU_URL, NEW_BUREAU_USERNAME, NEW_BUREAU_PASSWORD, NEW_BUREAU_CLIENT_ID, NEW_BUREAU_CLIENT_SECRET
3. Integración del llamador:
- ¿De dónde se llama? Si es parte de la aprobación de crédito, probablemente una nueva fase en app/Http/Controllers/AprobarCliente/ + un valor de enum ProcessType correspondiente + una entrada de log de event-sourcing por llamada
- Si reemplaza una llamada existente (p. ej. DataCredito), actualizar HDCValidationService
4. Tests:
- tests/Unit/Services/NewBureauServiceTest.php — usar Http::fake(['*/path/*' => Http::response([...], 200)]) contra el macro
- Cubrir: éxito, error HTTP, expiry+refresh del token, cache hit, cache miss
- Si es parte del pipeline de aprobación de crédito: tests/Feature/AprobarCliente/NewPhaseTest.php con ejercicio completo de la ruta
Ejemplo de cache key (NO copiar bug de DataCredito):
$cacheKey = "new_bureau.reporte.{$documentType}.{$documentNumber}.{$lastName}.{strtolower($firstName)}";

Prompt 15: Nueva página Vue en el portal aliado

Cuándo usar: Estás agregando una página nueva al portal partner bajo resources/js/pages/ally/.

Prompt:

Quiero agregar una nueva página Vue bajo resources/js/pages/ally/ para gestionar empleados. La estoy modelando en resources/js/pages/ally/empleados/Crear.vue (asumiendo que existe; si no, modelo en otra página CRUD aliada como ally/sucursales/Crear.vue).
Dime:
1. Qué props compartidas de Inertia están disponibles para esta página (desde app/Http/Middleware/HandleInertiaRequests.php para el guard app)
2. La convención de nombre de ruta Ziggy que debería seguir (p. ej. aliado.empleados.editar)
3. Cómo agregar la ruta en routes/ally/web.php con el grupo middleware correcto (auth:app)
4. El patrón para el método del controller (validar -> DTO -> servicio)
5. Cualquier chequeo de rol necesario (el usuario debe ser al menos 'aliado' o 'empleado'; las acciones solo-admin requieren el array de roles globales)
6. El layout que la página debería usar (AllyLayout desde resources/js/layouts/)
7. Qué componentes Reka UI / shadcn-vue debería reusar para formularios

Esbozo esperado:

Props compartidas para el guard app (desde HandleInertiaRequests::share):
- auth.user, auth.role
- empresa (la empresa del aliado)
- ziggy routes
- lineas, brands_allied, popular_categories (mayormente usadas por páginas de cara al cliente; disponibles en todas partes)
- links
Nombre de ruta Ziggy: aliado.empleados.editar (coincide con la convención de archivos usada en otras rutas aliadas)
Registro de ruta en routes/ally/web.php:
Route::middleware('auth:app')->prefix('aliados')->name('aliado.')->group(function () {
Route::get('/empleados/{empleado}/editar', [EmpleadoController::class, 'editar'])->name('empleados.editar');
Route::put('/empleados/{empleado}', [EmpleadoController::class, 'actualizar'])->name('empleados.actualizar');
});
Patrón de controller (app/Http/Controllers/Aliado/EmpleadoController):
public function editar(Empleado $empleado): Response
{
$this->authorize('edit', $empleado); // O inline: if (!in_array(auth()->user()->rol, ['aliado', 'administrador'])) abort(403);
return Inertia::render('ally/empleados/Editar', ['empleado' => $empleado]);
}
public function actualizar(ActualizarEmpleadoRequest $request, Empleado $empleado): RedirectResponse
{
$dto = ActualizarEmpleadoDTO::fromArray($request->validated());
$this->empleadoService->actualizar($empleado, $dto);
return back()->with('success', 'Empleado actualizado');
}
Layout: importa AllyLayout desde '@/layouts/AllyLayout.vue' y envuelve la página
Componentes de form reusables: primitivos Reka UI en resources/js/components/ui/ — Form, Input, Button, Select, Card. Sigue reglas de Prettier (150 char width, 4-space indent, sort de clases Tailwind).
Chequeo de rol: los aliados pueden editar sus propios empleados. Los admins pueden editar cualquiera. El chequeo inline está bien ya que Spatie Permission está instalado pero no cableado.

D. Prompts de debugging

Estos cinco prompts son para triage. Úsalos cuando algo está roto en producción o staging.

Prompt 16: Cliente atascado en el paso 3 de aprobación crediticia

Cuándo usar: Un usuario reporta que no puede pasar del paso de OTP.

Prompt:

Un Cliente está atascado en el paso 3 de aprobación de crédito (generación OTP) pero el flujo nunca avanza.
Camíname los pasos diagnósticos:
1. Qué keys de cache de Laravel inspeccionar (IdentityValidationService usa {userId}.validacion, {userId}.transaccion_otp_id, {userId}.requiere_cuestionario — ¿cuándo se setea cada uno y cuándo expira?)
2. Qué filas en aprobar_cupo_eventos consultar (filtrar por cliente_id, tipo_proceso=IV_OTP_GENERATION, ordenado por creado_en desc)
3. Qué env vars importan (EXPIRIAN_* — nota la ortografía)
4. Cómo se ven las respuestas de Experian (códigos resultadoOTP, resultadoValidacion)
5. Qué logs hacer grep (storage/logs/laravel.log y el canal database_backend_request)
Referencia docs/audit/12-credit-approval-workflow-diagram.md.

Esbozo esperado:

1. Cache keys (cache default de Laravel):
- {userId}.validacion — seteado por fase 2 (IdentityValidationService::validarIdentidad), TTL 60min. Si expiró, gen OTP retorna 'Realice la validación primero'
- {userId}.transaccion_otp_id — seteado por fase 3 (generarOTP), TTL 30min. Si expira para la fase 4, la verificación falla
- {userId}.requiere_cuestionario — seteado por fase 3, TTL 30min
Para inspeccionar: php artisan tinker >>> Cache::get("{$userId}.validacion")
2. Consulta a aprobar_cupo_eventos:
SELECT * FROM aprobar_cupo_eventos
WHERE cliente_id = ? AND tipo_proceso = 'iv_otp_generation'
ORDER BY creado_en DESC LIMIT 5;
Busca filas FINISH_UNSUCCESS y lee el JSON contexto para la razón de la falla.
3. Env vars:
- EXPIRIAN_URL (nota la ortografía, no EXPERIAN)
- EXPIRIAN_OKTA_URL, EXPIRIAN_OKTA_CLIENT_ID, EXPIRIAN_OKTA_CLIENT_SECRET, EXPIRIAN_OKTA_USERNAME, EXPIRIAN_OKTA_PASSWORD
- EXPIRIAN_API_KEY
4. Respuestas de Experian:
- resultadoOTP=true significa que la infra OTP aceptó el request
- resultadoValidacion="1" significa que la validación pasó
- El email y celular del cliente deben coincidir con los que Experian tiene en archivo — de lo contrario "Correo y celular no coinciden"
5. Logs:
- storage/logs/laravel.log — entradas formateadas Carbon desde logs de IdentityValidationService
- canal database_backend_request — warnings estructurados sobre fallas HTTP, consultable vía SELECT * FROM backend_request_logs WHERE level='warning' ORDER BY created_at DESC
Causa más común: el cache validacion expiró (60 min) entre el éxito de la fase 2 y el intento de fase 3. El cliente tiene que reiniciar desde la fase 2 (Identity Validation). El middleware no captura esto; el servicio levanta 'Realice la validación primero' que aflora como un genérico 'Error' al cliente. Mejorar ese mensaje de error es un arreglo de bajo riesgo.

Prompt 17: Venta atascada en estado PENDIENTE

Cuándo usar: El cliente reporta que completó el checkout, fue cobrado, pero la orden muestra PENDIENTE.

Prompt:

Un cliente reporta que su Venta está en estado PENDIENTE y firmó el pagaré. Diagnostica:
1. Qué job corre la transición de estado de PENDIENTE a APROBADA (es GenerarCreditoDeVenta::handle en :88-95 — confirma)
2. Dónde podría fallar silenciosamente (según los landmines en docs/onboarding/04-the-landmines/01-known-bugs.md, especialmente el handler failed() faltante en GenerarCreditoDeVenta)
3. Dónde encontraría evidencia de la falla (storage/logs/laravel.log, tabla failed_jobs ya que queue driver es database)
4. La SQL específica para consultar failed_jobs filtrada por GenerarCreditoDeVenta
5. El procedimiento de recuperación si el crédito SHIVAM fue creado pero el estado de venta no se actualizó (estado zombie)

Esbozo esperado:

1. La transición de estado: GenerarCreditoDeVenta::handle en :91-93:
$ordenCompra->update(['estado' => OrdenCompraEstado::PROCESADA]);
$this->venta->update(['estado' => EstadoVenta::APROBADA]);
$this->cliente->update(['cupo_disponible' => $this->cliente->cupo_disponible - $this->venta->total]);
2. Modos de falla silenciosa:
- Los 3 reintentos fallaron → sin handler failed() → la venta se queda PENDIENTE (LANDMINE)
- El rollback de DB::transaction en :97 deja la venta PENDIENTE aunque SHIVAM tuvo éxito
- rechazarVenta() en :117 setea RECHAZADA solo si creditoService retornó un valor no-true, no si lanzó
3. Evidencia:
- storage/logs/laravel.log — busca "GenerarCreditoDeVenta: Error" o "GenerarCreditoDeVenta: Rechazando venta"
- tabla failed_jobs (queue=database, así que es una tabla manejada por Laravel): SELECT * FROM failed_jobs WHERE payload LIKE '%GenerarCreditoDeVenta%' ORDER BY failed_at DESC
4. SQL:
SELECT id, queue, failed_at, exception FROM failed_jobs
WHERE payload LIKE '%GenerarCreditoDeVenta%'
AND payload LIKE '%"venta_id":<el venta id>%'
ORDER BY failed_at DESC LIMIT 5;
5. Recuperación:
- Primero confirma: ¿SHIVAM realmente creó el crédito? Chequea los logs de CoreCreditoService o consulta SHIVAM directamente con el vcard del cliente.
- Si SHIVAM está sincronizado (el crédito existe): manualmente actualiza ventas.estado=APROBADA y orden_compras.estado=PROCESADA, luego decrementa clientes.cupo_disponible. Usa una sesión de tinker.
- Si SHIVAM NO está sincronizado (crédito no creado): reintenta el job vía php artisan queue:retry <failed_job_id>. Si la falla es permanente, cae al rechazarVenta para reponer inventario.
Hasta que el handler failed() exista, este es un proceso manual. Agregar el handler failed() (ver Prompt 13) es el arreglo estructural.

Prompt 18: Las fechas de cuota están desfasadas por un día

Cuándo usar: El aliado reporta que las fechas de vencimiento de las cuotas aparecen mal.

Prompt:

Las fechas de fecha_vencimiento de Cuota aparecen desfasadas un día para algunas ventas. ¿Dónde está el cálculo, y cuál es la relación entre:
- Venta.creado_en (timestamp Modelo, la columna real de creación)
- Venta.created_at (default Laravel; no existe como columna en ventas — LANDMINE según VentaService.php:582)
- Cuota.fecha_vencimiento (calculada en generarCuotas)
Traza la lógica. Luego dime qué escenarios producen errores de un día o de muchos días.

Esbozo esperado:

Cálculo en VentaService::generarCuotas en :582:
$fechaVencimiento = $venta->created_at ?? now();
for ($i = 1; $i <= $numeroCuotas; $i++) {
$fechaVencimiento = $fechaVencimiento->copy()->addMonth();
...
}
Relación:
- Venta extiende Modelo (app/Models/Modelo.php:16-17) que renombra CREATED_AT a 'creado_en'.
- La migración de la tabla ventas tiene columnas `creado_en` y `actualizado_en` (español), NO `created_at` / `updated_at`.
- $venta->created_at retorna NULL (la columna no existe en la tabla del modelo).
- El fallback `?? now()` se dispara entonces, así que $fechaVencimiento se vuelve now() en el momento que el código corre.
Escenarios:
- Desfase de cero-a-muchos-minutos: si la venta fue creada a las 11:58pm UTC y las cuotas fueron generadas a las 12:01am UTC (día diferente en America/Bogota), entonces la primera cuota está fechada el día siguiente en lugar de un mes desde la creación.
- Desfase por horas dentro del mismo día: confusión de timezone. Carbon::now() retorna UTC por defecto; el cliente ve hora colombiana.
- Desfase por mes: si la generación de cuotas corre en ProcesarPagareDigital después de un delay (el pagaré puede firmarse hasta 60 minutos después de la creación), el ancla "now()" se mueve con el delay.
Esbozo de arreglo:
$fechaVencimiento = $venta->creado_en ?? now();
Este cambio es mecánico pero coordina con el equipo — puede haber tests que afirmaron el comportamiento del bug.
También afecta generarCuotasPorOrden en :657 ($ordenCompra->created_at) y :704 ($venta->created_at).

Prompt 19: Error de clase no encontrada

Cuándo usar: Obtienes un error de autoload de clase después de agregar un archivo nuevo.

Prompt:

Estoy obteniendo errores 'Class "App\\Services\\SomeService" not found' después de agregar un nuevo archivo de servicio.
¿Cuál es la config de autoload en este proyecto Laravel? ¿Cuándo necesito correr composer dump-autoload?
Específicamente:
1. Dónde se define el mapping PSR-4 (composer.json autoload.psr-4)
2. ¿El proyecto usa autoloading autoritativo o no autoritativo?
3. ¿Laravel 12 auto-descubre servicios o necesito registrarlos?
4. Después de agregar un archivo nuevo bajo app/Services/, ¿necesito dump-autoload?
5. Qué otros comandos podría necesitar correr (p. ej. php artisan config:clear)

Esbozo esperado:

1. composer.json autoload.psr-4 mapea:
- "App\\" -> "app/"
- "Database\\Factories\\" -> "database/factories/"
- "Database\\Seeders\\" -> "database/seeders/"
2. Mi Plante no usa autoloading autoritativo (sin flag --classmap-authoritative en CI). Así que los archivos nuevos bajo app/ son recogidos por PSR-4 directamente.
3. Laravel 12 auto-descubre servicios vía PSR-4 — NO necesitas registrarlos en un provider a menos que tengan dependencias de constructor que requieran binding explícito. La mayoría de servicios de Mi Plante usan inyección por constructor tipado simple (p. ej. VentaService acepta CerticamaraService vía el container) y eso se resuelve vía reflection.
4. Después de agregar un archivo nuevo bajo app/Services/:
- El nombre del archivo debe coincidir con el nombre de clase (PSR-4 estricto)
- El namespace debe ser App\Services\<sub>
- Si el archivo nuevo coincide en ambos: no se necesita nada más — Laravel lo encuentra en el siguiente request
- Si renombraste o moviste una clase existente y Composer cacheó el mapa viejo: corre composer dump-autoload
5. Otros comandos:
- php artisan config:clear si agregaste config respaldada por env y no aparece
- php artisan route:clear si agregaste rutas
- composer dump-autoload -o (con -o para optimizado) si la clase genuinamente no se encuentra
- Si usas OPcache: en dev, asegúrate que opcache.revalidate_freq esté bajo (los defaults de Laravel Sail están bien)
Error común: poner el archivo en app/Services/SubFolder/MyService.php pero usar namespace App\Services\MyService (falta SubFolder). PSR-4 estricto — el namespace debe espejar la estructura de carpetas.

Prompt 20: Inertia 409 en POST

Cuándo usar: Estás viendo 409 Conflict en la pestaña de network del navegador en requests Inertia.

Prompt:

Inertia está retornando un 409 en un request POST. ¿Cuál es el comportamiento de version-mismatch de Inertia en Laravel 12, y dónde chequeo la versión de asset desplegada?
Específicamente:
1. Qué significa un 409 en el contrato de Inertia
2. Dónde envía Laravel el header X-Inertia-Version (app/Http/Middleware/HandleInertiaRequests.php — ¿qué retorna version()?)
3. Qué hace el frontend cuando recibe un 409 con X-Inertia-Location
4. Cómo podría ocurrir un version mismatch en producción (cambio de hash de asset entre deployments)
5. La recuperación para usuarios que pegan un 409 a mitad de sesión
6. Cómo testear esto localmente (forzar un cambio de versión)

Esbozo esperado:

1. En Inertia, 409 Conflict significa "la versión de asset en el cliente no coincide con la versión de asset en el servidor". El cliente debe recargar para obtener nuevos assets.
2. HandleInertiaRequests::version() — Mi Plante usa el comportamiento default de Laravel que hashea el manifest de assets. El header X-Inertia-Version se envía en cada respuesta Inertia. El chequeo ocurre en el middleware del InertiaServiceProvider.
3. Comportamiento del frontend: cuando un 409 vuelve con header X-Inertia-Location: <url>, el cliente JS de Inertia hace un window.location reload completo a esa URL. El usuario ve una recarga breve y aterriza en la misma página que estaba intentando enviar, con los assets nuevos.
4. Los version mismatches ocurren durante deploys: el dev empuja JS nuevo, el hash del manifest cambia, el usuario tiene la app cliente vieja abierta. Siguiente POST → 409 → reload → el usuario reintenta en el cliente nuevo.
5. Recuperación: típicamente automática. Si el form del usuario tenía datos, los datos se pierden (el reload no los preserva). Este es un trade-off UX conocido — la solución de Inertia es aflorarlo gentilmente con un toast o banner.
6. Para testear localmente:
- Corre npm run build para producir un manifest
- Nota la versión actual en los headers de respuesta (X-Inertia-Version)
- Toca un archivo JS, corre npm run build de nuevo — el hash del manifest cambia
- Haz un POST Inertia desde el cliente viejo → 409
Aviso de Mi Plante: el HandleInertiaRequests del equipo no ha customizado version(). Si alguna vez quieres invalidar sesiones en un schedule manual, sobrescribe version() para retornar un string manual desde config.

E. Prompts de testing

Cinco prompts para escribir o extender tests.

Prompt 21: Test que bloquea clientes no verificados

Cuándo usar: Escribiendo el test de regresión L-01 — asegúrate que el acceso en cleartext a endpoints de checkout sea bloqueado cuando un Cliente no ha completado la validación de identidad.

Prompt:

Escribe un Feature test que afirme que el flujo de aprobación de crédito correctamente bloquea a un Cliente que no ha completado la validación de identidad.
Usa los tests existentes en tests/Feature/AprobarCliente/ como patrones. Cubre:
1. Un Cliente sin filas en aprobar_cupo_eventos debería ser bloqueado en /usuario/cupo/hdc-validation (no puede saltar adelante)
2. Un Cliente con solo LEGAL_CHECK FINISH_SUCCESS no debería poder llegar a la Fase 6
3. Un Cliente con 5 ProcessTypes con FINISH_SUCCESS este mes DEBERÍA pasar el chequeo AprobarCupoService::validarSiElClienteTieneSuCupoAprobado
4. Un Cliente con 4 ProcessTypes con FINISH_SUCCESS este mes NO debería pasar
Usa RefreshDatabase. La cola es sync durante tests. Cadenas de factory: User -> Persona -> Cliente -> AprobarCupoEvento. Referencia docs/audit/12 para los valores ProcessType + EventType.

Esbozo esperado:

namespace Tests\Feature\AprobarCliente;
use App\Enum\AprobarCupo\EventType;
use App\Enum\AprobarCupo\ProcessType;
use App\Services\AprobarCupoService;
use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
class AprobacionCupoGateTest extends TestCase
{
use RefreshDatabase;
public function test_cliente_sin_eventos_no_pasa_el_gate(): void
{
$cliente = $this->aclienteFactory();
$result = app(AprobarCupoService::class)->validarSiElClienteTieneSuCupoAprobado($cliente);
$this->assertFalse($result);
}
public function test_cliente_con_5_procesos_aprobados_pasa(): void
{
$cliente = $this->aclienteFactory();
$tipos = [
ProcessType::LEGAL_CHECK,
ProcessType::IDENTITY_VALIDATION,
ProcessType::IV_OTP_GENERATION,
ProcessType::IV_OTP_VERIFICATION,
ProcessType::HDC_VALIDATION,
];
foreach ($tipos as $tipo) {
$cliente->aprobarCupoEventos()->create([
'tipo_proceso' => $tipo->value,
'evento' => EventType::FINISH_SUCCESS->value,
'contexto' => json_encode([]),
]);
}
$this->assertTrue(app(AprobarCupoService::class)->validarSiElClienteTieneSuCupoAprobado($cliente));
}
public function test_cliente_con_4_procesos_aprobados_no_pasa(): void
{
// Misma forma, solo 4 ProcessTypes
...
$this->assertFalse(...);
}
}
Avisos:
- La cadena de factory User -> Persona -> Cliente puede no existir; chequea database/factories/ primero. Si falta, agrega factories.
- El middleware no garantiza el orden de fases; eso es por diseño (según docs/audit/16 §12).

Prompt 22: Test fallido que captura la generación duplicada de cuotas

Cuándo usar: Escribiendo una regresión estilo TDD para L-10 ANTES de arreglarlo. El test debería fallar hoy y pasar una vez que llegue el arreglo.

Prompt:

Escribe un Feature test que capture el bug L-10 de generación duplicada de cuotas. El test debería fallar hoy (hasta que llegue el arreglo) y pasar después.
Referencia tests/Feature/Market/VentaTest.php para patrones. Setup:
1. Crea un Cliente con cupo_disponible >= 1,000,000 y pagare_firmado_en ya seteado (para que la rama Certicámara despache)
2. POST a /mis-compras con un carrito válido (numero_cuotas=6)
3. La creación de venta llama VentaService::generarCuotas (primer set de cuotas)
4. Luego dispara ProcesarPagareDigital manualmente (ya que queue=sync en tests; o simula el webhook)
5. Afirma que Cuota::where('venta_id', $venta->id)->count() == 6 — no 12
Usa RefreshDatabase, queue=sync. Fake las llamadas HTTP a Certicámara con Http::fake.

Esbozo esperado:

public function test_no_se_generan_cuotas_duplicadas_despues_de_procesar_pagare(): void
{
Http::fake([
'*/api/v1/digitalsignatures/document*' => Http::response(['uuid' => 'mock-uuid'], 200),
'*/api/v1/digitalsignatures/document/mock-uuid' => Http::response(['estado' => 'firmado'], 200),
]);
$cliente = $this->aClienteFactory();
$cliente->update(['pagare_firmado_en' => now(), 'cupo_disponible' => 5_000_000, 'certicamara_uuid' => 'mock-uuid']);
$precio = Precio::factory()->create(['monto' => 500_000, 'inventario' => 10]);
$this->actingAs($cliente->user)
->post('/mis-compras', [
'carrito' => [
['precio_id' => $precio->id, 'cantidad' => 1],
],
])
->assertRedirect();
$venta = Venta::latest('id')->first();
$this->assertNotNull($venta);
// Simular el webhook disparando ProcesarPagareDigital
(new ProcesarPagareDigital($venta->ordenCompra))->handle(app(VentaService::class));
$cuotas = Cuota::where('venta_id', $venta->id)->get();
$this->assertCount(6, $cuotas, 'Expected 6 cuotas (numero_cuotas=6), got ' . $cuotas->count() . '. Duplicate generation bug L-10.');
}
Avisos:
- procesarCarrito hardcodea numero_cuotas=6 (LANDMINE ticket aparte); este test aprovecha eso
- El test fallará hoy con count 12 (generación en tiempo de venta + tiempo de webhook)
- Una vez que la guarda de idempotencia o el enfoque de disparo único esté en su lugar, el count será 6

Prompt 23: Test de idempotencia del webhook de Certicámara

Cuándo usar: Validando que el handler del webhook no double-cree estado si Certicámara reintenta.

Prompt:

Escribe un Feature test que ejercite el handler del webhook de Certicámara (POST /webhooks/certicamara/pagare-firmado en app/Http/Controllers/Webhooks/CerticamaraController.php) y verifique idempotencia. Llamar al webhook dos veces para el mismo uuid no debería duplicar la creación de cuotas ni doble-decrementar el cupo.
Setup:
1. Cliente con pagare_firmado_en NULL inicialmente, certicamara_uuid='mock-uuid'
2. Venta en estado PENDIENTE, orden_compra en estado PENDIENTE
3. Pega la URL del webhook con la firma/payload correctos (consulta el controller para la forma esperada)
4. Afirma que ValidarPagareDigital corrió (ya que queue=sync) y despachó ProcesarPagareDigital
5. Afirma que existen exactamente 6 cuotas
6. Pega el webhook una segunda vez
7. Afirma que SIGUE habiendo exactamente 6 cuotas (no 12)
8. Afirma que cupo_disponible fue decrementado exactamente una vez

Esbozo esperado:

public function test_webhook_certicamara_es_idempotente(): void
{
Http::fake([
'*/api/v1/digitalsignatures/document/mock-uuid' => Http::response(['estado' => 'firmado'], 200),
]);
$cliente = $this->aClienteFactory();
$venta = $this->aPendienteVentaFactory($cliente);
$cupoInicial = $cliente->cupo_disponible;
// Primer hit del webhook
$this->post('/webhooks/certicamara/pagare-firmado', [
'uuid' => 'mock-uuid',
'estado' => 'firmado',
])->assertOk();
$cliente->refresh();
$venta->refresh();
$primeraCuotaCount = Cuota::where('venta_id', $venta->id)->count();
$primerCupo = $cliente->cupo_disponible;
$this->assertEquals($cupoInicial - $venta->total, $primerCupo);
// Segundo hit del webhook (reintento de Certicámara)
$this->post('/webhooks/certicamara/pagare-firmado', [
'uuid' => 'mock-uuid',
'estado' => 'firmado',
])->assertOk();
$cliente->refresh();
$segundaCuotaCount = Cuota::where('venta_id', $venta->id)->count();
$this->assertEquals($primeraCuotaCount, $segundaCuotaCount, 'Webhook is not idempotent for cuotas');
$this->assertEquals($primerCupo, $cliente->cupo_disponible, 'Webhook is not idempotent for cupo');
}
Avisos:
- Este test fallará hoy (sin guardas de idempotencia en su lugar)
- Úsalo como ancla TDD para el arreglo L-10
- Una vez que L-10 llegue, este test pasa esa es la prueba

Prompt 24: HTTP-fake DataCredito

Cuándo usar: Escribiendo unit tests para DataCreditoService sin pegarle a la API real.

Prompt:

Quiero testear app/Services/DataCreditoService.php sin pegarle a la API real de DataCredito. Muéstrame el patrón Laravel HTTP fake y el setup de test existente en este repo.
Específicamente:
1. La sintaxis Http::fake() que coincide con el macro Http::datacredito() — qué patrones de URL usar
2. Cómo testear el comportamiento de caching de token (montar dos llamadas consecutivas y afirmar que la segunda usa el cache)
3. Cómo testear el cache de consultarHistorialCredito (la cache key es 'datacredito.historial.{numero_documento}.{tipo_documento_id}' — el apellido está intencionalmente omitido hoy, lo cual es un LANDMINE que estamos arreglando en un PR aparte)
4. Cómo afirmar que en una respuesta 401, el servicio refresca el token y reintenta
5. La forma de datos de fixture — cómo se ve la respuesta HDC Plus de DataCredito (responseCode 13 = éxito)

Esbozo esperado:

namespace Tests\Unit\Services;
use App\Services\DataCreditoService;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
class DataCreditoServiceTest extends TestCase
{
public function test_obtener_historial_usa_token_cacheado(): void
{
Cache::shouldReceive('get')->with('datacredito_access_token')->andReturn('cached-token');
Cache::shouldReceive('get')->with('datacredito.historial.12345678.1')->andReturn(null);
Http::fake([
'*/cs/credit-history/v1/hdcplus' => Http::response([
'ReportHDCplus' => [
'productResult' => [
'responseCode' => 13,
],
],
], 200),
]);
$svc = app(DataCreditoService::class);
$response = $svc->consultarHistorialCredito('12345678', 1, 'GARCIA');
$this->assertTrue($response->successful());
Http::assertSentCount(1);
Http::assertSent(function ($req) {
return str_contains($req->url(), '/cs/credit-history/v1/hdcplus')
&& $req->hasHeader('Client_id')
&& $req->header('Authorization')[0] === 'Bearer cached-token';
});
}
public function test_401_invalida_cache_y_reintenta(): void
{
Cache::flush();
Http::fake([
'*/spla/oauth2/v1/getToken*' => Http::sequence()
->push(['access_token' => 'first-token', 'expires_in' => 600], 200)
->push(['access_token' => 'second-token', 'expires_in' => 600], 200),
'*/cs/credit-history/v1/hdcplus' => Http::sequence()
->push([], 401)
->push(['ReportHDCplus' => ['productResult' => ['responseCode' => 13]]], 200),
]);
$svc = app(DataCreditoService::class);
$response = $svc->consultarHistorialCredito('12345678', 1, 'GARCIA');
$this->assertTrue($response->successful());
Http::assertSentCount(4); // 2 token + 2 hdc
}
}
Avisos:
- La cache key para historial omite apellido escribe el test para afirmar el comportamiento ACTUAL (con bug). Cuando llegue L-15, actualiza el test.
- En entorno local, DataCreditoService hace proxy a https://test.miplante.com/api/v1/datacredito/historial — usa APP_ENV=testing (no local) en phpunit.xml para que la rama real corra contra Http::fake

Prompt 25: La clase padre TestCase y reset de BD

Cuándo usar: Primera vez escribiendo un Feature test en este proyecto, quieres saber qué hay ya en tests/TestCase.php.

Prompt:

¿Cuál es el patrón conftest.php / setUp() en este proyecto para resetear la BD entre tests? Muéstrame la clase padre TestCase existente en tests/TestCase.php.
Específicamente:
1. De qué extiende el TestCase de Mi Plante (CreatesApplication, Tests\Trait, etc.)
2. ¿RefreshDatabase se usa por-test o globalmente?
3. ¿Cuál es la config de cola durante tests (según phpunit.xml — debería ser sync)?
4. ¿Hay métodos helper para crear entidades comunes (un "clienteFactory", "aprobadoClienteFactory", etc.)?
5. Dónde viven los factories (database/factories/)
6. Cualquier rareza que debería saber (p. ej. tests que dependen del estado del cache, que necesitan Cache::flush() en setUp)

Esbozo esperado:

tests/TestCase.php — extiende Illuminate\Foundation\Testing\TestCase. Sin helpers personalizados en la base; el proyecto usa el trait RefreshDatabase por-test.
Settings de phpunit.xml durante tests:
- DB_CONNECTION=mysql (una BD de test separada según las convenciones del equipo; algunos devs lo sobrescriben a sqlite localmente)
- QUEUE_CONNECTION=sync
- CACHE_STORE=array
- MAIL_MAILER=array
Patrón recomendado:
class MyTest extends TestCase {
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
// Opcionalmente vaciar caches si tu test se preocupa por ellos
Cache::flush();
}
}
Factories en database/factories/:
- UserFactory, PersonaFactory, ClienteFactory, EmpresaFactory, SucursalFactory, ProductoFactory, PrecioFactory
- Chequea cuáles existen — algunas entidades de dominio (Cuota, OrdenCompra) pueden no tener factories aún. Si faltan, agrega uno en lugar de construir filas a mano.
Métodos helper: aún no hay un archivo de test helper a nivel proyecto. Los devs hacen factories a mano por test. Si te encuentras haciendo el mismo setup tres veces, escribe un helper en tests/TestCase.php y llámalo desde tu test (p. ej. aClienteAprobadoFactory()).
Rarezas:
- Los tests que usan Http::fake deben llamarlo ANTES de que el código bajo test haga el request. Fake-ar tarde no hace nada.
- Tests que usan eventos/listeners: queue=sync significa que los jobs corren inline, pero los listeners de eventos corren sincrónicamente sin importar. Event::fake() si quieres afirmar sin ejecución.
- Los macros AppServiceProvider (Http::transunion, Http::datacredito, etc.) están registrados en boot() — existen en tests también. Fake-éalos con su patrón de URL REAL, no el nombre del macro.

F. Prompts de refactor y limpieza

Los últimos cinco prompts son para trabajo estructural más grande. Úsalos con moderación y siempre corre un Codex challenge o revisión de equipo antes de mergear.

Prompt 26: Quitar eventos muertos VentaCompletada / VentaCancelada

Cuándo usar: Te asignaron limpiar los eventos + listeners muertos VentaCompletada / VentaCancelada.

Prompt:

Quiero quitar los eventos sin uso VentaCompletada y VentaCancelada y sus listeners (código muerto según docs/audit y el hallazgo L-31).
Lista cada archivo a tocar. Chequea que ningún código vivo los despacha. Específicamente:
1. app/Events/ — VentaCompletada.php, VentaCancelada.php
2. app/Listeners/ — VentaCompletadaListener.php, VentaCanceladaListener.php (nota: VentaCanceladaListener referencia un caso EstadoVenta::CANCELADA inexistente y crashearía si alguna vez se disparara)
3. app/Providers/EventServiceProvider.php (si tiene mappings explícitos) o app/Providers/AppServiceProvider.php
4. Cualquier servicio o job que llame event(new VentaCompletada(...)) o event(new VentaCancelada(...))
Usa Grep para encontrar dispatches. Reporta hallazgos antes de sugerir borrados.

Esbozo esperado:

Archivos a borrar:
- app/Events/VentaCompletada.php
- app/Events/VentaCancelada.php
- app/Listeners/VentaCompletadaListener.php
- app/Listeners/VentaCanceladaListener.php (el crashy — referencia EstadoVenta::CANCELADA que no existe)
Archivos a chequear antes de borrar:
- app/Providers/EventServiceProvider.php — Laravel 12 auto-descubre, pero el equipo puede haber mapeado estos manualmente. Grep por VentaCompletada/VentaCancelada — si se encuentran, quita los mappings.
- app/Services/VentaService.php — grep por `event(` para confirmar que no hay dispatches event() de estas clases
- app/Jobs/ — grep por `VentaCompletada\|VentaCancelada` para confirmar que ningún job los despacha
- app/Http/Controllers/ — grep, igual
Si grep no encuentra dispatchers, el borrado es seguro. El caso CANCELADA en VentaCanceladaListener es un bonus — su presencia significa que incluso un dispatch futuro accidental crashearía; quitarlo es una ganancia neta de seguridad.
Tests a actualizar:
- Si hay tests/Feature/Events/VentaCompletadaTest.php o similar, bórralos.
- Si hay tests afirmando Event::assertDispatched(VentaCompletada::class), quita esas aserciones.
Convención del mensaje de commit:
"Remove unused VentaCompletada/VentaCancelada events (L-31 dead code cleanup)"

Prompt 27: Spatie Permission: completar o quitar

Cuándo usar: El equipo está decidiendo si terminar de cablear Spatie Permission o quitar la dependencia.

Prompt:

Spatie Permission está instalado pero mayormente sin uso en Mi Plante. Según la auditoría (docs/audit/07-auth-authorization-map.md y CLAUDE.md raíz), el modelo User no tiene trait HasRoles, ninguna ruta usa middleware role/permission, no existe seeder. Las tablas Spatie existen vía migraciones pero no se usan.
Ayúdame a decidir: ¿completar la implementación o quitar la dependencia?
Para cada opción, lista:
1. El trabajo involucrado (archivos a tocar, líneas de código)
2. Los riesgos (p. ej. quitar crea un camino de downgrade; completar requiere una migración de los chequeos in_array($rol, [...]) existentes)
3. La carga de mantenimiento de aquí en adelante
4. El beneficio (claridad de modelo de seguridad, guardas a nivel de ruta, permisos de grano fino)

Esbozo esperado:

Opción A — Completar la implementación:
Trabajo:
- Agregar trait HasRoles al modelo User (app/Models/User.php)
- Correr un seeder para crear los valores de rol existentes: cliente, aliado, empleado, administrador, administrador_comercial, administrador_financiero — y asignar permisos para cada uno
- Migrar los chequeos inline: encontrar cada `in_array($rol, [...])` en app/Http/Controllers/ y reemplazar con $user->hasAnyRole([...]) o middleware('role:cliente|aliado')
- Actualizar tests que dependan de la columna user.rol directamente (necesitarán assignRole() en lugar de update(['rol' => 'cliente']))
- Agregar un sistema de permisos: definir strings de permiso (p. ej. 'venta.crear', 'producto.aprobar') y guardar rutas/controllers
Riesgos:
- La config teams: true en spatie espera una columna teams. Mi Plante tiene empresa_id en algunos usuarios pero no en todos. Necesita decidirse si "team" = "empresa" o si los teams deben deshabilitarse.
- Los chequeos inline existentes están dispersos; la migración requiere mucho grep y es fácil perderse lugares.
- Tests que bypassan el sistema de roles (la BD de test no tiene roles seedeados) se romperán.
Mantenimiento: medio. Una vez cableado, agregar permisos nuevos es pequeño. La migración es el costo.
Beneficio: auth declarativa (la ruta muestra qué rol puede accederla), menos chequeos inline de strings, mejor tooling.
Opción B — Quitar Spatie Permission:
Trabajo:
- composer remove spatie/laravel-permission
- Dropear las migraciones de spatie (roles, permissions, model_has_roles, model_has_permissions, role_has_permissions) — escribir una migración que las dropee
- Quitar la config spatie (config/permission.php)
- Quitar los aliases de middleware spatie en bootstrap/app.php (líneas 31-33: role, permission, role_or_permission)
- Confirmar que nada más la referencia
Riesgos: muy bajos. Nada lo usa actualmente.
Mantenimiento: bajo. Una dependencia menos.
Beneficio: manifest más limpio, menos confusión para devs nuevos (el estado "instalado pero no cableado" es en sí un landmine — ver "04-when-not-to-trust-the-ai.md").
Recomendación: Opción B (quitar) a menos que el equipo tenga una necesidad concreta a corto plazo de permisos de grano fino. Los chequeos inline funcionan, y la auditoría explícitamente menciona el estado medio-cableado como peligro de confusión. Si/cuando se necesiten permisos de grano fino más adelante, reintroducir Spatie son unas horas de trabajo.

Prompt 28: API v2 con rate limiting

Cuándo usar: Planeando una v2 de la API que agrega throttling y auth adecuado, mientras mantiene v1 viva para clientes existentes.

Prompt:

Quiero introducir /api/v2/* con rate limiting adecuado y auth consistente, mientras mantengo /api/v1/* vivo para clientes existentes. Según docs/audit/02-route-inventory.md, el actual /api/v1/* tiene varios endpoints públicos (datacredito/historial siendo el más sensible).
Propón un plan de migración:
1. Layout de directorio (routes/api.php con prefijos v1 y v2, o routes/api_v2.php aparte)
2. Convención de nombre para los controllers v2 (Api/V2/<Domain>Controller)
3. Estrategia de middleware throttle (per-IP para público, per-user para autenticado)
4. Autenticación para v2 (¿tokens Sanctum? ¿OAuth? El equipo no ha decidido — lista opciones)
5. Política de deprecation (cuánto se queda v1)
6. Cómo testear ambas versiones en el mismo archivo de test
No rompas v1.

Esbozo esperado:

1. Directorio: dividir en dos archivos. routes/api.php mantiene v1 (status quo, sin cambios). Nuevo routes/api_v2.php registrado en bootstrap/app.php bajo withRouting(api: [...ambos archivos...]). Separación más limpia.
2. Naming:
- Controllers V1 se quedan en app/Http/Controllers/Api/<Domain>Controller (p. ej. DataCreditoController)
- Controllers V2 en app/Http/Controllers/Api/V2/<Domain>Controller
- Usar anotaciones OpenAPI o PHPDoc para documentar cada versión por separado
3. Throttle:
- Todos los endpoints v2 obtienen middleware('throttle:api-v2') por defecto
- Endpoints públicos: throttle per-IP, p. ej. 30/min
- Endpoints autenticados: throttle per-user, p. ej. 120/min
- Definir limitadores en AppServiceProvider::boot() bajo RateLimiter::for(...)
4. Opciones de auth:
- Sanctum (nativo de Laravel, basado en tokens, fácil de integrar): RECOMENDADO para v2
- OAuth (pesado, solo vale la pena si integraciones de 3ros lo necesitan)
- JWT (custom, sin beneficios sobre Sanctum aquí)
Decisión: usar Sanctum a menos que el equipo tenga una necesidad específica de lo contrario.
5. Deprecation:
- Anunciar deprecation v1 90 días antes del sunset
- Agregar un header Deprecation a todas las respuestas v1: `Deprecation: true` + `Sunset: <fecha>`
- Seguir aceptando llamadas v1 pero loggearlas; periódicamente alertar por volumen
- Cuando las llamadas v1 caigan bajo un umbral (p. ej. <1% del total), remover
6. Testing:
- tests/Feature/Api/V1/<Endpoint>Test.php y tests/Feature/Api/V2/<Endpoint>Test.php — separadas por versión
- Correr ambas suites en CI
- Un smoke test en tests/Feature/Api/VersionCoexistenceTest.php que pegue los mismos datos vía v1 y v2 y afirme que las formas de respuesta difieren como documentado
Otras restricciones:
- El endpoint LANDMINE público existente /api/v1/datacredito/historial se queda público en v1 (no romper clientes), pero el equivalente v2 debería requerir Sanctum + throttle per-user
- Los endpoints de webhook (/webhooks/*) no son parte de v1 o v2 — se quedan en /webhooks/* con verificación de firma
NO quites /api/v1/* hasta al menos 90 días de disponibilidad de v2. El equipo es dueño del calendario de deprecation.

Prompt 29: Consistencia de timestamps en español

Cuándo usar: Quieres alinear el código en timestamps solo-en-español. Este es un proyecto multi-semana, no un cambio de un solo PR.

Prompt:

Algunos modelos extienden Modelo (creado_en/actualizado_en) y algunos usan defaults Laravel (created_at/updated_at). Cuota es el notable que usa created_at — aunque el resto del namespace Facturacion usa creado_en.
Quiero migrar todos los modelos Facturacion para usar consistentemente creado_en/actualizado_en. Identifica:
1. Cada modelo Facturacion y sus columnas timestamp actuales
2. Cada lugar en el código que lee la columna INCORRECTA en el modelo INCORRECTO (p. ej. VentaService.php:582 lee $venta->created_at; la columna es creado_en — landmine)
3. Cada migración que necesita cambiar
4. La estrategia de migración (renombrar columnas vs agregar nuevas y hacer backfill)
5. El impacto en el frontend (qué componentes Vue leen venta.created_at vs venta.creado_en)

Esbozo esperado:

Modelos en app/Models/Facturacion/:
- OrdenCompra — extiende Modelo (creado_en, actualizado_en) ✓
- Venta — extiende Modelo (creado_en, actualizado_en) ✓
- VentaDetalle — extiende Modelo (creado_en, actualizado_en) ✓
- Beneficiario — extiende Modelo (creado_en, actualizado_en) ✓
- Cuota — extiende Model directamente (created_at, updated_at) ✗ (inconsistente)
Si el objetivo del equipo es consistencia, opciones:
A. Mantener Modelo y migrar Cuota a creado_en (radio de explosión más grande)
B. Dejar de usar Modelo en estos — volver a created_at en todo el board (más fácil de algunas maneras, más difícil de otras — cada otra tabla también tiene que cambiar)
Opción A — hacer que Cuota use creado_en:
Migración:
- ALTER TABLE cuotas RENAME COLUMN created_at TO creado_en;
- ALTER TABLE cuotas RENAME COLUMN updated_at TO actualizado_en;
- Actualizar modelo Cuota: extends Modelo en lugar de Model
- Auditar: cualquier código que lea $cuota->created_at — habrá alguno
Lecturas de columna-incorrecta a arreglar:
- VentaService.php:582 — $venta->created_at (Venta usa creado_en — la columna no existe, retorna null, fallback a now() — LANDMINE L-12)
- VentaService.php:657 — $ordenCompra->created_at (mismo problema)
- VentaService.php:704 — $venta->created_at (igual)
- Cualquier controller o repositorio que filtre/ordene por created_at en un modelo que extiende Modelo
Migraciones a cambiar (si Opción A):
- La migración original cuotas (database/migrations/...create_cuotas_table.php) ya está desplegada; agregar una nueva migración para renombrar
- Los tests que insertan filas a mano con 'created_at' necesitan actualizarse
Impacto en frontend:
- ally/ventas/Page.vue y similar — probablemente ya usan $venta->creado_en (ya que Venta es correcto)
- Páginas Vue relacionadas con Cuota pueden usar $cuota.created_at — busca en resources/js/pages por "created_at"
- Actualizar serialización de props compartidas de Inertia si algún modelo emite vía toArray() y el rename de columna cambia la forma JSON
Estrategia: hazlo en dos PRs.
PR 1: Arreglar las lecturas de COLUMNA-INCORRECTA (L-12). $venta->creado_en, $ordenCompra->creado_en. Tests + cambio, sin migración.
PR 2: (después) Migrar Cuota para usar Modelo. Migración + modelo + auditoría frontend. Esto es más grande y necesita más coordinación.
NO los combines en un PR. El primero es un arreglo claro de bug; el segundo es un cambio estructural con riesgo.

Prompt 30: Auditoría de ruido del interceptor axios

Cuándo usar: Revisando el landmine L-26 donde el interceptor axios del frontend loggea cada respuesta exitosa de vuelta al backend.

Prompt:

Audita el interceptor axios de resources/js para el issue L-26: loggea cada éxito al backend.
Específicamente:
1. Encuentra el archivo del interceptor (resources/js/app.ts probablemente, o un lib/axios.ts compartido)
2. Encuentra qué se loggea en éxito (probablemente POST /api/v1/logs/frontend o similar)
3. Cuantifica el volumen: asumiendo que una sesión activa tiene 20 llamadas axios / hora, ¿cuál es el volumen por usuario activo por día, por 1000 usuarios por día?
4. Propón un arreglo: solo loggear errores (respuestas 4xx y 5xx), o samplear éxitos (p. ej. tasa de sample 1%)
5. Muestra el cambio exacto de código
6. El impacto en el backend: si dejamos de loggear éxitos, ¿qué dashboard o monitoring se rompe?
No cambies código aún. Planea primero.

Esbozo esperado:

1. Ubicación probable: resources/js/app.ts — axios.defaults.timeout está seteado ahí; el interceptor estará cerca. También podría estar en resources/js/lib/axios.ts si fue extraído.
Búsqueda:
grep -rn "axios.interceptors" resources/js/
2. El interceptor probablemente llama algo como axios.post('/api/v1/logs/frontend', {...}) en cada onFulfilled. El endpoint del backend escribe a backend_request_logs (o tabla similar).
3. Volumen:
- 20 llamadas/hora/usuario es una sesión moderada
- 1000 usuarios activos -> 20,000 entradas de log por hora, ~480k por día
- La mayoría son ruido (búsquedas exitosas de producto, toggles de wishlist, actualizaciones de carrito)
4. Opciones de arreglo:
- A: solo loggear errores. Agregar un chequeo: solo llamar axios.post(logs) cuando response.status >= 400. Reducción: ~99%.
- B: samplear éxitos. Usar Math.random() < 0.01 para guardar 1% para análisis de tendencias.
- C: batch y debounce: recolectar entradas de log en memoria, enviar cada 30s como batch.
- D: tirar el interceptor por completo y depender del logging solo-backend vía el canal database_backend_request.
Recomendación: Opción A (solo errores) por ahora. Re-evaluar después de una semana.
5. Cambio de código (asumiendo Opción A):
// Antes
axios.interceptors.response.use(
(response) => {
axios.post(route('logs.frontend'), { url: response.config.url, status: response.status });
return response;
},
(error) => {
axios.post(route('logs.frontend'), { url: error.config?.url, status: error.response?.status, message: error.message });
return Promise.reject(error);
}
);
// Después
axios.interceptors.response.use(
(response) => response, // los éxitos pasan silenciosamente
(error) => {
// Solo loggear errores
axios.post(route('logs.frontend'), {
url: error.config?.url,
status: error.response?.status,
message: error.message,
method: error.config?.method,
}).catch(() => {}); // tragar errores del logger para que no cascadeen
return Promise.reject(error);
}
);
6. Impacto en backend:
- La tabla backend_request_logs se encogerá dramáticamente. Ahorro de almacenamiento.
- Si algún dashboard lee de esta tabla asumiendo que todos-los-éxitos-se-loggean (p. ej. "% de requests exitosos por página"), se rompe. Chequea paneles de Filament o cualquier dashboard de Metabase.
- Si alguna regla de alerta depende de la ausencia de una entrada de log (p. ej. "sin logs en 10 min = down"), se rompe. Verifica con infra/devops.
Si no existen tales dashboards (probable; el equipo no ha construido ninguno), el cambio es seguro.

Tips clave

Estos hábitos separan a los devs que sacan valor de las herramientas IA de los devs que pelean con ellas:

  1. Siempre referencia archivo:línea en tus prompts. “Arregla VentaService.php:582” obtiene una respuesta precisa. “Arregla VentaService” obtiene un refactor genérico. Mientras más específico el input, más correcto el output.

  2. Usa docs/audit/ como referencias ancla en prompts. Di “según docs/audit/16, el pipeline no garantiza el orden de fases”. El modelo lee el doc y aterriza su respuesta. Sin el ancla, el modelo usa conocimiento Laravel general que puede no coincidir con este código.

  3. Pregunta “¿qué podría romperse?” después de cualquier cambio planeado. Después que Claude proponga un arreglo, pregunta “¿qué podría romperse con este cambio?” El follow-up aflora efectos de segundo orden que podrías perder.

  4. No aceptes cambios de IA en zonas de landmine sin revisión manual. Las zonas “do not refactor without asking” en el CLAUDE.md raíz son seis lugares donde la IA debería proponer, nunca ejecutar. Corre /find-landmines-near antes de abrir una sesión de edición en estas zonas.

  5. Cuando la IA afirma con confianza algo sobre el dominio de Mi Plante, verifica con el glosario o el código real. “Sí, Venta tiene un método completar()” → grep antes de creer. La IA está equivocada en ~5% de afirmaciones de dominio. Lee 04-when-not-to-trust-the-ai.md para el catálogo completo de modos de falla.

  6. Cita los términos en español exactamente. “Cuota” no “Quota” o “Installment”. El código encontrará la palabra española; los aliases en inglés no existen.

  7. Reproduce el bug antes de arreglarlo. Siempre escribe un test fallido primero (o al menos un repro en tinker) antes de dejar que la IA proponga un arreglo. Esta es la lección L-10 / L-12: muchos arreglos “obvios” cambian el comportamiento de formas que los tests habrían capturado.

  8. Lee el diff. Aún después de 3 semanas de pair programming, lee el diff. La IA a veces “mejora” código cercano o normaliza strings españoles a inglés. Captura esto en review, no en producción.

  9. Cita qué doc de auditoría leíste. “Según docs/audit/12 §3.4, el cuestionario es condicional a requiere_cuestionario…” le gana a “leí en algún lugar que el cuestionario es condicional”. La especificidad hace que el siguiente dev que lea tu descripción de PR confíe en ti.

  10. Actualiza CLAUDE.md cuando aprendas algo. Si descubres un nuevo landmine o invariante durante tu trabajo, agrega una línea al CLAUDE.md relevante. El siguiente dev (o la IA en la siguiente sesión) será más inteligente por ello.

Ese es el recetario. Marca este archivo. Reléelo mensualmente.