# Plan de Cuotas Automático — Alcance por Categoría, Trazabilidad y Consistencia SIFEN — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** El selector "Plan de cuotas automático" (POS y Solicitud de Crédito) pasa a filtrar por categoría/precio de los productos del carrito (igual que ya hace el selector por producto desde Fase 6); el plan que efectivamente se usa queda persistido en la factura/solicitud; y el número de cuotas declarado a SIFEN (`dCuotas`) se deriva de la cuota real facturada, no de una fila de catálogo elegida por separado.

**Architecture:** Se extrae a un util puro compartido el matching por categoría/precio que hoy vive solo dentro de `generarGrillaCuotas` (Fase 6), y se reutiliza en un nuevo método/endpoint que filtra los planes automáticos por los ítems reales del carrito (todos deben matchear — un plan automático financia el monto total, no ítem por ítem). Se agrega una columna `plan_cuota_id` nullable a `factura_cab` y `solicitud_credito` para trazabilidad, conectando un callback que el componente de pago ya expone pero que hoy nadie usa. Se corrige `dcuotas` para que salga de la cantidad real de cuotas generadas, no del catálogo de condiciones de pago.

**Tech Stack:** NestJS + Prisma + PostgreSQL (`novasispy-backend-api`), Jest para tests unitarios, React + MUI + TanStack Query (`novasispy-erp`, sin suite de tests automatizada — verificación de cambios frontend vía eslint + prueba manual/E2E).

**Spec:** `docs/plan-motor-precios-rentabilidad.md` §12 (FASE 7), mismo repo backend.

## Global Constraints

- No commitear nada sin pedido explícito, salvo que se confirme lo contrario al ejecutar (mismo criterio que en la ejecución de Fase 6: si se corre con Subagent-Driven Development, confirmar con el usuario si los commits por tarea están autorizados antes de la Task 1).
- `condiciones_pago` NO se toca — sigue siendo el catálogo manual que alimenta `iCondOpe`. Este plan no fusiona `condiciones_pago` con `planes_cuotas`.
- `plan_cuota_id` es opcional en todo el flujo — una factura/solicitud sin plan automático (selector por producto, o carga manual) debe seguir funcionando exactamente igual que hoy, con el campo en `null`.
- El filtro de planes aplicables exige que **todos** los ítems del carrito matcheen un plan para que ese plan aparezca como opción — nunca alguno alcanza (un plan automático financia el monto total del carrito, no un ítem individual).
- El matching de categoría mira un solo nivel hacia arriba (categoría del producto y su padre directo) — mismo criterio que Fase 6, sin cambiarlo.
- `facturas.service.ts` (4081 líneas, sin ningún test existente) no recibe un test unitario nuevo para el cambio de `plan_cuota_id` — el método `create()` completo es un `$transaction` de ~900 líneas con decenas de llamadas a Prisma; escribir un test aislado significativo para una sola línea de passthrough no es proporcional. Ese cambio puntual se verifica con `tsc --noEmit` + la prueba manual E2E de la Task 10. `solicitudes-credito.service.ts` (764 líneas, método `create` de ~90 líneas) sí es proporcional a testear y se le agrega su primer spec file en este plan.

---

### Task 1: `planAplicaAProducto()` — extraer el matching de Fase 6 a un util compartido

**Files:**
- Create: `src/common/utils/plan-cuotas-matching.util.ts`
- Create: `src/common/utils/plan-cuotas-matching.util.spec.ts`
- Modify: `src/reglas-precio/precios-engine.service.ts:563-569` (`generarGrillaCuotas`)

**Interfaces:**
- Produces: `planAplicaAProducto(plan, producto, precioContado): boolean` — usado por `generarGrillaCuotas` (este task) y por `PlanesCuotasService.obtenerPlanesAplicables` (Task 3).

- [ ] **Step 1: Escribir el test que falla primero**

Crear `src/common/utils/plan-cuotas-matching.util.spec.ts`:

```typescript
import { planAplicaAProducto } from './plan-cuotas-matching.util';

describe('planAplicaAProducto', () => {
  const planSinAlcance = { categoria_id: null, precio_producto_desde: null, precio_producto_hasta: null };

  it('un plan sin categoría ni rango siempre aplica', () => {
    expect(planAplicaAProducto(planSinAlcance, { categoriaId: 'cat-x', categoriaPadreId: null }, 500000)).toBe(true);
    expect(planAplicaAProducto(planSinAlcance, { categoriaId: null, categoriaPadreId: null }, 500000)).toBe(true);
  });

  it('matchea la categoría específica del producto', () => {
    const plan = { categoria_id: 'cat-heladeras', precio_producto_desde: null, precio_producto_hasta: null };
    expect(planAplicaAProducto(plan, { categoriaId: 'cat-heladeras', categoriaPadreId: 'cat-electro' }, 500000)).toBe(true);
    expect(planAplicaAProducto(plan, { categoriaId: 'cat-zapateros', categoriaPadreId: 'cat-muebles' }, 500000)).toBe(false);
  });

  it('matchea la categoría padre del producto', () => {
    const plan = { categoria_id: 'cat-electro', precio_producto_desde: null, precio_producto_hasta: null };
    expect(planAplicaAProducto(plan, { categoriaId: 'cat-heladeras', categoriaPadreId: 'cat-electro' }, 500000)).toBe(true);
  });

  it('respeta el rango de precio (desde/hasta)', () => {
    const plan = { categoria_id: null, precio_producto_desde: 300000, precio_producto_hasta: 1000000 };
    expect(planAplicaAProducto(plan, { categoriaId: null, categoriaPadreId: null }, 500000)).toBe(true);
    expect(planAplicaAProducto(plan, { categoriaId: null, categoriaPadreId: null }, 200000)).toBe(false);
    expect(planAplicaAProducto(plan, { categoriaId: null, categoriaPadreId: null }, 2000000)).toBe(false);
  });

  it('precio_producto_hasta null = sin tope superior', () => {
    const plan = { categoria_id: null, precio_producto_desde: 300000, precio_producto_hasta: null };
    expect(planAplicaAProducto(plan, { categoriaId: null, categoriaPadreId: null }, 50000000)).toBe(true);
  });

  it('categoría Y precio deben cumplirse ambos (AND)', () => {
    const plan = { categoria_id: 'cat-tv', precio_producto_desde: 500000, precio_producto_hasta: null };
    expect(planAplicaAProducto(plan, { categoriaId: 'cat-tv', categoriaPadreId: null }, 300000)).toBe(false);
    expect(planAplicaAProducto(plan, { categoriaId: 'cat-otra', categoriaPadreId: null }, 700000)).toBe(false);
    expect(planAplicaAProducto(plan, { categoriaId: 'cat-tv', categoriaPadreId: null }, 700000)).toBe(true);
  });
});
```

- [ ] **Step 2: Correr el test y verificar que falla**

Run: `cd /var/www/html/proyectos/novasispy-backend-api && npx jest src/common/utils/plan-cuotas-matching.util.spec.ts --runInBand`
Expected: FAIL — `Cannot find module './plan-cuotas-matching.util'`.

- [ ] **Step 3: Crear el util**

Crear `src/common/utils/plan-cuotas-matching.util.ts`:

```typescript
/**
 * Alcance opcional por categoría/precio de un plan de cuotas o regla de precio
 * (Fase 6, docs/plan-motor-precios-rentabilidad.md §11). Un plan sin
 * categoria_id ni rango seteado siempre aplica (retrocompatible). Con
 * categoria_id seteado, matchea la categoría específica del producto o su
 * categoría padre directa (un solo nivel).
 */
export type PlanConAlcance = {
  categoria_id: string | null;
  precio_producto_desde: unknown; // Prisma.Decimal | number | null
  precio_producto_hasta: unknown;
};

export function planAplicaAProducto(
  plan: PlanConAlcance,
  producto: { categoriaId: string | null; categoriaPadreId: string | null },
  precioContado: number,
): boolean {
  const matchCategoria =
    !plan.categoria_id ||
    plan.categoria_id === producto.categoriaId ||
    plan.categoria_id === producto.categoriaPadreId;
  const matchDesde =
    plan.precio_producto_desde === null ||
    plan.precio_producto_desde === undefined ||
    precioContado >= Number(plan.precio_producto_desde);
  const matchHasta =
    plan.precio_producto_hasta === null ||
    plan.precio_producto_hasta === undefined ||
    precioContado <= Number(plan.precio_producto_hasta);
  return matchCategoria && matchDesde && matchHasta;
}
```

- [ ] **Step 4: Correr el test y verificar que pasa**

Run: `npx jest src/common/utils/plan-cuotas-matching.util.spec.ts --runInBand`
Expected: PASS (6 tests).

- [ ] **Step 5: Refactorizar `generarGrillaCuotas` para usar el util (sin cambiar comportamiento)**

En `src/reglas-precio/precios-engine.service.ts`, agregar el import al inicio del archivo:

```typescript
import { planAplicaAProducto } from '../common/utils/plan-cuotas-matching.util';
```

Reemplazar (líneas 563-569):

```typescript
    const planes = planesActivos.filter((plan) => {
      const matchCategoria =
        !plan.categoria_id || plan.categoria_id === categoriaId || plan.categoria_id === categoriaPadreId;
      const matchDesde = plan.precio_producto_desde === null || precioContado >= Number(plan.precio_producto_desde);
      const matchHasta = plan.precio_producto_hasta === null || precioContado <= Number(plan.precio_producto_hasta);
      return matchCategoria && matchDesde && matchHasta;
    });
```

por:

```typescript
    const planes = planesActivos.filter((plan) =>
      planAplicaAProducto(plan, { categoriaId, categoriaPadreId }, precioContado),
    );
```

- [ ] **Step 6: Correr TODOS los tests de `precios-engine.service.spec.ts` y confirmar que siguen pasando sin cambios**

Run: `npx jest src/reglas-precio/precios-engine.service.spec.ts --runInBand`
Expected: PASS (16/16 — mismo número de tests que antes del refactor; el comportamiento es idéntico, solo cambió dónde vive el código).

- [ ] **Step 7: `tsc --noEmit` limpio**

Run: `npx tsc --noEmit -p tsconfig.json 2>&1 | grep -E "plan-cuotas-matching|precios-engine"`
Expected: sin salida (0 errores nuevos en los 2 archivos tocados).

---

### Task 2: Migración — `plan_cuota_id` en `factura_cab` y `solicitud_credito`

**Files:**
- Modify: `prisma/schema.prisma:1610-1740` (modelo `factura_cab`, agregar campo + relación)
- Modify: `prisma/schema.prisma:6059-6120` (modelo `solicitud_credito`, agregar campo + relación)
- Modify: `prisma/schema.prisma` (modelo `planes_cuotas`, agregar relaciones inversas)
- Create: `prisma/migrations/20260828_plan_cuota_id_trazabilidad/migration.sql`

**Interfaces:**
- Produces: columna `factura_cab.plan_cuota_id` (`String?`), columna `solicitud_credito.plan_cuota_id` (`String?`) — usadas por Task 5 (facturas) y Task 6 (solicitudes de crédito).

- [ ] **Step 1: Editar el modelo `factura_cab`**

En `prisma/schema.prisma`, dentro del modelo `factura_cab` (empieza en la línea 1610), agregar el
campo escalar cerca de `condicion_operacion_id`/`icondcred`/`dcuotas` (buscar esas líneas dentro
del modelo y agregar inmediatamente después de `dcuotas`):

```prisma
  plan_cuota_id            String?                      @db.Uuid
```

Y en la sección de relaciones del mismo modelo (junto a las demás relaciones `@relation`), agregar:

```prisma
  plan_cuota               planes_cuotas?               @relation("factura_cab_plan_cuota", fields: [plan_cuota_id], references: [id], onDelete: SetNull, onUpdate: NoAction)
```

Agregar también un índice, junto a los demás `@@index` del modelo:

```prisma
  @@index([empresa_id, plan_cuota_id], map: "idx_factura_cab_plan_cuota")
```

- [ ] **Step 2: Editar el modelo `solicitud_credito`**

Mismo patrón, dentro del modelo `solicitud_credito` (empieza en la línea 6059), agregar el campo
escalar cerca de `condicion_pago_id` (línea 6065):

```prisma
  plan_cuota_id        String?   @db.Uuid
```

Relación (junto a las demás relaciones del modelo, cerca de `empresa`/`cliente`):

```prisma
  plan_cuota     planes_cuotas?                @relation("solicitud_credito_plan_cuota", fields: [plan_cuota_id], references: [id], onDelete: SetNull, onUpdate: NoAction)
```

Índice:

```prisma
  @@index([empresa_id, plan_cuota_id], map: "idx_solicitud_credito_plan_cuota")
```

- [ ] **Step 3: Agregar las relaciones inversas en el modelo `planes_cuotas`**

Buscar el modelo `planes_cuotas` en `prisma/schema.prisma` (tiene `cuotas_calculadas`,
`planes_cuotas_detalles`, `cuotas_individuales`, `categoria` como relaciones existentes desde Fase
6) y agregar, junto a esas relaciones:

```prisma
  facturas_cab           factura_cab[]            @relation("factura_cab_plan_cuota")
  solicitudes_credito    solicitud_credito[]      @relation("solicitud_credito_plan_cuota")
```

- [ ] **Step 4: Crear el directorio y el SQL de la migración**

```bash
mkdir -p prisma/migrations/20260828_plan_cuota_id_trazabilidad
```

Crear `prisma/migrations/20260828_plan_cuota_id_trazabilidad/migration.sql`:

```sql
-- Trazabilidad de qué plan de cuotas automático se usó en una venta (Fase 7).
-- Nullable: una factura/solicitud sin plan automático (selector por producto,
-- o carga manual) sigue funcionando igual, con este campo en null.
-- Ver docs/plan-motor-precios-rentabilidad.md §12.
ALTER TABLE factura_cab
  ADD COLUMN IF NOT EXISTS plan_cuota_id UUID;

ALTER TABLE solicitud_credito
  ADD COLUMN IF NOT EXISTS plan_cuota_id UUID;

DO $$ BEGIN
  ALTER TABLE factura_cab
    ADD CONSTRAINT factura_cab_plan_cuota_id_fkey
    FOREIGN KEY (plan_cuota_id) REFERENCES planes_cuotas(id) ON DELETE SET NULL;
EXCEPTION
  WHEN duplicate_object THEN NULL;
END $$;

DO $$ BEGIN
  ALTER TABLE solicitud_credito
    ADD CONSTRAINT solicitud_credito_plan_cuota_id_fkey
    FOREIGN KEY (plan_cuota_id) REFERENCES planes_cuotas(id) ON DELETE SET NULL;
EXCEPTION
  WHEN duplicate_object THEN NULL;
END $$;

CREATE INDEX IF NOT EXISTS idx_factura_cab_plan_cuota ON factura_cab (empresa_id, plan_cuota_id);
CREATE INDEX IF NOT EXISTS idx_solicitud_credito_plan_cuota ON solicitud_credito (empresa_id, plan_cuota_id);
```

- [ ] **Step 5: Aplicar la migración localmente**

Run: `cd /var/www/html/proyectos/novasispy-backend-api && npx prisma db execute --file prisma/migrations/20260828_plan_cuota_id_trazabilidad/migration.sql --schema prisma/schema.prisma`
Expected: sin errores.

- [ ] **Step 6: Marcar la migración como aplicada y regenerar el cliente**

Run: `npx prisma migrate resolve --applied "20260828_plan_cuota_id_trazabilidad"`
Run: `npx prisma generate`
Expected: ambos sin error.

- [ ] **Step 7: `tsc --noEmit` limpio**

Run: `npx tsc --noEmit -p tsconfig.json 2>&1 | tail -5`
Expected: mismo número de errores preexistentes que antes de este task (sin errores nuevos relacionados a `factura_cab`/`solicitud_credito`/`planes_cuotas`).

---

### Task 3: `PlanesCuotasService.obtenerPlanesAplicables()` + endpoint

**Files:**
- Modify: `src/planes-cuotas/planes-cuotas.service.ts` (nuevo método, cerca de `obtenerPlanes`, línea 96-119)
- Modify: `src/planes-cuotas/planes-cuotas.service.spec.ts` (agregar tests)
- Create: `src/planes-cuotas/dto/obtener-planes-aplicables.dto.ts`
- Modify: `src/planes-cuotas/planes-cuotas.controller.ts` (nuevo endpoint, cerca de `@Get('empresa')`, línea 66-92)

**Interfaces:**
- Consumes: `planAplicaAProducto()` (Task 1).
- Produces: `PlanesCuotasService.obtenerPlanesAplicables(empresaId, items)` y `POST /planes-cuotas/aplicables` — consumidos por el frontend en Tasks 8 y 9.

- [ ] **Step 1: Crear el DTO del body**

Crear `src/planes-cuotas/dto/obtener-planes-aplicables.dto.ts`:

```typescript
import { ApiProperty } from '@nestjs/swagger';
import { IsArray, IsNumber, IsUUID, Min, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';

export class ItemAplicableDto {
  @ApiProperty({ description: 'ID del producto' })
  @IsUUID()
  productoId: string;

  @ApiProperty({ description: 'Precio de catálogo (contado) del producto en el carrito' })
  @IsNumber()
  @Min(0)
  precioContado: number;
}

export class ObtenerPlanesAplicablesDto {
  @ApiProperty({ type: [ItemAplicableDto], description: 'Ítems del carrito actual' })
  @IsArray()
  @ValidateNested({ each: true })
  @Type(() => ItemAplicableDto)
  items: ItemAplicableDto[];
}
```

- [ ] **Step 2: Escribir el test que falla primero**

Agregar a `src/planes-cuotas/planes-cuotas.service.spec.ts` (mismo archivo, nuevo `describe` al
final):

```typescript
describe('PlanesCuotasService.obtenerPlanesAplicables - alcance por categoría (Fase 7)', () => {
  const planGlobal = {
    id: 'plan-global',
    nombre: '12 cuotas',
    activo: true,
    categoria_id: null,
    precio_producto_desde: null,
    precio_producto_hasta: null,
  };
  const planCategoria = {
    id: 'plan-cat',
    nombre: '6 cuotas Televisores',
    activo: true,
    categoria_id: 'cat-televisores',
    precio_producto_desde: null,
    precio_producto_hasta: null,
  };

  const createService = (planes: any[], productos: any[]) => {
    const prisma = {
      planes_cuotas: { findMany: jest.fn().mockResolvedValue(planes) },
      productos: { findMany: jest.fn().mockResolvedValue(productos) },
    };
    const auditService = { log: jest.fn().mockResolvedValue(undefined) };
    const service = new PlanesCuotasService(prisma as any, auditService as any);
    return { service, prisma };
  };

  it('un carrito de una sola categoría ve el plan global y el de esa categoría', async () => {
    const producto = { id: 'prod-tv', categoria_id: 'cat-televisores', categoria: { padre_id: null } };
    const { service } = createService([planGlobal, planCategoria], [producto]);

    const resultado = await service.obtenerPlanesAplicables('emp-1', [
      { productoId: 'prod-tv', precioContado: 500000 },
    ]);

    expect(resultado.map((p) => p.id).sort()).toEqual(['plan-cat', 'plan-global']);
  });

  it('un carrito mixto (categoría cubierta + no cubierta) excluye el plan acotado', async () => {
    const productoTv = { id: 'prod-tv', categoria_id: 'cat-televisores', categoria: { padre_id: null } };
    const productoZapatero = { id: 'prod-zap', categoria_id: 'cat-zapateros', categoria: { padre_id: 'cat-muebles' } };
    const { service } = createService([planGlobal, planCategoria], [productoTv, productoZapatero]);

    const resultado = await service.obtenerPlanesAplicables('emp-1', [
      { productoId: 'prod-tv', precioContado: 500000 },
      { productoId: 'prod-zap', precioContado: 100000 },
    ]);

    expect(resultado.map((p) => p.id)).toEqual(['plan-global']);
  });

  it('carrito vacío devuelve todos los planes activos (mismo comportamiento que obtenerPlanes)', async () => {
    const { service, prisma } = createService([planGlobal, planCategoria], []);

    const resultado = await service.obtenerPlanesAplicables('emp-1', []);

    expect(resultado.map((p) => p.id).sort()).toEqual(['plan-cat', 'plan-global']);
    expect(prisma.productos.findMany).not.toHaveBeenCalled();
  });
});
```

El archivo ya importa `PlanesCuotasService` en la línea 1 (`import { PlanesCuotasService } from
'./planes-cuotas.service';`, agregado en las tareas de Fase 6) — no hace falta un import nuevo.

- [ ] **Step 3: Correr el test y verificar que falla**

Run: `npx jest src/planes-cuotas/planes-cuotas.service.spec.ts --runInBand`
Expected: FAIL — `service.obtenerPlanesAplicables is not a function`.

- [ ] **Step 4: Implementar el método**

En `src/planes-cuotas/planes-cuotas.service.ts`, agregar el import al inicio del archivo:

```typescript
import { planAplicaAProducto } from '../common/utils/plan-cuotas-matching.util';
```

Agregar el método, inmediatamente después de `obtenerPlanes` (después de la línea 119):

```typescript
  /**
   * Como obtenerPlanes(), pero filtra por los ítems del carrito actual: un
   * plan solo aparece si su alcance (categoría/precio, Fase 6) cubre TODOS
   * los ítems — un plan automático financia el monto total del carrito, no
   * un ítem individual, así que no alcanza con que matchee alguno. Un plan
   * sin alcance seteado siempre aplica. Ver docs/plan-motor-precios-rentabilidad.md §12.
   */
  async obtenerPlanesAplicables(
    empresaId: string,
    items: { productoId: string; precioContado: number }[],
  ) {
    const todos = await this.obtenerPlanes(empresaId, true);
    if (items.length === 0) return todos;

    const productoIds = items.map((i) => i.productoId);
    const productos = await this.prisma.productos.findMany({
      where: { id: { in: productoIds } },
      select: { id: true, categoria_id: true, categoria: { select: { padre_id: true } } },
    });
    const productoPorId = new Map(productos.map((p) => [p.id, p]));

    return todos.filter((plan) =>
      items.every(({ productoId, precioContado }) => {
        const producto = productoPorId.get(productoId);
        if (!producto) return false;
        return planAplicaAProducto(
          plan,
          { categoriaId: producto.categoria_id, categoriaPadreId: producto.categoria?.padre_id ?? null },
          precioContado,
        );
      }),
    );
  }
```

- [ ] **Step 5: Correr el test y verificar que pasa**

Run: `npx jest src/planes-cuotas/planes-cuotas.service.spec.ts --runInBand`
Expected: PASS (6/6 — 3 tests preexistentes de Fase 6 + 3 nuevos).

- [ ] **Step 6: Agregar el endpoint**

En `src/planes-cuotas/planes-cuotas.controller.ts`, agregar el import del nuevo DTO al inicio del
archivo:

```typescript
import { ObtenerPlanesAplicablesDto } from './dto/obtener-planes-aplicables.dto';
```

Agregar el endpoint, inmediatamente después del método `obtenerPlanes` del controller (después de
la línea 92):

```typescript
  @Post('aplicables')
  @ApiOperation({
    summary: 'Planes de cuotas automáticos aplicables al carrito actual (filtrados por categoría/precio, Fase 7)',
  })
  @ApiResponse({ status: 200, description: 'Lista de planes aplicables' })
  async obtenerPlanesAplicables(@GetUser() user: LoginUserInfo, @Body() dto: ObtenerPlanesAplicablesDto) {
    try {
      const planes = await this.planesCuotasService.obtenerPlanesAplicables(user.empresa_id, dto.items);
      return { success: true, data: planes };
    } catch (error) {
      if (error instanceof HttpException) throw error;
      throw new HttpException(
        {
          success: false,
          message: error.message || 'Error al obtener los planes aplicables',
        },
        error.status || HttpStatus.INTERNAL_SERVER_ERROR,
      );
    }
  }
```

`Body` y `Post` ya están importados de `@nestjs/common` en este archivo (líneas 2 y 9, usados por
`crearPlan`) — no hace falta agregar imports de decoradores nuevos.

- [ ] **Step 7: `tsc --noEmit` limpio**

Run: `npx tsc --noEmit -p tsconfig.json 2>&1 | grep -E "planes-cuotas"`
Expected: sin salida.

---

### Task 4: DTOs — `plan_cuota_id` en `FacturaCabDto` y `CrearSolicitudCreditoDto`

**Files:**
- Modify: `src/facturas/dto/create-factura.dto.ts:324` (después de `dcuotas`)
- Modify: `src/solicitudes-credito/dto/create-solicitud-credito.dto.ts:131` (después de `condicion_pago_id`)

**Interfaces:**
- Produces: `FacturaCabDto.plan_cuota_id?: string`, `CreateSolicitudCreditoDto.plan_cuota_id?: string` (esta última también disponible en `UpdateSolicitudCreditoDto` vía `PartialType`, sin cambios adicionales) — consumidos por Tasks 5 y 6.

- [ ] **Step 1: `FacturaCabDto`**

En `src/facturas/dto/create-factura.dto.ts`, después de la línea 324 (`dcuotas?: number;`),
agregar:

```typescript
  @ApiPropertyOptional({
    description: 'ID del plan de cuotas automático aplicado a esta factura (Fase 7). Opcional — null si se usó el selector por producto o carga manual.',
  })
  @IsOptional()
  @IsUUID()
  plan_cuota_id?: string;
```

`IsUUID` ya está importado en este archivo (línea 11, usado por varios otros campos) — no hace
falta tocar el import.

- [ ] **Step 2: `CreateSolicitudCreditoDto`**

En `src/solicitudes-credito/dto/create-solicitud-credito.dto.ts`, después de la línea 131
(`condicion_pago_id?: string;`), agregar:

```typescript
  @ApiPropertyOptional({ description: 'ID del plan de cuotas automático aplicado (Fase 7)' })
  @IsOptional()
  @IsUUID()
  plan_cuota_id?: string;
```

`IsUUID` ya está importado en este archivo (línea 12, usado por `condicion_pago_id`).

- [ ] **Step 3: `tsc --noEmit` limpio**

Run: `cd /var/www/html/proyectos/novasispy-backend-api && npx tsc --noEmit -p tsconfig.json 2>&1 | grep -E "create-factura.dto|create-solicitud-credito.dto"`
Expected: sin salida.

---

### Task 5: `FacturasService.create()` — persistir `plan_cuota_id`

**Files:**
- Modify: `src/facturas/facturas.service.ts:721-724`

**Interfaces:**
- Consumes: `FacturaCabDto.plan_cuota_id` (Task 4).

- [ ] **Step 1: Aplicar el cambio**

En `src/facturas/facturas.service.ts`, dentro del `tx.factura_cab.create({ data: { ... } })`,
después de la línea `dcuotas: cabecera?.dcuotas ?? null,` (línea 724), agregar:

```typescript
            plan_cuota_id: cabecera?.plan_cuota_id ?? null,
```

- [ ] **Step 2: Verificar que compila**

Run: `cd /var/www/html/proyectos/novasispy-backend-api && npx tsc --noEmit -p tsconfig.json 2>&1 | grep -E "facturas.service"`
Expected: sin salida.

- [ ] **Step 3: Nota de verificación (no hay test unitario para este cambio — ver Global Constraints)**

Este cambio se verifica junto con el resto del flujo en la Task 10 (prueba manual E2E contra
Kety) — `facturas.service.ts` no tiene tests preexistentes y su método `create()` es un
`$transaction` de casi 900 líneas; no es proporcional escribir un test aislado para una sola línea
de passthrough. Confirmar en la Task 10 que una factura creada con un plan automático realmente
guarda `plan_cuota_id` en `factura_cab` (consulta directa a la base, mismo patrón ya usado en la
verificación de Fase 6).

---

### Task 6: `SolicitudesCreditoService` — persistir `plan_cuota_id` (crear y actualizar) + test

**Files:**
- Modify: `src/solicitudes-credito/solicitudes-credito.service.ts:56-69` (`create`)
- Modify: `src/solicitudes-credito/solicitudes-credito.service.ts:195-213` (`update`)
- Create: `src/solicitudes-credito/solicitudes-credito.service.spec.ts` (primer spec file de este servicio)

**Interfaces:**
- Consumes: `CreateSolicitudCreditoDto.plan_cuota_id`/`UpdateSolicitudCreditoDto.plan_cuota_id` (Task 4).

- [ ] **Step 1: Escribir el test que falla primero**

`SolicitudesCreditoService` tiene un solo parámetro en su constructor
(`constructor(private readonly prisma: PrismaService) {}`, líneas 22-24) y sus métodos `create`
(línea 28) y `update` (línea 185) llaman a otros métodos **privados de la misma clase**
(`logHistorial`, `findOneInternal`, `assertEditable`, y — solo si `dto.estado` no es `'borrador'`
— `reservarNumeroSolicitud`) que no son simples llamadas a Prisma. Para poder testear
específicamente el passthrough de `plan_cuota_id` sin tener que mockear todo lo que esos métodos
tocan internamente, se los reemplaza con `jest.spyOn` sobre el prototipo — el patrón correcto para
aislar una unidad de sus colaboradores internos sin reescribir su lógica.

Crear `src/solicitudes-credito/solicitudes-credito.service.spec.ts`:

```typescript
import { SolicitudesCreditoService } from './solicitudes-credito.service';

describe('SolicitudesCreditoService - plan_cuota_id (Fase 7)', () => {
  const createService = (prismaOverrides: Record<string, any> = {}) => {
    const prisma = {
      clientes: {
        findFirst: jest.fn().mockResolvedValue({ id: 'cliente-1', personas: { razon_social: 'Cliente Test' } }),
      },
      solicitud_credito: {
        create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'sol-1', ...data })),
        update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'sol-1', ...data })),
      },
      $transaction: jest.fn(async (cb) => cb(prisma)),
      ...prismaOverrides,
    };
    const service = new SolicitudesCreditoService(prisma as any);
    // Colaboradores internos de create()/update() — no forman parte de lo que este
    // test verifica (el passthrough de plan_cuota_id), se stubean directamente.
    jest.spyOn(service as any, 'logHistorial').mockResolvedValue(undefined);
    jest.spyOn(service as any, 'findOneInternal').mockResolvedValue({ id: 'sol-1' });
    jest.spyOn(service as any, 'assertEditable').mockResolvedValue({ id: 'sol-1', estado: 'borrador' });
    return { service, prisma };
  };

  it('crearla con plan_cuota_id lo persiste', async () => {
    const { service, prisma } = createService();

    await service.create(
      { cliente_id: 'cliente-1', estado: 'borrador', plan_cuota_id: 'plan-1' } as any,
      'emp-1',
      'user-1',
    );

    const dataEnviada = prisma.solicitud_credito.create.mock.calls[0][0].data;
    expect(dataEnviada.plan_cuota_id).toBe('plan-1');
  });

  it('crearla sin plan_cuota_id lo deja null (retrocompatible)', async () => {
    const { service, prisma } = createService();

    await service.create({ cliente_id: 'cliente-1', estado: 'borrador' } as any, 'emp-1', 'user-1');

    const dataEnviada = prisma.solicitud_credito.create.mock.calls[0][0].data;
    expect(dataEnviada.plan_cuota_id).toBeNull();
  });

  it('actualizarla con plan_cuota_id lo persiste', async () => {
    const { service, prisma } = createService();

    await service.update('sol-1', { plan_cuota_id: 'plan-2' } as any, 'emp-1', 'user-1');

    const dataEnviada = prisma.solicitud_credito.update.mock.calls[0][0].data;
    expect(dataEnviada.plan_cuota_id).toBe('plan-2');
  });

  it('actualizarla sin tocar plan_cuota_id no lo incluye en el update (no lo pisa)', async () => {
    const { service, prisma } = createService();

    await service.update('sol-1', { observaciones: 'test' } as any, 'emp-1', 'user-1');

    const dataEnviada = prisma.solicitud_credito.update.mock.calls[0][0].data;
    expect(dataEnviada.plan_cuota_id).toBeUndefined();
  });
});
```

Nota sobre `estado: 'borrador'` en los DTOs de `create`: si `dto.estado` no es exactamente
`'borrador'`, `create()` toma la rama `PENDIENTE_APROBACION` y además llama a
`reservarNumeroSolicitud` (que si no se stubea intentaría tocar más tablas de las que este test
necesita mockear). Forzar `'borrador'` mantiene el test enfocado solo en el passthrough de
`plan_cuota_id`, sin tener que mockear ese camino aparte.

- [ ] **Step 2: Correr el test y verificar que falla**

Run: `cd /var/www/html/proyectos/novasispy-backend-api && npx jest src/solicitudes-credito/solicitudes-credito.service.spec.ts --runInBand`
Expected: FAIL — `dataEnviada.plan_cuota_id` es `undefined` en el primer y tercer test (el
servicio todavía no mapea el campo).

- [ ] **Step 3: Mapear el campo en `create`**

En `src/solicitudes-credito/solicitudes-credito.service.ts:62` (dentro del `data` del
`tx.solicitud_credito.create`), después de la línea `condicion_pago_id: dto.condicion_pago_id ?? null,`,
agregar:

```typescript
          plan_cuota_id: dto.plan_cuota_id ?? null,
```

- [ ] **Step 4: Mapear el campo en `update`**

En `src/solicitudes-credito/solicitudes-credito.service.ts:203` (dentro del `data` del
`tx.solicitud_credito.update`), después de la línea
`...(dto.condicion_pago_id !== undefined && { condicion_pago_id: dto.condicion_pago_id || null }),`,
agregar:

```typescript
          ...(dto.plan_cuota_id !== undefined && { plan_cuota_id: dto.plan_cuota_id || null }),
```

- [ ] **Step 5: Correr el test y verificar que pasa**

Run: `npx jest src/solicitudes-credito/solicitudes-credito.service.spec.ts --runInBand`
Expected: PASS (4/4).

- [ ] **Step 6: `tsc --noEmit` limpio**

Run: `npx tsc --noEmit -p tsconfig.json 2>&1 | grep -E "solicitudes-credito"`
Expected: sin salida.

---

### Task 7: Frontend — cliente API `obtenerPlanesAplicables`

**Files:**
- Modify: `src/api/planes-cuotas.service.js`

**Interfaces:**
- Produces: `obtenerPlanesAplicables(items)` — consumido por Tasks 8 y 9.

- [ ] **Step 1: Agregar la función**

En `src/api/planes-cuotas.service.js`, agregar (siguiendo el estilo de las funciones existentes en
este archivo, ej. `getPlanesEmpresa`):

```javascript
/**
 * Planes de cuotas automáticos aplicables al carrito actual — filtrados por
 * categoría/precio de los productos (Fase 7).
 * @param {{productoId: string, precioContado: number}[]} items
 */
export const obtenerPlanesAplicables = async (items) => {
  const response = await api.post("/planes-cuotas/aplicables", { items });
  return response.data;
};
```

- [ ] **Step 2: Verificar que el lint no rompe**

Run: `cd /var/www/html/proyectos/novasispy-erp && npx eslint src/api/planes-cuotas.service.js`
Expected: sin errores nuevos.

---

### Task 8: Frontend — `POSAdminTemplate.jsx`: filtro por carrito, `plan_cuota_id`, `dcuotas`

**Files:**
- Modify: `src/components/templates/POSAdminTemplate.jsx:45` (import)
- Modify: `src/components/templates/POSAdminTemplate.jsx:247-249` (query de planes)
- Modify: `src/components/templates/POSAdminTemplate.jsx:2681-2697` (`<PaymentPanel>`)
- Modify: `src/components/templates/POSAdminTemplate.jsx:1796-1800` (`dcuotas`)
- Modify: el payload de creación de factura (cerca de donde se arma `cabecera`, mismo bloque que ya arma `icondcred`/`dcuotas`)
- Modify: `src/components/organismos/POSAdmin/PaymentPanel.jsx` (nuevo efecto que hereda el plan cuando se carga una solicitud de crédito — ver Step 6)

**Interfaces:**
- Consumes: `obtenerPlanesAplicables` (Task 7).
- Produces: la factura creada desde POS ahora incluye `plan_cuota_id` cuando corresponde — tanto en venta directa (selector del usuario) como al facturar una solicitud de crédito que ya tenía un plan automático asignado (heredado, ver Step 6).

**Nota — por qué hace falta tocar `PaymentPanel.jsx` además de `POSAdminTemplate.jsx`:** cuando el
POS carga una solicitud de crédito existente para facturarla (`solicitudActiva`), el flujo NO pasa
por un endpoint de "facturar solicitud": `POSAdminTemplate.jsx` reconstruye `condicionPagoActiva`
a mano a partir de los datos de la solicitud (líneas 1556-1593) y solo copia `vendedor_id`/
`cobrador_id` (líneas 1596-1600) — nada ahí toca el plan automático. El estado que realmente
determina `plan_cuota_id` en el payload (`planCuotaSeleccionado`, Step 3) vive en
`POSAdminTemplate.jsx`, pero se llena exclusivamente vía el callback `onPlanCuotaChange` de
`PaymentPanel.jsx`, cuyo estado interno `planSeleccionado` (`PaymentPanel.jsx:43`) arranca en
`null` y **no tiene ningún código que lo inicialice desde `solicitudActiva.plan_cuota_id`** — sin
el Step 6, facturar una solicitud creada con plan automático (Task 9) resultaría en
`plan_cuota_id: null` en la factura, rompiendo la trazabilidad que este plan busca agregar y
haciendo fallar la Task 10 Step 4.

- [ ] **Step 1: Import**

En `src/components/templates/POSAdminTemplate.jsx:45`, cambiar:

```jsx
import { getPlanesEmpresa } from "../../api/planes-cuotas.service";
```

por:

```jsx
import { getPlanesEmpresa, obtenerPlanesAplicables } from "../../api/planes-cuotas.service";
```

- [ ] **Step 2: Cambiar la query de planes para filtrar por el carrito**

En `src/components/templates/POSAdminTemplate.jsx:247-249`, reemplazar (ver nota debajo del bloque
sobre por qué no hace falta reordenar nada aquí):

```jsx
  const { data: planesCuotasData } = useQuery({
    queryKey: ["planesCuotasActivos"],
    queryFn: () => getPlanesEmpresa({ activo: "true" }),
```

por:

```jsx
  // Fase 7: filtra por categoría/precio de los productos del carrito actual —
  // firma en la queryKey para que se recalcule cuando cambian los ítems.
  const itemsParaPlanes = items.map((i) => ({ productoId: i.producto_id, precioContado: i.precio_unitario }));
  const { data: planesCuotasData } = useQuery({
    queryKey: ["planesCuotasActivos", JSON.stringify(itemsParaPlanes)],
    queryFn: () =>
      itemsParaPlanes.length > 0 ? obtenerPlanesAplicables(itemsParaPlanes) : getPlanesEmpresa({ activo: "true" }),
```

`items` ya está declarado antes de este punto (`const [items, setItems] = useState([]);` en la
línea 128, el `useQuery` de planes está en la línea 247) — no hace falta reordenar nada en este
archivo, a diferencia de `NuevaSolicitudCredito.jsx` (Task 9), donde sí hace falta mover el bloque.

- [ ] **Step 3: Conectar `onPlanCuotaChange`**

Cerca de la declaración de otros estados del componente (buscar donde se declaran estados
relacionados a pagos/cuotas, ej. cerca de `const [cuotas, setCuotas] = useState(...)` o similar),
agregar:

```jsx
  const [planCuotaSeleccionado, setPlanCuotaSeleccionado] = useState(null);
```

En `src/components/templates/POSAdminTemplate.jsx:2681-2697`, agregar la prop a `<PaymentPanel>`:

```jsx
            <PaymentPanel
              key={condicionPagoActiva?.id || condicionPagoActiva?.cod_condicion_venta || "default"}
              totalFactura={totales.total || 0}
              monedaActiva={monedaActiva}
              condicionPago={condicionPagoActiva}
              mediosPago={mediosPago}
              tarjetas={tarjetas}
              planesCuotas={planesCuotas}
              items={items}
              onPagosChange={setPagos}
              onCuotasChange={setCuotas}
              onPlanCuotaChange={setPlanCuotaSeleccionado}
              onPrecioFinanciadoToggle={handlePrecioFinanciadoToggle}
              onValidacionChange={setValidacionPago}
              tieneItems={items.length > 0}
              tieneCliente={!!cliente}
              solicitudActiva={solicitudActiva}
            />
```

(Solo se agrega la línea `onPlanCuotaChange={setPlanCuotaSeleccionado}` — el resto de las props
queda igual.)

- [ ] **Step 4: `dcuotas` derivado de la cuota real**

En `src/components/templates/POSAdminTemplate.jsx:1796-1800`, cambiar:

```jsx
    return {
      icondcred: esCuotasCredito ? "2" : "1",
      dplazocre: !esCuotasCredito ? condicionPagoActiva?.descripcion : null,
      dcuotas: esCuotasCredito ? condicionPagoActiva?.cuotas : null,
    };
```

por:

```jsx
    return {
      icondcred: esCuotasCredito ? "2" : "1",
      dplazocre: !esCuotasCredito ? condicionPagoActiva?.descripcion : null,
      // Fase 7: dcuotas sale de la cantidad real de cuotas que se van a
      // facturar (cuotas.length), no de la fila de condiciones_pago elegida
      // a mano — evita que se declare a SIFEN un número que no coincide con
      // lo realmente facturado. Ver docs/plan-motor-precios-rentabilidad.md §12.
      dcuotas: esCuotasCredito ? cuotas.length : null,
    };
```

`cuotas` (`const [cuotas, setCuotas] = useState([]);`, línea 152) ya está declarado antes de la
línea 1796 — sin problema de orden.

- [ ] **Step 5: Incluir `plan_cuota_id` en el payload de la factura**

Buscar el bloque donde se arma el objeto `cabecera` que se envía a la API de creación de factura
(mismo bloque de la Step 4, que ya arma `icondcred`/`dplazocre`/`dcuotas`) y agregar:

```jsx
      plan_cuota_id: planCuotaSeleccionado?.id || undefined,
```

- [ ] **Step 6: Heredar el plan en `PaymentPanel.jsx` al cargar una solicitud de crédito**

En `src/components/organismos/POSAdmin/PaymentPanel.jsx`, el efecto existente en las líneas 51-102
sincroniza `cuotas` desde `solicitudActiva` pero corre **una sola vez por solicitud** (guardia en
la línea 52: `if (!solicitudActiva || solicitudActiva.id === solicitudIdRef.current) return;`). No
se puede agregar la herencia del plan dentro de ese mismo efecto porque `planesCuotas` (prop
recibida del padre) puede llegar en un render posterior — el efecto ya habría marcado esa solicitud
como procesada (`solicitudIdRef.current`) antes de que `planesCuotas` tenga datos, y nunca
reintentaría. Se agrega un efecto nuevo, separado, con su propio ref, que reintenta hasta encontrar
el plan:

Agregar cerca de la declaración de `solicitudIdRef` (línea 50):

```jsx
  const planHeredadoRef = useRef(null);
```

Y un nuevo efecto, después del efecto que agrega `onPlanCuotaChange` (línea 355-357):

```jsx
  // Fase 7: al cargar una solicitud de crédito que ya tenía un plan automático
  // asignado, preseleccionarlo — si no, la factura resultante pierde la
  // trazabilidad (`plan_cuota_id` quedaría null aunque la solicitud sí tenía uno).
  // Efecto separado del de la línea 51 porque `planesCuotas` puede llegar tarde
  // (fetch async en el padre): reintenta en cada render hasta encontrar el plan.
  useEffect(() => {
    if (!solicitudActiva?.plan_cuota_id) return;
    if (planHeredadoRef.current === solicitudActiva.id) return;
    const plan = planesCuotas.find((p) => p.id === solicitudActiva.plan_cuota_id);
    if (!plan) return;
    setPlanSeleccionado(plan);
    planHeredadoRef.current = solicitudActiva.id;
  }, [solicitudActiva, planesCuotas]);
```

`useRef` ya está importado en este archivo (línea 2, usado por `solicitudIdRef`) — no hace falta
agregar el import. Una vez que `planHeredadoRef.current` queda seteado para esa solicitud, este
efecto no vuelve a tocar `planSeleccionado` — si el usuario cambia el plan a mano después de que se
heredó, ese cambio no se pisa.

- [ ] **Step 7: Verificar que eslint no rompe**

Run: `cd /var/www/html/proyectos/novasispy-erp && npx eslint src/components/templates/POSAdminTemplate.jsx src/components/organismos/POSAdmin/PaymentPanel.jsx`
Expected: mismo baseline de errores/warnings preexistentes que antes de este task (sin errores
nuevos introducidos por este cambio).

- [ ] **Step 8: Verificación manual mínima en el navegador**

Levantar el frontend (`npm run dev`), abrir el POS, seleccionar un cliente, elegir una condición
de pago "N cuotas", agregar un producto de una categoría con un plan acotado (ej. "TV MAST 14""
usado en la verificación de Fase 6) y confirmar que el selector "Plan de cuotas automático" sigue
mostrando los planes esperados (no debería haber roto nada visualmente en este punto — la
verificación completa del filtro por carrito mixto queda para la Task 10). Adicionalmente, cargar
una solicitud de crédito existente que tenga `plan_cuota_id` seteado (crear una desde cero si no
hay ninguna todavía — depende de que la Task 9 ya esté implementada) y confirmar que el selector de
plan automático aparece con ese plan ya preseleccionado.

---

### Task 9: Frontend — `NuevaSolicitudCredito.jsx`: filtro por carrito + `plan_cuota_id`

**Files:**
- Modify: `src/pages/NuevaSolicitudCredito.jsx:1241-1256` (mover el bloque del plan automático a
  después de la declaración de `items`)
- Modify: `src/pages/NuevaSolicitudCredito.jsx:1864-1918` (payload de creación)

**Interfaces:**
- Consumes: `obtenerPlanesAplicables` (Task 7).
- Produces: la solicitud de crédito creada ahora incluye `plan_cuota_id` cuando corresponde.

- [ ] **Step 1: Import**

Buscar el import de `getPlanesEmpresa` en este archivo (mismo import usado en
`POSAdminTemplate.jsx`) y agregar `obtenerPlanesAplicables` a la misma línea de import, igual que
en la Task 8 Step 1.

- [ ] **Step 2: Mover el bloque de la query y filtrarla por los ítems de la solicitud**

`items` (`const [items, setItems] = useState([]);`) se declara en la línea 1278 — **después** del
bloque del plan automático (líneas 1245-1254), que hoy no lo necesita. Hay que mover el bloque de
la query (líneas 1249-1255: el `useQuery` y la línea `const planesAutomaticos = ...`) a **después**
de la línea 1278, para poder usar `items` sin caer en un error de referencia antes de declaración.
Las demás líneas de este bloque (`planAutomaticoId`, `cuotasPlanElegidas`, `cronogramaPlan`,
`calculandoPlan`, `handleElegirPlanAutomatico`) no dependen de `items` y **quedan donde están**.

Texto actual (líneas 1249-1255):

```jsx
  const { data: planesAutomaticosResp } = useQuery({
    queryKey: ["planes-cuotas-empresa-activos"],
    queryFn: () => getPlanesEmpresa({ activo: "true" }),
    staleTime: 5 * 60 * 1000,
    retry: false,
  });
  const planesAutomaticos = planesAutomaticosResp?.data || [];
```

Eliminar esas líneas de esa posición, y agregar esto inmediatamente después de la línea 1278
(`const [items, setItems] = useState([]);`):

```jsx
  // Fase 7: filtra por categoría/precio de los productos ya agregados a la
  // solicitud — firma en la queryKey para que se recalcule al cambiar `items`.
  const itemsParaPlanes = items.map((i) => ({ productoId: i.producto_id, precioContado: i.precio_unitario }));
  const { data: planesAutomaticosResp } = useQuery({
    queryKey: ["planes-cuotas-empresa-aplicables", JSON.stringify(itemsParaPlanes)],
    queryFn: () =>
      itemsParaPlanes.length > 0 ? obtenerPlanesAplicables(itemsParaPlanes) : getPlanesEmpresa({ activo: "true" }),
    staleTime: 5 * 60 * 1000,
    retry: false,
  });
  const planesAutomaticos = planesAutomaticosResp?.data || [];
```

`handleElegirPlanAutomatico` (línea 1259, sin mover) sigue funcionando igual: aunque queda definida
textualmente antes de la nueva posición de `planesAutomaticos`, es una función que solo se ejecuta
cuando el usuario elige un plan (no en el momento en que React arma el componente), así que para
cuando se invoca, `planesAutomaticos` ya está asignado en esa misma pasada de render — no hace
falta moverla también.

Mantener el comentario existente sobre el addon (líneas 1241-1244 originales) — sigue siendo
válido, solo cambia la fuente de datos cuando el addon sí está contratado.

- [ ] **Step 3: Incluir `plan_cuota_id` en el payload**

En el payload de creación (`src/pages/NuevaSolicitudCredito.jsx:1864-1918`), agregar junto a
`condicion_pago_id: condicionPagoId || undefined,`:

```jsx
  plan_cuota_id: planAutomaticoId || undefined,
```

(`planAutomaticoId` ya existe como estado en este archivo desde antes, línea 1245.)

- [ ] **Step 4: Verificar que eslint no rompe**

Run: `cd /var/www/html/proyectos/novasispy-erp && npx eslint src/pages/NuevaSolicitudCredito.jsx`
Expected: mismo baseline preexistente.

---

### Task 10: Verificación end-to-end contra datos reales de Kety

No es una tarea de código — verificación manual de que las 9 tareas anteriores funcionan juntas,
mismo criterio que la Task 10 del plan de Fase 6 (`docs/superpowers/plans/2026-08-27-alcance-categoria-precios-cuotas.md`).

**Files:** ninguno (solo uso de la app corriendo contra la empresa Comercial Kety, `empresa_id
c9a80e4a-f640-4588-a80e-b0c706f6cd28`, login `2156747`/`2156747#`).

- [ ] **Step 1: Filtro por carrito mixto en el selector automático**

En el POS, armar un carrito con un producto de una categoría que tenga un plan acotado (ej. "TV
MAST 14"", categoría Televisores, plan `TESTSDD-CAT-TV`) y confirmar que el selector "Plan de
cuotas automático" lo muestra. Agregar un segundo producto de una categoría sin ese plan (ej. un
producto de Zapateros) y confirmar que el plan acotado a Televisores **desaparece** de las
opciones, dejando solo los planes globales. Repetir la misma verificación en "Nueva Solicitud de
Crédito".

- [ ] **Step 2: `plan_cuota_id` persistido**

Crear una factura desde el POS usando el selector "Plan de cuotas automático" (no el selector por
producto). Consultar `factura_cab` en la base y confirmar que `plan_cuota_id` quedó guardado con
el id del plan elegido. Repetir para una solicitud de crédito creada con plan automático,
confirmando `solicitud_credito.plan_cuota_id`.

- [ ] **Step 3: `dcuotas` coincide con la cantidad real de cuotas, en los 3 orígenes**

Crear tres facturas de prueba, una por cada origen de cuotas, y para cada una comparar
`factura_cab.dcuotas` contra `SELECT COUNT(*) FROM factura_cuotas WHERE factura_cab_id = ...` —
deben coincidir exactamente:
  1. Plan de cuotas automático (con y sin categoría acotada).
  2. Selector de cuotas por producto (`producto_precio_cuota`).
  3. Carga manual de cuotas (sin ningún plan seleccionado).

- [ ] **Step 4: Flujo completo solicitud → facturación con plan automático**

Crear una solicitud de crédito usando el plan automático, aprobarla, facturarla desde el POS
("Solicitudes Crédito" → cargar solicitud → Registrar Pago), y confirmar que la factura resultante
también tiene `plan_cuota_id` seteado (heredado de la solicitud, no perdido en el camino) y que
`dcuotas` sigue coincidiendo con la cantidad real de `factura_cuotas`.

- [ ] **Step 5: Retrocompatibilidad**

Crear una factura sin usar ningún plan automático (condición de pago "Contado", o crédito con
cuotas cargadas a mano) y confirmar que todo sigue funcionando exactamente igual que antes de este
plan — `plan_cuota_id` en `null`, sin errores, `dcuotas` sigue siendo correcto (ahora derivado de
`cuotas.length`, que en este caso coincide con el número de cuotas cargadas a mano).
