Saltearse al contenido

Tareas de Nivel 1 — Primeras Contribuciones (Correcciones de Bugs)

Las primeras contribuciones — manos en el teclado

Audiencia: desarrolladores nuevos en los días 3-5 del onboarding, con Claude Code o cualquier pareja de IA configurada. Objetivo: cinco correcciones de bugs graduadas que acotan el radio de impacto, entregan valor y enseñan el código base un landmine a la vez. Fuente de verdad: cada tarea a continuación referencia docs/onboarding/04-the-landmines/01-known-bugs.md y ha sido validada contra el código real en el archivo:línea citado.

Cómo usar este documento

  1. Elige la tarea cuya dificultad coincida con tu apetito del día. T1-01 es la de menor alcance; T1-05 es la más grande y ejercita dos tipos de archivos (Vue + PHP).
  2. Lee primero el landmine relacionado en docs/onboarding/04-the-landmines/01-known-bugs.md. La entrada del landmine explica por qué existe el bug, qué toca y el contexto histórico. La tarea aquí te dice qué entregar.
  3. Lee las seis secciones de la tarea antes de empezar. La sección “Qué NO hacer” es la más importante — cada entrada proviene de una trampa real.
  4. Abre una rama usando la convención de 03-how-to-ship.md (<iniciales>/fix/<landmine-id>-<slug>).
  5. Dirige a Claude Code con los prompts a continuación. Revisa cada diff antes de aceptarlo.
  6. Abre un PR usando la plantilla en 03-how-to-ship.md.

Cada tarea debe poder hacerse en una sola sesión. Si no has entregado después de 4 horas, detente y pregunta. O la tarea es más grande de lo que parecía, o hay contexto oculto que el documento omitió — ambas cosas valen la pena sacar a la superficie.


T1-01 — Detener al interceptor de frontend de registrar cada respuesta exitosa

Dificultad: ★☆☆☆☆ Tiempo estimado (con Claude Code como pareja): 1-2 horas Landmine relacionado: L-26 (docs/onboarding/04-the-landmines/01-known-bugs.md) Nivel de riesgo: Bajo — puramente frontend, sin mutación de base de datos, sin riesgo de datos de producción.

El bug

Cada respuesta exitosa de axios (2xx) se envía vía POST a /api/error-logs y se persiste en la tabla MySQL frontend_error_logs junto con el cuerpo completo de la respuesta. El interceptor debía registrar solo errores. Hoy la tabla crece decenas de filas por vista de página, y la PII de los clientes (cupo, dni, contenido del carrito) se escribe en almacenamiento en texto plano en cada solicitud.

Por qué importa

  • Presión sobre la base de datos. La tabla crece sin límite y sin política de retención. Las lecturas y escrituras contra frontend_error_logs se vuelven lentas a medida que crece; toda la app se ralentiza en proporción al tráfico.
  • Fuga de PII. Habeas Data (Ley 1581 / Ley 1266) regula cómo se almacena la PII. Persistir cada respuesta de API en texto plano no cumple ese estándar.
  • Relación señal-ruido. Los errores reales de producción están diluidos ~1000:1 por logs de éxito. Nadie investiga la tabla.

Archivos que tocarás

  • resources/js/plugins/errorInterceptor.ts — el interceptor de respuestas que registra erróneamente los éxitos. Único archivo que debes editar.

Tal vez quieras revisar (solo lectura):

  • resources/js/services/errorLogger.ts — confirma que logSuccess es la ruta que estás eliminando.
  • app/Http/Controllers/ErrorLogController.php — confirma el endpoint que está siendo golpeado.

La corrección

En resources/js/plugins/errorInterceptor.ts:27-50, el interceptor de respuestas tiene dos ramas: una rama de éxito que llama a errorLogger.logSuccess(...), y una rama de error que llama a errorLogger.logAxiosError(...). Reemplaza la rama de éxito con un pass-through. Mantén la rama de error exactamente como está — el registro de errores es el uso legítimo de este interceptor.

La forma antes/después, para orientación:

// BEFORE
instance.interceptors.response.use(
async (response) => {
if (response.config.url && !response.config.url.includes('/api/error-logs')) {
await errorLogger.logSuccess({ url: ..., method: ..., statusCode: ..., responseData: response.data, trackingId: ... });
}
return response;
},
async (error) => { /* unchanged */ }
);
// AFTER
instance.interceptors.response.use(
(response) => response,
async (error) => { /* unchanged */ }
);

Ese es el cambio completo. No refactorices la rama de error. No renombres nada. No elimines errorLogger.logSuccess del archivo del servicio — deja el método en su lugar para que código futuro pueda llamarlo deliberadamente. Solo la invocación automática del interceptor está mal.

Enfoque sugerido con Claude Code

  1. Lee primero el archivo y el landmine relacionado.

    • Lee resources/js/plugins/errorInterceptor.ts (el archivo completo, 55 líneas).
    • Lee el landmine L-26 en docs/onboarding/04-the-landmines/01-known-bugs.md.
  2. Dale a Claude este texto exacto:

    In resources/js/plugins/errorInterceptor.ts, the response interceptor logs every successful response to the backend. That is the bug described as L-26 in docs/onboarding/04-the-landmines/01-known-bugs.md. Change the success arm of instance.interceptors.response.use(...) to a pure pass-through: (response) => response. Do not touch the error arm. Do not delete or modify errorLogger.logSuccess in resources/js/services/errorLogger.ts — leave that method in place for future deliberate use. Output a unified diff of the change only.

  3. Aplica el diff. Ejecuta lint y format:

    Ventana de terminal
    npm run lint
    npm run format
  4. Smoke-test en desarrollo:

    Ventana de terminal
    composer dev

    En un navegador, inicia sesión como cualquier cliente, navega por el catálogo, abre el carrito. En una segunda terminal:

    Ventana de terminal
    php artisan tinker
    >>> DB::table('frontend_error_logs')->where('creado_en', '>', now()->subMinutes(2))->count();

    Debería retornar 0 si solo navegaste exitosamente. Si disparas un error (p. ej., un 404), el conteo sube en uno — ese es el comportamiento esperado.

  5. Agrega un test pequeño (opcional pero recomendado). El proyecto tiene Vitest instalado pero no configurado (ver package.json). El Nivel 1 no requiere que conectes Vitest — ese es su propio alcance. Anótalo como seguimiento en la descripción de tu PR.

Criterios de aceptación

  • En una respuesta exitosa 200/201/204 de axios, no se realiza ningún POST a /api/error-logs.
  • En un error de axios (4xx/5xx), aún se realiza un POST a /api/error-logs (la rama de error no cambia).
  • npm run lint pasa.
  • npm run format:check pasa.
  • Smoke test manual: inicia sesión, navega, confirma que frontend_error_logs no crece durante navegación exitosa.

Plantilla de pull request

Título: fix: stop frontend interceptor from logging every successful response (L-26)

Cuerpo:

## What
Removes the automatic `logSuccess` call from the axios response interceptor so that only errors are sent to `/api/error-logs`.
## Why
L-26 in `docs/onboarding/04-the-landmines/01-known-bugs.md`. The interceptor was logging every 2xx response with full body, polluting `frontend_error_logs`, spilling PII, and hiding real errors.
## How
- `resources/js/plugins/errorInterceptor.ts`: success arm of `instance.interceptors.response.use(...)` is now `(response) => response`. Error arm is unchanged.
## Test plan
- [x] `npm run lint && npm run format:check` pass.
- [x] Local smoke test: navigated the catalog, cart, profile while monitoring `SELECT COUNT(*) FROM frontend_error_logs`. No growth on success.
- [x] Triggered a deliberate 404 in dev tools; the error arm fired one log entry. Verified.
## Risk
Low. Frontend only. No DB schema change. No backend change.
## Follow-ups
- Lock `/api/error-logs` behind `auth` + `throttle` middleware (tracked as part of L-26 phase 2).
- Add a scheduled job to prune historical pollution in `frontend_error_logs`.
- Configure Vitest so this kind of change has an automated guard.

Qué NO hacer

  • No elimines errorLogger.logSuccess. Ese método está en resources/js/services/errorLogger.ts. Eliminarlo está fuera del alcance y romperá cualquier código que lo llame intencionalmente. Deja el método, solo detén la llamada automática.
  • No bloquees /api/error-logs en el mismo PR. Eso es parte de la corrección de L-26 pero cambia el contrato de la API para el frontend; merece su propio PR con su propio plan de pruebas. Anótalo como seguimiento.
  • No agregues una política de retención en el mismo PR. Podar la tabla frontend_error_logs es una operación destructiva que necesita aprobación — es una tarea aparte.
  • No “mejores” la rama de error. La rama de error está bien. Tocarla expande el diff y dispara escrutinio del revisor que no quieres para un primer PR.
  • Cuidado con la IA. Una alucinación común de Claude es eliminar también el interceptor de solicitudes que agrega el trackingId. Mantén eso intacto — es el mecanismo de trazabilidad, no el logger.

Si algo sale mal

  • El diff es una línea más código eliminado. Revertir es git checkout -- resources/js/plugins/errorInterceptor.ts.
  • Si ves que frontend_error_logs sigue creciendo después del cambio, haz un hard refresh — Vite cachea módulos. Confirma que el servidor de desarrollo tomó el cambio (composer dev imprimirá actualizaciones HMR).
  • Si no estás seguro a quién preguntar, este es un cambio solo de frontend sin riesgo de datos de producción. Etiqueta a cualquier revisor.

T1-02 — Aplicar rate-limit al endpoint público de historial de DataCrédito

Dificultad: ★★☆☆☆ Tiempo estimado (con Claude Code como pareja): 2-3 horas Landmine relacionado: L-02 (docs/onboarding/04-the-landmines/01-known-bugs.md) Nivel de riesgo: Bajo — agrega un middleware a una ruta. El tráfico legítimo existente cae fácilmente bajo 10/minuto.

El bug

GET /api/v1/datacredito/historial está definido en routes/api.php:24-26 sin ningún middleware de auth o throttle. Cualquiera en internet puede consultar el buró de crédito colombiano usando las credenciales del buró de Mi Plante. Tres consecuencias inmediatas:

  1. Un atacante puede extraer historial de crédito de colombianos arbitrarios (exposición de Habeas Data bajo Ley 1266 — multas de la SIC de hasta ~2.000 SMLMV por incidente).
  2. El buró cobra por consulta; un atacante puede consumir el presupuesto del buró de Mi Plante.
  3. El rastro de auditoría de consultas al buró de Mi Plante (requerido por SFC Capítulo IV) se contamina con consultas que no son de clientes.

Por qué importa

El endpoint es el landmine regulatorio más visible en el código base. El Doc 16 lo clasifica como un hallazgo de seguridad Crítico. La corrección completa (mover el endpoint detrás de auth, eliminar el montaje público por completo) es una tarea de Nivel 3 porque requiere coordinación con el flujo público de /consultar-cupo que puede compartir el servicio subyacente. El alcance del Nivel 1 es solo rate limiting — no cierra el hueco, pero reduce dramáticamente el impacto práctico mientras planeamos la migración completa.

Archivos que tocarás

  • routes/api.php — agrega ->middleware('throttle:10,1') a la definición de ruta existente.

Tal vez quieras leer (solo lectura):

  • app/Http/Controllers/Api/DataCreditoController.php — confirma el controlador al que apunta esta ruta.
  • app/Http/Kernel.php (si existe) y bootstrap/app.php — confirma que el middleware throttle por defecto de Laravel está registrado. En Laravel 12 este es el caso por defecto; no necesitas registrar un nuevo middleware.

La corrección

Dos cambios en routes/api.php, líneas adyacentes:

// BEFORE
Route::prefix('v1')->group(function () {
Route::get('/datacredito/historial', [DataCreditoController::class, 'consultarHistorial'])
->name('api.datacredito.historial');
Route::prefix('webhooks')->group(function () {
Route::post('/certicamara', CerticamaraController::class)
->name('api.webhooks.certicamara');
});
});
// AFTER
Route::prefix('v1')->group(function () {
Route::get('/datacredito/historial', [DataCreditoController::class, 'consultarHistorial'])
->middleware('throttle:10,1')
->name('api.datacredito.historial');
Route::prefix('webhooks')->group(function () {
Route::post('/certicamara', CerticamaraController::class)
->name('api.webhooks.certicamara');
});
});

Ese es el diff completo. throttle:10,1 significa “10 solicitudes por IP por 1 minuto”. Laravel retorna HTTP 429 con un header Retry-After cuando se excede.

Enfoque sugerido con Claude Code

  1. Lee routes/api.php (33 líneas).

  2. Lee el landmine L-02 en docs/onboarding/04-the-landmines/01-known-bugs.md. Presta atención a la sección Recommended fix — nota que la auditoría recomienda tanto auth como throttle. El Nivel 1 entrega solo el throttle; el cambio de auth queda documentado como seguimiento abajo.

  3. Dale a Claude este texto exacto:

    In routes/api.php, the route GET /api/v1/datacredito/historial (lines 24-26) is public and unthrottled. That is landmine L-02 in docs/onboarding/04-the-landmines/01-known-bugs.md. Add ->middleware('throttle:10,1') to that route’s definition and nothing else. Do not change the webhook route. Do not add auth middleware in this PR — that is a separate task. Output a unified diff.

  4. Lint:

    Ventana de terminal
    composer pint
  5. Smoke test manual (sin hacer llamadas reales al buró):

    Ventana de terminal
    php artisan serve
    # en un segundo shell:
    for i in {1..12}; do
    curl -s -o /dev/null -w "%{http_code}\n" "http://localhost:8000/api/v1/datacredito/historial?numero_documento=12345678&tipo_documento=CC&apellido=GARCIA"
    done

    Las primeras 10 solicitudes deberían retornar lo que el controlador retorne (a menudo un error de validación o un error de servicio externo, dependiendo del entorno). Las solicitudes 11 y 12 deberían retornar 429.

  6. Agrega un feature test (esta es la única tarea de Nivel 1 donde un test es genuinamente pequeño y vale la pena escribir):

    tests/Feature/Api/DataCreditoHistorialRateLimitTest.php
    <?php
    namespace Tests\Feature\Api;
    use Tests\TestCase;
    class DataCreditoHistorialRateLimitTest extends TestCase
    {
    public function test_eleventh_request_in_a_minute_is_throttled(): void
    {
    for ($i = 0; $i < 10; $i++) {
    $this->get('/api/v1/datacredito/historial?numero_documento=12345678&tipo_documento=CC&apellido=GARCIA');
    }
    $response = $this->get('/api/v1/datacredito/historial?numero_documento=12345678&tipo_documento=CC&apellido=GARCIA');
    $response->assertStatus(429);
    }
    }

    Ejecútalo:

    Ventana de terminal
    php artisan test --filter=DataCreditoHistorialRateLimitTest

Criterios de aceptación

  • La undécima solicitud a GET /api/v1/datacredito/historial dentro de un minuto desde la misma IP retorna HTTP 429.
  • La ruta de webhook POST /api/v1/webhooks/certicamara no cambia.
  • composer pint pasa.
  • Feature test agregado y pasa.
  • Sin cambios en los resultados de tests existentes.

Plantilla de pull request

Título: fix: rate-limit public DataCrédito historial endpoint (L-02)

Cuerpo:

## What
Adds `throttle:10,1` middleware to the public `GET /api/v1/datacredito/historial` route.
## Why
L-02 in `docs/onboarding/04-the-landmines/01-known-bugs.md`. The endpoint queries the Colombian credit bureau without rate limiting. An attacker can burn bureau credits, scrape PII, or poison Mi Plante's audit trail. This PR caps the blast radius while the full fix (auth + remove public mount) is planned as Tier 3.
## How
- `routes/api.php`: added `->middleware('throttle:10,1')` to the `api.datacredito.historial` route.
- Added `tests/Feature/Api/DataCreditoHistorialRateLimitTest.php` to lock the behavior.
## Test plan
- [x] `composer pint` passes.
- [x] `php artisan test --filter=DataCreditoHistorialRateLimitTest` passes.
- [x] Local manual test with `curl` confirms 11th request returns 429.
## Risk
Low. The bureau is called server-side from `/consultar-cupo` and from `AprobarCupoService`; both go through the controller as a service call, not as an HTTP request, so neither is affected by the throttle.
## Follow-ups
- Move the endpoint behind `auth:web` and reconsider whether it should be an HTTP endpoint at all (full L-02 fix — Tier 3).
- Add SIEM/Sentry alert when the 429 limit is hit (operational signal that we are being scanned).
- Same throttle pattern should be applied to `/api/error-logs` (part of L-26 phase 2).

Qué NO hacer

  • No agregues middleware de auth en este PR. Es la corrección correcta a largo plazo según la auditoría, pero cambia el contrato para invocadores que legítimamente usan el endpoint de forma anónima (la página /consultar-cupo puede hacer proxy a través de él). Agregar auth en el mismo PR triplica el perfil de riesgo de un cambio que debería ser casi cero.
  • No cambies el rate limit a throttle:60,1 o superior. 10 por minuto es generoso para un flujo de cliente real; un atacante que necesita 11/min es el caso que estamos limitando.
  • No introduzcas un rate limiter custom (RateLimiter::for('datacredito', ...)). El middleware throttle:N,M por defecto está bien para esta tarea. Un limiter custom es un seguimiento si quieres límites por IP+usuario.
  • Cuidado con la IA: Claude a veces intentará “mejorar” el controlador ya que está ahí. Rechaza cualquier diff que toque DataCreditoController.php. Alcance = un archivo de rutas.

Si algo sale mal

  • El cambio es una línea. Rollback: git revert <commit> o git checkout HEAD -- routes/api.php.
  • Si usuarios legítimos chocan con 429, el síntoma es “la página de historial de crédito da error para mí”. Revisa storage/logs/laravel.log para el evento de throttle. Si un flujo legítimo está siendo throttleado, el movimiento correcto no es subir el límite — es poner el flujo legítimo detrás de un limiter por usuario que no comparta el bucket de IP pública.
  • Esta ruta también aparece en el índice de higiene del landmine L-23. No corrijas L-23 aquí.

T1-03 — Agregar un handler failed() a GenerarCreditoDeVenta

Dificultad: ★★★☆☆ Tiempo estimado (con Claude Code como pareja): 3-5 horas Landmine relacionado: L-13 (docs/onboarding/04-the-landmines/01-known-bugs.md) Nivel de riesgo: Bajo si te mantienes dentro de failed(). Agregar un método es puramente aditivo — el flujo existente no cambia.

El bug

app/Jobs/GenerarCreditoDeVenta.php declara public int $tries = 3 y un backoff de 60 segundos pero no define un método failed(\Throwable $e). Cuando el job lanza tres veces, Laravel lo escribe en failed_jobs y se detiene. Hoy esto deja:

  • Venta.estado atascado en el valor que tenía antes del job (típicamente PENDIENTE).
  • OrdenCompra.estado atascado (o incorrecto — ver L-12).
  • El cupo_disponible del cliente sin cambios (el inventario no se restaura porque rechazarVenta solo se dispara con un rechazo limpio de SHIVAM, no con una excepción lanzada).
  • Sin alerta. Sin entrada de log que diga “esta venta ahora está permanentemente varada”.

El cliente ve su compra como permanentemente pendiente, ops no tiene superficie para recuperarse, y la única ruta para reconciliar es SQL manual.

Por qué importa

Esta es una brecha real de confiabilidad en la cadena de generación de crédito. Una caída de SHIVAM SOAP hoy produce una cola de jobs muertos y una cola de ventas varadas. El análisis de queue-jobs del Doc 16 marcó esta como la corrección de mayor impacto único en la capa de jobs.

Archivos que tocarás

  • app/Jobs/GenerarCreditoDeVenta.php — agregar el método failed().

Leerás (solo lectura):

  • app/Enum/Facturacion/EstadoVenta.php — para confirmar que RECHAZADA es el estado terminal correcto.
  • app/Jobs/CLAUDE.md — contexto por directorio que ya documenta la convención que esta tarea implementa.
  • app/Models/Facturacion/Venta.php y app/Models/Cliente.php — para confirmar las relaciones que tu handler recorrerá.

La corrección

Agrega un método failed(\Throwable $exception): void que:

  1. Registre la falla a nivel critical con venta_id, cliente_id, empresa_id, el mensaje de la excepción y un trace truncado.
  2. Restaure el inventario recorriendo venta.detalles[].precio.inventario e incrementando cada uno por el cantidad del detalle. Esto refleja el comportamiento de rechazarVenta() ya en la clase.
  3. Marque la Venta como RECHAZADA con un causal de sistema como SYS:retries_exhausted. Esta es la convención de app/Jobs/CLAUDE.md y la corrección recomendada de L-13.
  4. Envuelva los cambios de inventario + estado en un DB::transaction().

En esta tarea NO: intentes despachar una notificación de Slack, intentes tocar el OrdenCompra.estado (eso es trabajo de L-12 — fuera de alcance aquí), o llames a cualquier servicio externo.

Esquema del método a agregar (esto es intencionalmente cercano a la corrección recomendada en L-13):

public function failed(\Throwable $exception): void
{
Log::critical('GenerarCreditoDeVenta: agotados los reintentos', [
'venta_id' => $this->venta->id,
'cliente_id' => $this->cliente->id,
'empresa_id' => $this->empresa->id,
'error' => $exception->getMessage(),
// Trace truncated to keep log rows small.
'trace' => mb_substr($exception->getTraceAsString(), 0, 2000),
]);
DB::transaction(function () use ($exception) {
$this->venta->loadMissing('detalles.precio');
foreach ($this->venta->detalles as $detalle) {
if ($detalle->precio) {
$detalle->precio->increment('inventario', $detalle->cantidad);
}
}
$this->venta->update([
'estado' => EstadoVenta::RECHAZADA,
'causal' => 'SYS:retries_exhausted - ' . mb_substr($exception->getMessage(), 0, 200),
]);
});
}

Tendrás que asegurarte de que use Illuminate\Support\Facades\Log; esté en la parte superior del archivo (ya está, según el código real) y que EstadoVenta esté importado (lo está — línea 5).

Enfoque sugerido con Claude Code

  1. Lee estos archivos en orden:

    • app/Jobs/GenerarCreditoDeVenta.php (140 líneas).
    • app/Jobs/CLAUDE.md (el contexto por directorio).
    • L-13 en docs/onboarding/04-the-landmines/01-known-bugs.md.
  2. Dale a Claude el prompt:

    In app/Jobs/GenerarCreditoDeVenta.php, add a failed(\Throwable $exception): void method that runs when the job exhausts its 3 retries. The method should mirror the inventory-restore logic of the existing rechazarVenta(...) private method, then mark the venta as EstadoVenta::RECHAZADA with causal SYS:retries_exhausted - <truncated message>. Wrap both side-effects in a single DB::transaction(...). Log at critical level with venta_id, cliente_id, empresa_id, the exception message, and a truncated trace (max 2000 chars). Do not touch the existing handle() or rechazarVenta() methods. Do not touch OrdenCompra (that is landmine L-12’s scope). Output a unified diff.

  3. Lint:

    Ventana de terminal
    composer pint
  4. Escribe un feature test en tests/Feature/Jobs/GenerarCreditoDeVentaFailedTest.php. Esqueleto:

    <?php
    namespace Tests\Feature\Jobs;
    use App\Enum\Facturacion\EstadoVenta;
    use App\Jobs\GenerarCreditoDeVenta;
    use App\Models\Cliente;
    use App\Models\Empresa;
    use App\Models\Facturacion\Venta;
    use App\Models\Facturacion\VentaDetalle;
    use App\Models\Precio;
    use Illuminate\Foundation\Testing\RefreshDatabase;
    use Tests\TestCase;
    class GenerarCreditoDeVentaFailedTest extends TestCase
    {
    use RefreshDatabase;
    public function test_failed_handler_restores_inventory_and_marks_venta_rechazada(): void
    {
    $cliente = Cliente::factory()->create();
    $empresa = Empresa::factory()->create();
    $venta = Venta::factory()->create([
    'user_id' => $cliente->user_id,
    'estado' => EstadoVenta::PENDIENTE,
    ]);
    $precio = Precio::factory()->create(['inventario' => 5]);
    VentaDetalle::factory()->create([
    'venta_id' => $venta->id,
    'precio_id' => $precio->id,
    'cantidad' => 2,
    ]);
    $job = new GenerarCreditoDeVenta($empresa, $cliente, $venta);
    $job->failed(new \Exception('SHIVAM timeout'));
    $this->assertEquals(7, $precio->fresh()->inventario, 'inventario should be restored by 2');
    $this->assertEquals(EstadoVenta::RECHAZADA->value, $venta->fresh()->estado);
    $this->assertStringContainsString('SYS:retries_exhausted', $venta->fresh()->causal);
    }
    }

    Ejecuta:

    Ventana de terminal
    php artisan test --filter=GenerarCreditoDeVentaFailedTest
  5. Smoke-test en desarrollo (opcional, más confianza): en tinker, fuerza el job a un estado de falla y observa:

    php artisan tinker
    >>> $job = new \App\Jobs\GenerarCreditoDeVenta($empresa, $cliente, $venta);
    >>> $job->failed(new \Exception('forced failure for smoke test'));
    >>> $venta->fresh()->estado; // EstadoVenta::RECHAZADA
    >>> $venta->fresh()->causal; // 'SYS:retries_exhausted - forced failure...'

Criterios de aceptación

  • El método failed(\Throwable $exception): void existe en GenerarCreditoDeVenta.
  • Cuando se invoca, registra a nivel critical con las cuatro claves de contexto (venta_id, cliente_id, empresa_id, error).
  • El inventario se restaura: cada VentaDetalle.precio.inventario se incrementa por cantidad.
  • El estado de Venta se mueve a EstadoVenta::RECHAZADA.
  • El campo causal de Venta captura el marcador del sistema.
  • El feature test pasa.
  • Sin regresiones en feature tests existentes de Jobs/.
  • composer pint pasa.

Plantilla de pull request

Título: fix(jobs): add failed() handler to GenerarCreditoDeVenta (L-13)

Cuerpo:

## What
Adds a `failed(\Throwable $exception): void` method to `GenerarCreditoDeVenta`. When the job exhausts its 3 retries, it now restores inventory and marks the Venta as `RECHAZADA` with a system causal.
## Why
L-13 in `docs/onboarding/04-the-landmines/01-known-bugs.md`. Without `failed()`, an exhausted job left the venta stuck `PENDIENTE` forever, with no inventory restoration and no observability. Recovery required manual SQL.
## How
- `app/Jobs/GenerarCreditoDeVenta.php`: new `failed()` method using `DB::transaction()` to (1) restore inventory across `venta.detalles` and (2) mark the venta `RECHAZADA` with causal `SYS:retries_exhausted - <message>`.
- Mirrors the existing `rechazarVenta()` pattern in the same class.
- `tests/Feature/Jobs/GenerarCreditoDeVentaFailedTest.php`: locks the behavior.
## Test plan
- [x] `php artisan test --filter=GenerarCreditoDeVentaFailedTest` passes.
- [x] Full jobs suite still green.
- [x] `composer pint` passes.
## Risk
Low. `failed()` is purely additive — it runs only when `tries` is exhausted. The happy-path code in `handle()` is unchanged.
## Follow-ups
- Plug a Slack / Sentry alert in the `failed()` body (depends on monitoring stack — L-22).
- Apply the same `failed()` convention to `ProcesarPagareDigital` and `ValidarPagareDigital` (per `app/Jobs/CLAUDE.md`).
- Fix the `OrdenCompra.estado` race documented in L-12 (separate PR — that one is a multi-venta orchestration change).

Qué NO hacer

  • No cambies handle(). El handle actual es la ruta en vivo. failed() es la ruta de recuperación. Tocar ambas a la vez duplica tu superficie de riesgo y triplica la carga del revisor.
  • No cambies OrdenCompra.estado en failed(). Ese es territorio de L-12. Una venta fallida en una orden multi-empresa no significa automáticamente que la orden deba marcarse como terminal — la política para órdenes con falla parcial no se ha decidido.
  • No llames a Notification::send(...) aún. L-22 (sin stack de monitoreo) significa que Slack no está configurado. Documenta la alerta como seguimiento; no llames a un canal inexistente.
  • Cuidado con la IA: Claude a veces intentará “decrementar el cupo de vuelta” dentro de failed() — pero el cupo solo se decrementa después del éxito en handle() (línea 93). Los jobs fallidos nunca decrementaron en primer lugar, así que no hay nada que restaurar del lado del cupo. Rechaza cualquier diff que decremente o incremente cupo_disponible desde failed().
  • Cuidado con la IA: Claude a veces envolverá loadMissing fuera de la transacción. Eso está bien, pero asegúrate de que el loop y el update estén ambos dentro de DB::transaction(...) para que una falla parcial revierta limpiamente.

Si algo sale mal

  • El cambio es una adición de método. Rollback: revierte el commit; el comportamiento anterior (falla silenciosa hacia failed_jobs) se restaura.
  • Si tu feature test pasa pero el assertion en causal falla, revisa que la columna permita suficiente longitud — ventas.causal es una columna string con longitud fija. La migración actual la define a 255 chars; el truncamiento mb_substr($e->getMessage(), 0, 200) está dimensionado en consecuencia.
  • Si loadMissing('detalles.precio') produce una advertencia N+1 en dev, eso está bien para esta ruta de código (failed() se ejecuta raramente). No “optimices” con un query custom en este PR.

T1-04 — Corregir el desajuste de contrato entre Profile.vue y el backend

Dificultad: ★★☆☆☆ Tiempo estimado (con Claude Code como pareja): 2-3 horas Landmine relacionado: L-18 (docs/onboarding/04-the-landmines/01-known-bugs.md) Nivel de riesgo: Bajo — toca un archivo Vue. Sin cambios en backend. Sin cambios en base de datos.

El bug

resources/js/pages/settings/Profile.vue es scaffolding de Laravel Breeze sobrante. Vincula y envía un único campo name junto con email:

const form = useForm({
name: user.name,
email: user.email,
});

Pero el backend app/Http/Requests/Settings/ProfileUpdateRequest.php valida nombres y apellidos (separados). El modelo User no tiene una columna name — tiene nombres y apellidos. Así que cuando un cliente envía el formulario, el backend lo rechaza con un 422 y el usuario ve un error genérico de Inertia.

Por qué importa

El flujo de “editar mi perfil” no funciona hoy. Para cualquier cliente que haya notado que la página existe, esto es una funcionalidad central rota. La corrección es pequeña y de alta señal — le dice al nuevo desarrollador que el código base prefiere nombres de campo en español y que sí existen bugs puramente de frontend.

Archivos que tocarás

  • resources/js/pages/settings/Profile.vue — cambio principal.

Referencias de solo lectura:

  • app/Http/Requests/Settings/ProfileUpdateRequest.php — confirma el contrato del backend.
  • app/Models/User.php — confirma que nombres y apellidos son columnas reales.
  • resources/js/types/index.d.ts (o donde se tipe User) — confirma la forma de page.props.auth.user.

La corrección

En resources/js/pages/settings/Profile.vue:31-40:

// BEFORE
const form = useForm({
name: user.name,
email: user.email,
});
// AFTER
const form = useForm({
nombres: user.nombres,
apellidos: user.apellidos,
email: user.email,
});

Y en el template (líneas 52-56), reemplaza el input único name con dos inputs — nombres y apellidos. Etiquétalos en español para coincidir con el resto de la app. También actualiza el texto de la página (“Profile information”, “Update your name and email address”) a español por consistencia. La etiqueta del breadcrumb item también vale la pena españolizar ('Perfil' en lugar de 'Profile settings').

La forma del nuevo bloque de template:

<div class="grid gap-2">
<Label for="nombres">Nombres</Label>
<Input id="nombres" class="mt-1 block w-full" v-model="form.nombres" required autocomplete="given-name" placeholder="Tus nombres" />
<InputError class="mt-2" :message="form.errors.nombres" />
</div>
<div class="grid gap-2">
<Label for="apellidos">Apellidos</Label>
<Input id="apellidos" class="mt-1 block w-full" v-model="form.apellidos" required autocomplete="family-name" placeholder="Tus apellidos" />
<InputError class="mt-2" :message="form.errors.apellidos" />
</div>

Si el tipo TypeScript User no tiene nombres / apellidos, puedes ver un error de tipo. Dos opciones:

  1. (Preferido) Actualiza la interfaz User en resources/js/types/index.d.ts para incluir nombres: string; apellidos: string;. Esta es la jugada correcta a largo plazo y es puramente aditiva.
  2. (Rápido) Cast: as { nombres: string; apellidos: string; email: string; email_verified_at?: string } en el sitio del destructure.

Elige la opción 1 si el archivo de tipos es pequeño y autocontenido. Usa la opción 2 solo si el archivo de tipos requiere tocar definiciones no relacionadas.

Enfoque sugerido con Claude Code

  1. Lee resources/js/pages/settings/Profile.vue, app/Http/Requests/Settings/ProfileUpdateRequest.php, y app/Models/User.php (parte superior del archivo — fillable / casts).

  2. Lee el landmine L-18.

  3. Dale a Claude el prompt:

    In resources/js/pages/settings/Profile.vue, the form uses name but the backend (ProfileUpdateRequest) validates nombres and apellidos (landmine L-18). Update the useForm call to bind nombres, apellidos, email. Replace the single Name input in the template with two inputs (Nombres / Apellidos). Use Spanish labels and placeholders matching the rest of the app. Update the breadcrumb item to Perfil and the heading to Información del perfil / Actualiza tus nombres, apellidos y correo electrónico. Do not change app/Http/Requests/Settings/ProfileUpdateRequest.php. If the User TypeScript type lacks nombres / apellidos, add them to the type in resources/js/types/index.d.ts. Output a unified diff covering only the necessary files.

  4. Lint:

    Ventana de terminal
    npm run lint
    npm run format
  5. Smoke test en navegador:

    Ventana de terminal
    composer dev
    • Inicia sesión como cualquier cliente sembrado (ver database/seeders/ClienteAdministradorSeeder.php para credenciales).
    • Navega a /settings/profile.
    • Cambia Nombres y Apellidos, presiona Guardar.
    • Refresca — los valores persisten.
    • Abre DevTools → Network. El PATCH /settings/profile debería retornar 200 con el usuario actualizado, no 422.
  6. Opcional: hay una clase de test de backend en tests/Feature/Settings/. Si aún no afirma la forma del campo (debería, dado L-18), agrega un test:

    tests/Feature/Settings/ProfileUpdateContractTest.php
    public function test_profile_update_accepts_nombres_and_apellidos(): void
    {
    $user = User::factory()->create(['nombres' => 'Old', 'apellidos' => 'Name', 'email' => 'old@example.com']);
    $this->actingAs($user)->patch('/settings/profile', [
    'nombres' => 'Nuevo',
    'apellidos' => 'Apellido',
    'email' => 'old@example.com',
    ])->assertRedirect();
    $this->assertSame('Nuevo', $user->fresh()->nombres);
    $this->assertSame('Apellido', $user->fresh()->apellidos);
    }

Criterios de aceptación

  • El formulario vincula nombres, apellidos, email.
  • El envío PATCH retorna 200/302 (redirect de Inertia) y los nombres + apellidos del usuario se persisten.
  • Sin 422 en la pestaña de red durante envío normal.
  • El texto de UI está en español para coincidir con el resto de la app.
  • npm run lint y npm run format:check pasan.
  • Si agregaste un test de backend, pasa; los tests existentes de Settings/ siguen pasando.

Plantilla de pull request

Título: fix: align Profile.vue with backend nombres/apellidos contract (L-18)

Cuerpo:

## What
Updates the customer profile settings page to use `nombres` and `apellidos` instead of a single `name` field, matching the backend `ProfileUpdateRequest` validator.
## Why
L-18 in `docs/onboarding/04-the-landmines/01-known-bugs.md`. The page was leftover Breeze scaffolding and submitted `name`; the backend rejected with 422 because it expects `nombres` and `apellidos`. The profile update flow was broken for every customer who tried it.
## How
- `resources/js/pages/settings/Profile.vue`: form binds `nombres`, `apellidos`, `email`. Template replaces the single Name input with two inputs labeled in Spanish.
- `resources/js/types/index.d.ts`: User type now includes `nombres: string; apellidos: string;`.
- (Optional) `tests/Feature/Settings/ProfileUpdateContractTest.php`: locks the contract.
## Test plan
- [x] Manual: logged in, changed name fields, confirmed persistence.
- [x] `npm run lint && npm run format:check` pass.
- [x] (If added) backend contract test passes.
## Risk
Low. Frontend-only behavioral change. No DB schema change. No backend validation change.
## Follow-ups
- Audit the rest of `resources/js/pages/settings/*` for the same drift.
- Consider auto-generating a frontend types file from PHP models (e.g., `tightenco/ziggy` already covers routes — a `php artisan typescript:transform` style script would close the same gap for models).

Qué NO hacer

  • No cambies ProfileUpdateRequest.php. El contrato del backend es el correcto. El frontend es el que se desvió.
  • No renombres la ruta. profile.update está cableada en la nav del layout de Inertia. Renombrarla dispara una cascada de cambios que no necesitas.
  • No agregues un accessor name al modelo User. Eso enmascararía el bug para clientes antiguos pero no arregla el formulario. También recrea el problema de doble nombrado del que advierte L-11.
  • Cuidado con la IA: Claude a veces “ayudará” agregando una propiedad computada name como fallback. Rechaza. El esquema es nombres + apellidos; el UI debe coincidir.
  • Cuidado con la IA: Claude a veces agregará validación del lado del cliente (p. ej., un esquema zod). Fuera de alcance. El backend valida; el frontend solo recolecta y envía.

Si algo sale mal

  • Cambio solo de frontend. Rollback: git checkout HEAD -- resources/js/pages/settings/Profile.vue resources/js/types/index.d.ts.
  • Si el formulario envía pero la página no se refresca con nuevos valores, revisa que el User compartido vía Inertia (HandleInertiaRequests) incluya los campos actualizados. El middleware debería releer el usuario; si cachea, ese es su propio bug.
  • Si user.nombres es undefined, el prop de usuario de Inertia no está exponiendo esos campos. Revisa app/Http/Middleware/HandleInertiaRequests.php — ya debería retornar nombres y apellidos. Si no, agrégalos ahí; ese es un cambio aditivo de una línea.

T1-05 — Corregir el desajuste de paginador en el composable useWishlist

Dificultad: ★★★☆☆ Tiempo estimado (con Claude Code como pareja): 3-4 horas Landmine relacionado: L-19 (docs/onboarding/04-the-landmines/01-known-bugs.md) Nivel de riesgo: Bajo — toca un composable TypeScript. Sin cambios de backend requeridos.

El bug

resources/js/composables/useWishlist.ts:25-35 asume que el backend retorna un arreglo plano:

const { data } = await axios.get(route('lista-deseos.index'));
items.value = Array.isArray(data) ? data.map((d: any) => ({ id: d.id, precio_id: d.precio_id })) : [];

Pero ListaDeseoController::index llama a obtenerListaDeseos($search, $perPage) y retorna el resultado vía response()->json($listaDeseo). Ese servicio retorna un paginador de Laravel (LengthAwarePaginator) — un objeto con la forma { data: [...], current_page, last_page, per_page, total, ... }. Así que Array.isArray(data) es false y el composable establece items.value = []. La wishlist aparece vacía aún cuando hay items.

Por qué importa

Los clientes que agregan items a su wishlist ven una lista vacía después del refresh. La wishlist es una funcionalidad de bajo riesgo en aislamiento pero también es un ejemplo representativo del patrón L-18/L-19/L-20: frontend y backend no están de acuerdo en la forma de una respuesta. Corregirlo le enseña al desarrollador a leer ambos extremos de un contrato de API.

Archivos que tocarás

  • resources/js/composables/useWishlist.ts — cambio principal.

Referencias de solo lectura:

  • app/Http/Controllers/Market/ListaDeseoController.php — confirma que el index del controlador retorna el paginador del servicio.
  • app/Services/ListaDeseoService.php — confirma que obtenerListaDeseos retorna un paginador.
  • Cualquier página que consuma el composable (busca useWishlist en resources/js/pages/) — para verificar que el lado del consumidor siga funcionando.

La corrección

En useWishlist.ts:25-35, normaliza la forma de la respuesta. El backend puede retornar tanto un paginador (el comportamiento actual) como — históricamente — un arreglo plano. La lectura defensiva maneja ambos:

async function load(force = false) {
if (loaded.value && !force) return;
try {
const { data } = await axios.get(route('lista-deseos.index'));
// The controller returns a Laravel paginator: { data: [...], total, ... }.
// Historically it returned a plain array; we accept both shapes.
const list = Array.isArray(data) ? data : (data?.data ?? []);
items.value = list.map((d: any) => ({ id: d.id, precio_id: d.precio_id }));
ensureIndex();
loaded.value = true;
} catch {
// silencioso
}
}

Ese es el único cambio en el composable. No cambies los métodos add / remove / toggle — ya manejan la forma dual (Array.isArray(data) ? data[0] : data en la línea 47). Anótalo en la descripción de tu PR como una pista de que el contrato ha sido inestable por un tiempo.

El conteo total (usado por el badge de nav en AppHeader.vue o similar) no está en el composable hoy. Si el consumidor lee items.value.length, la corrección entrega el conteo correcto para la página actual. Si quieres el total global del paginador, expónlo como una ref total:

// at module scope, alongside `items` and `loaded`
const total = ref(0);
async function load(force = false) {
// ...
if (!Array.isArray(data) && typeof data?.total === 'number') {
total.value = data.total;
} else {
total.value = list.length;
}
// ...
}
// in the public hook return
export function useWishlist() {
return { items, loaded, total, /* existing */ load, add, remove, toggle, isInWishlist, loadingOp };
}

La ref total es opcional para esta tarea. Solo agrégala si un consumidor actualmente muestra un badge de wishlist — busca useWishlist en el código base para averiguarlo. Si ningún consumidor usa .length como el badge hoy, omite el campo total y mantente mínimo.

Enfoque sugerido con Claude Code

  1. Lee estos tres archivos:

    • resources/js/composables/useWishlist.ts (99 líneas).
    • app/Http/Controllers/Market/ListaDeseoController.php (48 líneas).
    • app/Services/ListaDeseoService.php (el método obtenerListaDeseos).
  2. Lee el landmine L-19.

  3. Inventaria los consumidores (Claude puede hacerlo por ti):

    In the directory resources/js/pages/, find every file that imports useWishlist and tell me what it reads from the returned object. I am asking because I want to know whether the wishlist count UI depends on items.value.length or on a separate total field.

  4. Dale a Claude el prompt:

    In resources/js/composables/useWishlist.ts, the load() function (lines 25-35) assumes the backend returns a plain array. The backend in fact returns a Laravel paginator: { data: [...], total: number, ... }. Update load() to handle both shapes: if data is an array, use it directly; otherwise read from data.data. Do not change the add, remove, or toggle methods. Do not add a total field unless consumer pages already depend on one. Output a unified diff.

  5. Lint:

    Ventana de terminal
    npm run lint
    npm run format
  6. Smoke test en navegador:

    Ventana de terminal
    composer dev
    • Inicia sesión como un cliente sembrado.
    • Agrega 2-3 items a la wishlist haciendo clic en los íconos de corazón en el catálogo.
    • Recarga la página de wishlist.
    • Los items ahora deberían ser visibles. Sin la corrección, no lo eran.
  7. (Opcional) Bloquea el comportamiento con un test de Vitest. Vitest está en package.json pero no configurado. Anótalo en los seguimientos de tu PR; no lo configures como parte de este PR.

Criterios de aceptación

  • Después de agregar items a wishlist y recargar, los items son visibles.
  • useWishlist().items se popula sin importar si el backend retorna un paginador o un arreglo plano.
  • add, remove, toggle siguen funcionando sin cambios.
  • npm run lint pasa.
  • npm run format:check pasa.
  • Manual: confirmado en dev que el badge de conteo de wishlist (si existe) refleja la realidad.

Plantilla de pull request

Título: fix: useWishlist composable now handles paginator response (L-19)

Cuerpo:

## What
Updates `useWishlist.load()` to accept both a plain-array and Laravel-paginator response shape from `GET /lista-deseos`.
## Why
L-19 in `docs/onboarding/04-the-landmines/01-known-bugs.md`. The composable's `Array.isArray(data)` check returned false on the actual paginator response, so `items.value` was set to `[]` and the wishlist appeared empty after every reload. The bug was masking real saved items.
## How
- `resources/js/composables/useWishlist.ts`: `load()` reads from `data.data` when the response is a paginator; falls back to `data` when it is a raw array.
- `add` / `remove` / `toggle` unchanged — they already handled both shapes (a hint that the contract has been unstable).
## Test plan
- [x] Manual: added 3 items to wishlist as a seeded customer, reloaded, all 3 visible.
- [x] `npm run lint && npm run format:check` pass.
## Risk
Low. Composable-only change. The defensive read also handles the legacy plain-array shape, so reverting the backend to that shape later (if anyone does) does not break this code.
## Follow-ups
- Decide and document the canonical response shape for `/lista-deseos` (paginator or array). Then drop the dual-handling in `add` / `remove` / `toggle`.
- Add a Vitest test (Vitest is installed but not configured — that is its own task).
- Same shape-drift pattern exists for L-18 (Profile) and L-20 (Ventas status colors); see the landmine doc.

Qué NO hacer

  • No cambies el backend. El paginador es la forma correcta a largo plazo (la wishlist podría crecer mucho). El frontend es el que debe adaptarse.
  • No elimines el manejo dual en add / remove. Esos métodos ya aceptan ambas formas. Eliminar la rama legacy ensancha el diff y te obliga a verificar toda la cadena de respuesta del backend — fuera de alcance.
  • No refactorices el composable a una clase. Pinia / composables basados en clase son una elección de arquitectura válida pero una conversación diferente. Mantén el diff pequeño.
  • Cuidado con la IA: Claude a veces agregará un cambio <script lang="ts" setup> a una página consumidora para “usar el nuevo campo total”. Si no necesitabas agregar total, rechaza cualquier diff que toque páginas consumidoras — desviación de alcance.
  • Cuidado con la IA: Claude a veces intentará “arreglar” el bloque silencioso catch {}. El catch silencioso es intencional según el comentario en el código (// silencioso). No lo toques en este PR. La UX de la wishlist está cableada para ser silenciosa; alertar sobre cargas fallidas es una decisión de UX, no una decisión de limpieza de código.

Si algo sale mal

  • Cambio solo de composable. Rollback: git checkout HEAD -- resources/js/composables/useWishlist.ts.
  • Si la wishlist sigue vacía después de la corrección, dos chequeos:
    1. En DevTools Network, ¿qué retorna GET /lista-deseos? Si data.data es un arreglo vacío, la wishlist está genuinamente vacía para este usuario.
    2. Si data.data tiene items pero la página aún no renderiza nada, revisa la página consumidora — puede leer items.value y asumir una forma diferente (p. ej., items.value.id en lugar de items.value[].id).
  • Si el type checker se queja de (d: any), esa es la convención deliberada de tipado laxo del proyecto (ver CLAUDE.md). Deja el any en esta tarea; ajustar tipos es un PR diferente.

Cierre — pasando de Nivel 1 a Nivel 2

Cuando hayas entregado una tarea de Nivel 1 de principio a fin, has hecho el loop completo: leer el landmine, validar contra el código real, proponer un cambio, escribir un test, lint, prueba en navegador, abrir un PR, responder a la revisión, hacer merge.

Una vez que hayas entregado dos tareas de Nivel 1 (idealmente una de frontend y una de backend), gradúa a 02-tier-2-tasks.md. El trabajo de Nivel 2 es más amplio en alcance (5-15 archivos), requiere más entendimiento del dominio, y es donde empiezas a internalizar la arquitectura de Mi Plante en lugar de solo parcharla.

Antes de empezar una tarea de Nivel 2, vuelve a leer docs/onboarding/04-the-landmines/01-known-bugs.md de principio a fin. Las tareas de Nivel 1 están deliberadamente elegidas para evitar las zonas más peligrosas; Nivel 2 empieza a rozarlas. Entrar con el mapa completo de landmines en tu cabeza es la jugada más segura.


Apéndice: Referencia rápida de Nivel 1

IDTítuloDificultadLandmineArchivosTiempo
T1-01Detener interceptor de frontend de registrar éxitos★☆☆☆☆L-2611-2h
T1-02Rate-limit a historial de DataCrédito★★☆☆☆L-021 (+1 test)2-3h
T1-03Agregar failed() a GenerarCreditoDeVenta★★★☆☆L-131 (+1 test)3-5h
T1-04Corregir contrato de Profile.vue★★☆☆☆L-181-22-3h
T1-05Corregir paginador de useWishlist★★★☆☆L-1913-4h

Ventana total de entrega de Nivel 1: ~11-17 horas de trabajo enfocado. Apunta a una tarea por día durante la semana 1.