# Plan: Motor de Precios, Cuotas, Descuentos, Rentabilidad y Revendedores

> Estado: Fase 1 (Módulo A), Fase 2 (Módulo D), Fase 3 (Módulo C), Fase 6 (alcance por
> categoría) y **Fase 7 (plan de cuotas automático)** implementadas y verificadas end-to-end
> contra datos reales de Kety (ver §10.8, §11.8 y §12.7). Fase 4 (Módulo B) parcial — cuotas en
> POS/solicitud de crédito funcionan, falta el motor de ecommerce (ver §5.2, punto suelto
> documentado en `docs/info/aclaracion-gastos-cuotas.md`). Fase 5 sin empezar.
> **⚠️ La Fase 7 y sus ajustes posteriores están SIN COMMITEAR** — todo en el working tree de
> `novasispy-backend-api` y `novasispy-erp`, rama `reglas-precios` en ambos. Pendientes conocidos
> en §12.9.
> Guía de usuario/IA con el detalle de las fases implementadas:
> `docs/guias/guia-motor-precios-rentabilidad.md` (pendiente de actualizar con Fase 7).
> Guía de prueba por caso de uso: `docs/guia-prueba-reglas-precio-planes-cuotas.md`.
> Autor: Derlis + Claude. Fecha: 2026-08-24 (actualizado 2026-08-29).
> Documentos relacionados (construye **sobre** ellos, no los reemplaza):
> - `configuracion-y-listas-de-precios.md` — config global (`empresa.configuracion_precios`) + `lista_precios`.
> - `plan-precios-inventario.md` — administración de precios desde la vista de inventario.
> - `plan-cuotas-flujo-pos-admin.md` — flujo de cuotas en POS admin.
> - `plan-modulo-mora.md`, `plan-creditos-cobranzas.md`, `plan-comisiones-liquidaciones.md`.

---

## 1. Problema y objetivo

Un comercio (caso testigo: local de electrodomésticos que vende contado y en cuotas)
necesita que **el dueño defina las políticas de precio y el sistema las aplique y las haga
respetar**, sin que las vendedoras tengan que preguntar ni improvisar. Concretamente:

1. **Precio de venta contado derivado del costo**, con marcación **escalonada por rango de
   costo** (no es lo mismo +15% sobre algo de 100.000 que sobre algo de 10.000.000).
2. **Precio/monto de cuotas** según cantidad de cuotas y producto, mostrable también en el
   **ecommerce** (dropdown estilo Bristol: `1 x 2.224.000 … 18 x 189.000` + `Contado 1.329.000`).
3. **Control de descuento máximo** por producto/categoría/rol, con **autorización** (PIN de
   supervisor) cuando se excede.
4. **Rentabilidad real** de cada escenario (contado, crédito en cuotas, solicitud de crédito,
   ecommerce), usando **costo exacto por lote (FIFO)** e imputando los costos de vender a
   crédito (comisión, cobranza, costo financiero, mora esperada). Objetivo: que el dueño
   **sepa cuándo y a qué precio conviene vender**.
5. **Vendedores externos / revendedores**: se les factura (comprobante obligatorio) a un
   **precio propio** y **pagan de a poco** (cuenta corriente). NO es consignación.

El diseño es **genérico y paramétrico**: el mismo motor sirve a electro, farmacia, kiosco,
mayorista, ferretería y servicios, porque todo es regla configurable, nada hardcodeado por rubro.

---

## 2. Qué ya existe (construimos encima)

Inventario del schema actual relevante a este plan:

| Necesidad | Ya existe | Estado |
|---|---|---|
| Costo y precio por producto | `productos.precio_costo`, `productos.precio` | manual |
| Listas de precios multi-moneda por canal/cliente/zona, **piso/techo por producto** (`precio_minimo`/`precio_maximo`), descuento/recargo, redondeo, vigencia, prioridad | `lista_precios` + `lista_precios_productos` + `lista_precios_asignaciones` | maduro |
| Config global de precios | `empresa.configuracion_precios` (JSON) | ✅ |
| Planes de financiación (tasa simple/compuesto/francés, cuota inicial, intervalos, montos min/max) | `planes_cuotas` + `planes_cuotas_detalles` | ✅ |
| Cronograma calculado por factura | `cuotas_calculadas` + `cuotas_calculadas_detalle` | ✅ |
| Grilla de cuotas por producto (para ecommerce) | `producto_precio_cuota`, `cuotas_individuales` | ✅ (carga manual hoy) |
| Autorización genérica con PIN — **ya soporta `tipo='descuento'`** | `autorizaciones_caja` | ✅ |
| Comisiones + liquidaciones | `comisiones`, `liquidaciones_comisiones` | ✅ |
| Mora / intereses con comprobante | `config_mora`, `cob_config_intereses` | ✅ |
| Costo por lote + FIFO | `lotes_producto`, `movimientos_inventario` | ✅ |
| Cliente con lista propia + cuenta corriente | `clientes.tipo_cliente`, `clientes.lista_precios_id`, `limite_credito`, `saldo_pendiente`, `bloqueado_credito`, `cuentas_cobrar`, `recibos_cobro`, `factura_cuotas` | ✅ |

**No existe:**
- Un **motor de reglas de marcación** que derive el precio de venta desde el costo. → Módulo A (nuevo).
- **Políticas de descuento máximo** con enforcement en POS. → Módulo C (nuevo).
- Un **servicio de rentabilidad** transversal. → Módulo D (nuevo).
- Auto-generación de la **grilla de cuotas** del ecommerce a partir del precio. → parte de A.

> ⚠️ `precios_reseller` / `limites_reseller` NO se usan acá: son del programa de revendedores
> del SaaS (planes de suscripción del propio ERP), no de mercadería.

---

## 3. Decisiones tomadas (base de este diseño)

1. **Marcación**: en esta etapa **solo por rango de costo**. El schema se deja **preparado para
   ser configurable** (categoría/marca/producto) a futuro, pero la UI de esta fase expone
   únicamente rango de costo.
2. **Precio sugerido**: **editable, pero bloqueante por permiso** — cualquiera ve el precio
   calculado, pero **solo un usuario con el privilegio `PRECIOS_OVERRIDE`** puede modificarlo;
   el override queda auditado.
3. **Revendedor**: **modelo A (spread)** — su ganancia es la diferencia entre lo que revende y
   el precio revendedor que se le factura. No se le paga comisión. No es entidad nueva: es un
   `cliente` con `tipo_cliente='revendedor'` + `lista_precios_id` propia + cuenta corriente.
4. **Rentabilidad**: costo de mercadería tomado del **lote FIFO** consumido, no del promedio.

---

## 4. Arquitectura (5 módulos)

### Módulo A — Motor de reglas de precio  *(Fase 1, detallado en §5)*
Deriva el **precio contado** desde `precio_costo` según una **regla de marcación por rango de
costo**, lo escribe en la lista destino, y **auto-genera la grilla de cuotas**
(`producto_precio_cuota`) combinando el contado con cada `plan_cuota` activo.

### Módulo B — Precio crédito + cuotas + ecommerce
- Precio crédito = contado × (1 + recargo financiero del plan) — `planes_cuotas.tasa_interes`.
- Cronograma en POS y solicitud de crédito — `cuotas_calculadas` (ya existe).
- Ecommerce: endpoint `GET /ecommerce/producto/:id/financiacion` → `{ contado, cuotas:[{n,monto,total}] }`
  leyendo `producto_precio_cuota`; componente dropdown estilo Bristol.

### Módulo C — Descuentos con tope y autorización *(Fase 3, detallado en §10 — ✅ implementado)*
Tope de descuento automático por producto = punto de equilibrio exacto respecto al piso
(`precio_minimo` si está seteado, si no `precio_costo`); sin tabla `politicas_descuento` ni
tope configurable por rol (se descartó, ver §10 — cualquier exceso, sea quien sea, pide
autorización). Enforcement en frontend (POS) y backend (al crear factura) + override vía
`autorizaciones_caja tipo='descuento'`, dos modos: PIN presencial o remoto por websocket vía
`PanelSupervisor`.

### Módulo D — Rentabilidad transversal
`RentabilidadService` consumido en POS contado, POS crédito, solicitud de crédito y ecommerce.
Config del dueño en `costos_operacion_credito`. Costo de mercadería del **lote FIFO**.

### Módulo E — Cliente revendedor (modelo A / spread)
`tipo_cliente='revendedor'` + lista propia + factura obligatoria + cuenta corriente + paga de
a poco (todo reutilizado). Badges/filtros para diferenciarlo. Sin módulo de inventario nuevo.

---

## 5. FASE 1 — Motor de precios (diseño detallado)

### 5.1 Modelo de datos

Tabla nueva `reglas_precio`. En esta fase se usa **solo** el ámbito global + rango de costo,
pero se dejan las columnas de ámbito para la evolución futura.

```prisma
model reglas_precio {
  id                 String    @id @default(dbgenerated("uuid_generate_v4()")) @db.Uuid
  empresa_id         String    @db.Uuid
  nombre             String    @db.VarChar(100)
  activo             Boolean   @default(true)
  prioridad          Int       @default(1)

  // Ámbito (Fase 1: siempre 'global'; resto preparado a futuro)
  alcance            String    @default("global") @db.VarChar(20) // global | categoria | marca | producto
  categoria_id       String?   @db.Uuid
  marca_id           String?   @db.Uuid
  producto_id        String?   @db.Uuid

  // Condición por rango de costo (núcleo de Fase 1)
  moneda             String    @default("PYG") @db.VarChar(3)
  costo_desde        Decimal   @default(0) @db.Decimal(19, 4)
  costo_hasta        Decimal?  @db.Decimal(19, 4)   // null = sin tope superior

  // Método de cálculo
  metodo             String    @default("margen_sobre_costo") @db.VarChar(25)
                     // margen_sobre_costo | margen_sobre_precio | precio_fijo
  valor              Decimal   @db.Decimal(9, 4)    // 15.0000 = +15%  (o monto si precio_fijo)

  // Redondeo del precio resultante
  redondeo_activo    Boolean   @default(true)
  redondeo_tipo      String    @default("superior") @db.VarChar(10) // superior | normal | inferior
  redondeo_multiplo  Decimal   @default(1000) @db.Decimal(19, 4)    // redondear a múltiplos de X

  // Destino
  lista_precios_id   String    @db.Uuid   // a qué lista escribe el precio contado

  // Vigencia
  fecha_inicio       DateTime  @default(now()) @db.Date
  fecha_fin          DateTime? @db.Date

  usuario_creacion   String?   @db.VarChar(100)
  created_at         DateTime  @default(now()) @db.Timestamp(6)
  updated_at         DateTime? @db.Timestamp(6)

  empresa       empresas      @relation(fields: [empresa_id], references: [id], onDelete: Cascade)
  lista_precios lista_precios @relation(fields: [lista_precios_id], references: [id], onDelete: Cascade)
  categoria     categorias?   @relation(fields: [categoria_id], references: [id], onDelete: SetNull)
  marca         marcas?       @relation(fields: [marca_id], references: [id], onDelete: SetNull)
  producto      productos?    @relation(fields: [producto_id], references: [id], onDelete: SetNull)

  @@index([empresa_id, activo])
  @@index([lista_precios_id])
  @@index([alcance, categoria_id, marca_id, producto_id])
}
```

Auditoría del override manual de precio (decisión 2). Se puede resolver con el `audit_logs`
existente (`entity_type='producto_precio'`, `action='override'`, `old_value`/`new_value`), sin
tabla nueva. Se registra: precio sugerido por regla, precio final elegido, usuario, motivo.

### 5.2 Servicio de precios — `PreciosEngineService`

Reglas de resolución (Fase 1 — solo rango de costo, ámbito global):

1. Filtrar `reglas_precio` de la empresa, activas, vigentes, de la lista destino, cuya moneda
   coincida y cuyo `costo_desde ≤ precio_costo < costo_hasta` (o `costo_hasta = null`).
2. Si hay más de una candidata, gana la de **mayor `prioridad`** (desempate: rango más
   específico / `costo_desde` mayor).
3. Calcular:
   - `margen_sobre_costo`: `precio = costo × (1 + valor/100)`
   - `margen_sobre_precio` (markup sobre PV): `precio = costo / (1 - valor/100)`
   - `precio_fijo`: `precio = valor`
4. Aplicar redondeo (`superior` a múltiplo de `redondeo_multiplo`, etc.).
5. Devolver `{ precio_sugerido, regla_aplicada_id, desglose }`. **No** persiste solo: el precio
   se confirma/edita en la UI del producto (ver 5.4) o por el recálculo masivo (5.3).

Métodos:
- `calcularPrecio(productoId, listaId)` → precio sugerido + traza.
- `previsualizar(reglaId)` → cuántos productos afecta y muestra ejemplos (antes de guardar).
- `recalcularLista(listaId, opts)` → recorre productos y actualiza `lista_precios_productos`
  (respetando overrides manuales si `opts.preservar_override`).
- `generarGrillaCuotas(productoId, listaId)` → crea/actualiza filas `producto_precio_cuota`
  para cada `plan_cuota` activo, a partir del contado (alimenta el ecommerce y el POS).

**Costo por lote:** el precio se deriva del `precio_costo` del producto (costo de reposición).
La **rentabilidad** (Módulo D) es la que usa el costo del lote FIFO consumido — no el motor de
precios. Se documenta acá para evitar confusión: *precio* se marca sobre costo de reposición;
*rentabilidad* se mide sobre costo real del lote.

### 5.3 Endpoints (módulo backend nuevo `reglas-precio`)

```
GET    /reglas-precio                      # listar (empresa)
POST   /reglas-precio                      # crear
PUT    /reglas-precio/:id                  # editar
DELETE /reglas-precio/:id                  # baja lógica
POST   /reglas-precio/:id/previsualizar    # impacto: N productos, ejemplos
POST   /reglas-precio/recalcular           # recálculo masivo (body: listaId, filtros, preservar_override)
GET    /productos/:id/precio-sugerido?listaId=  # precio sugerido para un producto
```

Guards: privilegios `PRECIOS_REGLAS` (ver/editar reglas) y `PRECIOS_OVERRIDE` (editar el precio
sugerido de un producto). Siguen el patrón del módulo `privilegios` existente.

### 5.4 Frontend

- **Pantalla "Reglas de precio"** (Configuración → Precios → Reglas), reusando componentes de
  `src/components/_standards/` (obligatorio por `CLAUDE.md`): `ScreenGuia` colapsable,
  `MonedaInput` para `valor` cuando es `precio_fijo`, `EmptyState` con CTA, enums para `metodo`
  y `redondeo_tipo`. Tabla de reglas ordenadas por prioridad, con botón **Previsualizar impacto**
  y **Recalcular lista**.
- **En el form de producto**: el campo precio muestra el **precio sugerido** (badge "según regla
  X"). Editable solo si el usuario tiene `PRECIOS_OVERRIDE`; si no, `readOnly` con tooltip
  "Requiere autorización para modificar el precio". Al editar, pide motivo → `audit_logs`.
- **Grilla de cuotas** del producto: botón "Generar cuotas" que llama a `generarGrillaCuotas`.

### 5.5 Verificación previa (antes de codear Fase 1)

- [ ] Confirmar que la **salida de stock** (`movimientos_inventario`) guarda el **costo del lote**
      consumido (necesario para Módulo D). Si no, es un ajuste chico y se agenda en Fase 2.
- [ ] Confirmar nombres de privilegios y cómo se registran (`privilegios` module).
- [ ] Confirmar redondeo esperado por el negocio (a múltiplos de 1.000 Gs por defecto).

---

## 6. Fases siguientes (alto nivel)

| Fase | Módulo | Resumen | Estado |
|---|---|---|---|
| 1 | A | Motor de precios por rango de costo + grilla de cuotas + pantalla + override por permiso. | ✅ Implementado |
| 2 | D | `RentabilidadService` + `costos_operacion_credito` + reporte de margen real (costo por lote FIFO). Incluye ajuste de costo-por-lote en salidas si faltara. | ✅ Implementado |
| 3 | C | Tope de descuento automático por margen (sin tabla `politicas_descuento`, ver §10) + enforcement en POS y backend + override con `autorizaciones_caja` (PIN presencial o remoto). | ✅ Implementado y verificado E2E, ver §10.8 |
| 4 | B | Cuotas en POS + endpoint y dropdown de financiación en ecommerce. | ⚠️ Parcial: cuotas en POS/solicitud de crédito ✅; motor de ecommerce (`generarGrillaCuotas`) usa cálculo de interés simple, no el motor corregido — ver `docs/info/aclaracion-gastos-cuotas.md` |
| 5 | E | `tipo_cliente='revendedor'` (modelo spread) + lista propia + badges/filtros. | ❌ Sin empezar — candidata para seguir ahora |
| 6 | A (extendido) | Alcance opcional por categoría (específica o padre) en `reglas_precio` y `planes_cuotas`, además del rango de costo/precio ya existente. | ✅ Implementado y verificado E2E (2026-08-28), ver §11.8 |
| 7 | A+B (extendido) | Plan de cuotas automático: filtro por categoría/precio del carrito, `plan_cuota_id` persistido en factura/solicitud, `dcuotas` derivado de la cuota real. Más ajustes de producto posteriores (exclusión con cuotas por producto, condición de pago, permisos de precio, convención TEA). | ✅ Implementado y verificado E2E (2026-08-29), **sin commitear** — ver §12.7–12.9 |

Rentabilidad va temprano (Fase 2) por ser el objetivo central del negocio y porque, con el
motor de precios ya hecho, es barata.

---

## 9. FASE 2 — Rentabilidad neta (diseño detallado)

> Estado: **implementado**. Investigación previa en el código existente (ver hallazgos abajo)
> confirmó que ya existe una base sólida (`reportes-rentabilidad`) que usa costo FIFO real
> capturado en `factura_det.costo_total_venta`/`costo_unitario_venta` (poblado por
> `FifoService.deductFifo` al facturar, ver `src/lotes/fifo.service.ts`). Fase 2 **no reemplaza**
> ese reporte: le agrega un overlay de costos de operación a crédito para poder mostrar
> **utilidad bruta vs. utilidad neta**.

### 9.1 Señal de "venta a crédito"

No existe un campo único y confiable en `factura_cab` para esto (`icondcred`/`dcuotas` son
datos declarativos de SIFEN, no siempre coherentes con lo realmente cobrable). **Decisión**: una
factura se considera a crédito si tiene **una o más filas en `factura_cuotas`** — es la tabla
operativa real de cobro, se genera siempre que hubo financiación, independientemente de si se
usó el simulador `planes_cuotas`/`cuotas_calculadas`. El plazo de financiamiento se estima como
`max(factura_cuotas.dvenccuo) − factura_cab.dfeemide`, en meses (mínimo 1).

### 9.2 Modelo de datos — `costos_operacion_credito`

Una fila por empresa (mismo patrón que `config_mora`):

```prisma
model costos_operacion_credito {
  id                            String    @id @default(dbgenerated("uuid_generate_v4()")) @db.Uuid
  empresa_id                    String    @unique @db.Uuid
  activo                        Boolean   @default(false)
  comision_venta_pct            Decimal   @default(0) @db.Decimal(6, 3) // fallback si no hay comisión real ni % propio del vendedor
  costo_cobranza_pct            Decimal   @default(0) @db.Decimal(6, 3) // % sobre ingresos, solo ventas a crédito
  costo_financiero_pct_mensual  Decimal   @default(0) @db.Decimal(6, 3) // costo de oportunidad del capital, tasa mensual sobre el costo de mercadería financiado
  mora_esperada_pct             Decimal   @default(0) @db.Decimal(6, 3) // provisión estadística sobre ingresos, solo ventas a crédito
  usuario_actualizacion         String?   @db.VarChar(100)
  created_at                    DateTime  @default(now()) @db.Timestamptz(6)
  updated_at                    DateTime  @default(now()) @db.Timestamptz(6)
  empresa                       empresas  @relation(fields: [empresa_id], references: [id], onDelete: Cascade)
}
```

Si `activo=false` o no existe fila, el overlay es cero en todo el reporte (utilidad neta = bruta),
comportamiento seguro por defecto.

### 9.3 Fórmula del overlay (por factura, luego prorrateada a cada línea)

Para cada factura del período:

1. `comisionVenta` = suma real de `comisiones` (`tipo='venta'`, `factura_id=<id>`) si existen filas
   (empresa con addon de comisiones activo); si no, `vendedor.comision_venta` (o
   `comision_porcentaje`) × ingresos de la factura si hay vendedor asignado; si tampoco, el
   `comision_venta_pct` de la config. Se aplica **a toda venta**, no solo a crédito.
2. Si la factura es a crédito (§9.1), además:
   - `costoCobranza` = ingresos_factura × `costo_cobranza_pct` / 100.
   - `costoFinanciero` = costo_mercadería_factura (FIFO) × `costo_financiero_pct_mensual` / 100 ×
     meses_financiamiento.
   - `moraEsperada` = ingresos_factura × `mora_esperada_pct` / 100.
3. `utilidadNeta = utilidadBruta − comisionVenta − costoCobranza − costoFinanciero − moraEsperada`.

El overlay se calcula a nivel factura y se prorratea a cada `factura_det` según su participación
en el ingreso total de la factura, para poder seguir agregando por producto/cliente/sucursal con
el mismo pipeline existente (`aggregate()`), agregando `utilidad_neta`/`margen_neto_pct` junto a
los campos brutos existentes (que no cambian, evita romper consumidores actuales del reporte).

### 9.4 Endpoints nuevos

```
GET /costos-operacion-credito        # config de la empresa (upsert automático si no existe)
PUT /costos-operacion-credito        # guardar config
```

Los 4 endpoints existentes de `reportes-rentabilidad` devuelven ahora también `utilidad_neta`,
`margen_neto_pct` y un desglose (`comision`, `cobranza`, `financiero`, `mora`) por fila y en el
resumen — sin romper los campos brutos existentes.

Guards: privilegios `REP_ROC_CONFIG_VER` / `REP_ROC_CONFIG_EDITAR` bajo módulo `REPORTES`,
submódulo nuevo `REP_RENTABILIDAD` (mismo patrón que los demás submódulos de `REPORTES`).

### 9.5 Frontend

Extiende `ReporteRentabilidad.jsx` (no pantalla nueva): botón "Configurar costos de crédito"
(gated por `REP_ROC_CONFIG_EDITAR`) que abre un diálogo con los 4 campos + activo; KPIs y tabla
muestran neto junto al bruto cuando la config está activa.

---

## 7. Genericidad por rubro

| Negocio | Cómo se configura |
|---|---|
| Electro / mueblería | Marcación por rango de costo + cuotas + revendedores + descuento con tope. |
| Farmacia / kiosco | Marcación por categoría (fase futura), sin cuotas, descuento mínimo. |
| Mayorista / distribuidora | Listas por canal + clientes revendedores fuertes. |
| Ferretería | Marcación por marca/proveedor (fase futura) + ofertas por volumen. |
| Servicios | Precio fijo, sin costo, sin stock, sin cuotas. |

Todo se maneja con reglas configurables; ningún comportamiento está atado a un rubro.

---

## 8. Riesgos y notas

- **Overrides masivos**: el recálculo debe preservar precios editados a mano salvo que se pida
  explícitamente pisarlos (`preservar_override`).
- **Multi-moneda**: las reglas son por moneda; no mezclar costos en USD con reglas en PYG.
- **Redondeo**: definir default con el cliente (múltiplos de 1.000 Gs, hacia arriba).
- **Rentabilidad vs precio**: precio se marca sobre costo de reposición; rentabilidad se mide
  sobre costo real del lote. Son dos costos distintos a propósito.
- **Revendedor spread**: si en el futuro se quiere comisión explícita, se activa el modelo B
  (registrar comisiones en `comisiones`) sin rehacer nada de lo anterior.

---

## 10. FASE 3 — Descuentos con tope y autorización (diseño e implementación)

> Estado: diseñado con el usuario y **completamente implementado el 2026-08-26** (mismo día),
> con verificación end-to-end vía Playwright contra datos reales de Kety — ver §10.8. Ver
> también `docs/info/aclaracion-gastos-cuotas.md` (sección "tope de descuento") para la
> discusión completa con ejemplos numéricos, y `docs/guias/guia-motor-precios-rentabilidad.md`
> para la guía de usuario/IA.

### 10.1 Problema

Hoy el descuento en POS es libre — 0-100% por ítem (`ItemsTable.jsx`) y un descuento global
de factura (`TotalsPanel.jsx`) — sin ningún tope ni validación, ni en frontend (más allá de no
pasar de 100%) ni en backend al crear la factura.

### 10.2 Diseño descartado (v1 de este mismo documento)

La primera versión de este diseño proponía una tabla nueva `politicas_descuento` con tope
configurable por producto/categoría/rol. Se descartó tras revisión con el usuario: el tope no
debe ser un número inventado y configurado a mano — debe derivar del margen real ya cargado en
el producto (costo vs. precio de venta), y no hace falta diferenciar por rol si cualquier
exceso, sin importar quién lo pida, requiere autorización de supervisor.

### 10.3 Cómo se calcula el tope — sin tabla nueva

```
piso = lista_precios_productos.precio_minimo si está seteado y > 0
       si no, productos.precio_costo

tope_pct = max(0, (1 − piso / precio_venta) × 100)
```

`1 − costo/precio_venta` es el % de descuento que lleva exactamente al punto de equilibrio,
sea cual sea el método de la `regla_precio` que generó el precio (`margen_sobre_costo`,
`margen_sobre_precio`, `precio_fijo`). **No alcanza con tomar el `valor` % de la regla tal
cual** — verificado con un caso real (costo 500.000, regla +25% `margen_sobre_costo`, precio
625.000): un descuento "tope 25%" da 468.750, por debajo del costo; el tope exacto de
equilibrio es 20%. La fórmula de arriba da el número correcto sin tener que leer ni conocer la
regla que generó el precio — solo mira costo/precio actuales.

**Campo de respaldo manual**, para cuando no hay costo ni `precio_minimo` cargados (producto
sin costo tracking, o negocio que no usa el motor de reglas): campo nuevo opcional
`lista_precios_productos.descuento_maximo_pct`. Si tampoco está cargado, tope = 0% (cualquier
descuento pide autorización — seguro por defecto). Se edita con el mismo permiso que ya existe
para precios en `PreciosListasSection.jsx`; no se crea submódulo ni permiso nuevo.

### 10.4 Descuento global de factura

Sin fórmula propia: se prorratea entre los ítems del carrito según su peso en el subtotal, se
suma al descuento propio de cada ítem, y el total resultante por línea se valida contra el
`tope_pct` de cada producto — mismo camino de validación que el descuento por ítem.

### 10.5 Autorización — dos modos, mismo mecanismo de fondo (mayormente ya construido)

Ninguno de los dos modos usa una tabla o endpoint nuevo — el enum `tipo='descuento'` y las UI
ya existen, simplemente nunca se conectaron:

1. **PIN presencial** — `ModalPinSupervisor` (`src/components/organismos/POSDesign/
   CajaDesign/ModalPinSupervisor.jsx`) ya tiene `"descuento"` en `OPERACION_LABELS`. Llama a
   `validarPinSupervisor` (`users.service.ts:629`) → valida PIN + privilegio `POS_SUPERVISOR`
   (superadmin bypasea) → registra en `autorizaciones_caja` (`estado='aprobada'` directo).
   Síncrono, requiere al supervisor físicamente en el punto de venta.
2. **Remoto vía websocket** — `PanelSupervisor.jsx` ya escucha `autorizacion:solicitud` /
   `aprobada` / `rechazada` y ya tiene `"descuento": "Descuento"` en `TIPO_LABELS`, listo para
   listar cualquier pendiente `tipo='descuento'` sin cambios. Falta el lado del cajero: botón
   "Pedir autorización remota" → `solicitarAutorizacion` (`api/pos-security.service.js`, ya
   existe) + un listener de socket nuevo en el POS que escuche la resolución de esa solicitud
   puntual para desbloquear la venta sola cuando se aprueba. Así el supervisor no necesita
   estar en el salón de ventas.

`precio_minimo` no es un piso absoluto: se puede pisar con cualquiera de los dos modos de
autorización de arriba, igual que cualquier otro exceso de tope.

### 10.6 Backend — la validación se repite al crear la factura

El tope no puede ser solo un candado de UI (se salta con devtools). Al crear la factura,
recalcular el mismo `tope_pct` por línea (mismo `piso`/fórmula de §10.3) y exigir que exista
una `autorizaciones_caja` con `estado='aprobada'`, `tipo='descuento'`, del mismo usuario y
contexto, y reciente, si el descuento de esa línea lo supera. Sin esto el control es
cosmético — cualquiera podría mandar el payload directo sin pasar por la UI.

### 10.7 Checklist de implementación (completado 2026-08-26)

- [x] Migración: `descuento_maximo_pct` (nullable) en `lista_precios_productos`
      (`prisma/migrations/20260826_descuento_maximo_pct/`).
- [x] Servicio de resolución del tope: `ListaPreciosService.calcularTopeDescuento()`, expuesto
      en `obtenerPrecioProducto`/`obtenerPreciosProductosBatch` como `topeDescuentoPct` (+
      `precioCosto`/`precioMinimo`/`descuentoMaximoPct` para trazabilidad).
- [x] Ventana de "reciente": se reutiliza `expires_at` de `autorizaciones_caja` tal cual ya
      existía (30s para PIN presencial, 5 min para solicitud remota) — no hizo falta un campo
      nuevo. `FacturasService.validarTopeDescuentoFactura()` exige `estado='aprobada'` y
      `expires_at > now()`.
- [x] Nuevo estado `EstadoAutorizacionCaja.UTILIZADA`: al crear la factura, la autorización se
      consume atómicamente (`updateMany` con `where: { estado: 'aprobada' }`) para que no se
      pueda reusar en una segunda venta.
- [x] Hook de socket del lado del cajero para el modo remoto: `POSAdminTemplate.jsx` —
      `handleSolicitarAutorizacionRemota()` abre un socket ad-hoc al namespace `/pos` y escucha
      `autorizacion:aprobada`/`rechazada` filtrando por el `autorizacion_id` propio.
- [x] Frontend: bloqueo del input de descuento (`ProductSearch.jsx`, `ItemsTable.jsx`) al tope
      real del producto, con hint visible ("máx. X% sin autorización"), y gate final en
      `POSAdminTemplate.handleConfirmSave` sobre el descuento ya prorrateado (ítem + global)
      antes de enviar la factura.
- [x] Campo manual `descuento_maximo_pct` editable en `PreciosListasSection.jsx` (pestaña
      "Precios" del producto).

### 10.8 Verificación end-to-end y bugs encontrados en el camino (2026-08-26)

Se probaron los 6 casos de uso con datos reales de Kety (Playwright headless, dos sesiones de
navegador simulando cajero + supervisor en pestañas separadas): descuento dentro de tope sin
bloqueo, descuento excedido rechazado por el backend, clamp exacto del input al tope real,
descuento global prorrateado que dispara el diálogo de autorización, autorización por PIN
presencial, y autorización remota completa (pedida desde el POS, aprobada desde
`PanelSupervisor` en otra pestaña, la venta se destraba sola por WebSocket sin más clics). Los
6 casos terminan en una factura real creada en la base de Kety.

De paso se encontraron y corrigieron 4 bugs reales que las pruebas unitarias no detectaban:

1. **WebSocket de `PanelSupervisor.jsx` roto de antes**: usaba `useAuthStore((s) => s.token)`,
   un campo que nunca existió en ese store — el socket nunca se conectaba (silencioso, tapado
   por el poll de 30s de `getPendientes`). Arreglado con `getAccessToken()`
   (`src/api/api.config.js`). Verificado con logs de frames WS y el indicador "Conectado" del
   panel.
2. **`ProductSearch.jsx.handleSelect`** reconstruye `selectedProduct` con una lista fija de
   campos (no spread) — perdía `tope_descuento_pct` aunque `ProductoBrowserModal` lo hubiera
   resuelto bien.
3. **`handleUpdateItem` y `handlePrecioFinanciadoToggle`** (`POSAdminTemplate.jsx`) reconstruyen
   el ítem del carrito vía `construirItemDetalle` sin pasar `precioListaInfo` — cualquier cosa
   que dispare estos handlers (confirmado: el `onAccept` de un `IMaskInput` se dispara solo al
   montar, con `field="cantidad"`) volvía a poner `tope_descuento_pct` en `null` entre "agregar
   al carrito" y "confirmar venta".
4. **Closure obsoleto**: `handlePinSuccess` y el listener `autorizacion:aprobada` reintentaban
   `handleConfirmSave()` 100ms después de `setAutorizacionDescuentoId(...)`, pero ese closure de
   `handleConfirmSave` seguía viendo el estado viejo (`null`) — reabría el mismo diálogo de
   autorización en loop. Arreglado pasando el id de autorización explícito como argumento
   (`handleConfirmSave(autorizacionIdOverride)`, con guard de tipo porque
   `DocumentoPreviewModal` invoca `onConfirm` directo como handler de click).

Y un gap real de UX (no un bug de seguridad — el backend siempre revalidaba correctamente):
`ProductSearch.jsx` solo resolvía precio/tope vía `getPrecioProducto` cuando el cliente tenía
`lista_precios_id` asignada **directo** — el caso común en Kety es lista general (sin
asignación directa), así que el aviso de autorización nunca aparecía en el buscador rápido por
texto (sí funcionaba desde "Explorar todos los productos", que usa el endpoint batch sin ese
filtro). Arreglado: la llamada ya no depende de `cliente?.lista_precios_id`, y el
`topeDescuentoPct` de la respuesta se aplica aunque ninguna lista concreta haya aplicado
(antes se descartaba si `listaAplicada` era `null`).

---

## 11. FASE 6 — Alcance opcional por categoría en reglas de precio y planes de cuotas

> Estado: diseñado el 2026-08-27 y **completamente implementado, revisado y verificado end-to-end
> el 2026-08-28** vía Subagent-Driven Development (10 tareas del plan + una ronda de fix wave
> sobre hallazgos de la revisión final de rama) — ver §11.8. Pedido original: además del rango de
> costo/precio, poder acotar una regla de precio o un plan de cuotas a una categoría de producto
> (padre o específica), de forma opcional.

### 11.1 Problema

Hoy ambos motores solo miran un rango numérico (costo para `reglas_precio`, monto de venta para
la validación manual de `planes_cuotas`) para decidir si aplican. Un comercio con rubros mixtos
(ej. electro + línea blanca) necesita poder decir "esta marcación del 15% es solo para
Electrodomésticos" o "el plan de 12 cuotas sin interés solo aplica a Celulares", sin que eso
afecte al resto del catálogo que cae en el mismo rango de costo/precio.

### 11.2 `reglas_precio` — destrabar el ámbito ya modelado (sin cambio de schema)

El modelo ya tiene `alcance` (`global | categoria | marca | producto`) + `categoria_id` desde
Fase 1 (§5.1), preparado a propósito para esta evolución. El único cambio es en
`PreciosEngineService.calcularPrecio()`, que hoy fuerza `alcance: 'global'` en el `where`
(`src/reglas-precio/precios-engine.service.ts`). Pasa a:

1. Resolver la categoría del producto (`productos.categoria_id`) y, si tiene, la de su padre
   (`categorias.padre_id`).
2. Buscar candidatas cuyo rango de costo matchee (igual que hoy) **y** además:
   - `alcance = 'global'`, o
   - `alcance = 'categoria'` y `categoria_id` = la categoría del producto (match **específico**), o
   - `alcance = 'categoria'` y `categoria_id` = el `padre_id` de la categoría del producto (match
     de **categoría padre** — cubre reglas definidas a nivel del rubro general).
3. Si hay más de una candidata (p. ej. una regla global y una de categoría específica compiten
   por el mismo rango de costo), desempatar por **especificidad primero**, no solo por
   `prioridad`:

   ```
   categoría específica  >  categoría padre  >  global
   ```

   Dentro del mismo nivel de especificidad, se mantiene el desempate actual (`prioridad` desc,
   luego `costo_desde` desc). Esto no rompe reglas existentes: todas las reglas de hoy son
   `alcance='global'`, así que siguen compitiendo entre sí exactamente igual que antes; el nuevo
   nivel de especificidad solo entra en juego cuando alguien crea una regla de categoría.
4. Los alcances `marca` y `producto` quedan **sin usar por ahora** (ya modelados en el schema,
   mismo mecanismo que categoría) — se decidió no habilitarlos en esta fase para no ampliar la
   superficie de prueba sin necesidad (YAGNI, no fue parte de lo pedido); son un paso corto si
   se piden después, con el mismo patrón que categoría (exacto para `producto`, específico/padre
   para `marca` si las marcas tuvieran jerarquía, que hoy no tienen).

No hace falta migración: `categoria_id`, `marca_id`, `producto_id` y `alcance` ya existen en la
tabla desde Fase 1.

### 11.3 `planes_cuotas` — nuevas columnas, filtro de inclusión (no hay ámbito hoy)

A diferencia de `reglas_precio`, acá no hay nada que destrabar: `monto_minimo`/`monto_maximo`
son y siguen siendo una validación del **monto de la venta** al elegir un plan a mano
(`PlanesCuotasService.calcularCuotas`, §2 de la exploración previa) — no tocarlos evita romper
esa validación existente. Se agregan columnas nuevas, todas opcionales:

```prisma
model planes_cuotas {
  // ...campos existentes sin cambios...

  // Alcance opcional (Fase 6) — si están null, el plan aplica a cualquier producto/precio,
  // igual que hoy. Mismo mecanismo de categoría específica/padre que reglas_precio.
  categoria_id           String?  @db.Uuid
  precio_producto_desde  Decimal? @db.Decimal(19, 4)
  precio_producto_hasta  Decimal? @db.Decimal(19, 4)  // null = sin tope superior

  categoria categorias? @relation(fields: [categoria_id], references: [id], onDelete: SetNull, onUpdate: NoAction)

  @@index([empresa_id, categoria_id], map: "idx_planes_cuotas_categoria")
}
```

**Semántica: filtro de inclusión, no competencia.** A diferencia de `reglas_precio` (que elige
UNA regla ganadora para calcular un precio), un producto puede ofrecer varios planes de cuotas
a la vez (3 y 6 cuotas simultáneas, por ejemplo). No hay "más específico gana" acá — cada plan
decide independientemente si aplica o no al producto.

`PreciosEngineService.generarGrillaCuotas()` (`src/reglas-precio/precios-engine.service.ts`),
que hoy trae *todos* los planes activos con `cantidad_cuotas` seteado sin ningún filtro, pasa a
filtrar así por cada plan candidato:

```
aplica = (plan.categoria_id es null
            OR plan.categoria_id = producto.categoria_id            // específica
            OR plan.categoria_id = producto.categoria.padre_id)     // padre
         AND (plan.precio_producto_desde es null OR precioContado >= plan.precio_producto_desde)
         AND (plan.precio_producto_hasta es null OR precioContado <= plan.precio_producto_hasta)
```

Un plan sin `categoria_id` ni rango de precio seteados (el caso de **todos** los planes
existentes hoy) sigue aplicando a cualquier producto — comportamiento 100% retrocompatible, sin
migración de datos.

### 11.4 Migración

Una sola migración Prisma, solo sobre `planes_cuotas` (3 columnas nullable + índice):
`categoria_id UUID NULL REFERENCES categorias(id) ON DELETE SET NULL`,
`precio_producto_desde DECIMAL(19,4) NULL`, `precio_producto_hasta DECIMAL(19,4) NULL`.
`reglas_precio` no cambia de schema.

### 11.5 Frontend

- `ReglaPrecioFormDialog.jsx`: agregar un `Autocomplete` de categoría (mostrando jerarquía
  padre → hijos, mismo patrón ya usado en `ProductosTab.jsx`), con opción explícita "Todas las
  categorías" que setea `alcance='global'` (comportamiento actual, default). Al elegir una
  categoría, `alcance='categoria'` + `categoria_id`.
- `ReglasPrecioTemplate.jsx`: columna nueva en la tabla mostrando el alcance ("Global" o el
  nombre de la categoría) junto al rango de costo.
- `PlanesCuotasTab.jsx`: en la sección "Límites (opcional)" ya existente (junto a
  `montoMinimo`/`montoMaximo`), agregar el mismo `Autocomplete` de categoría y un rango
  `MonedaInput` para `precioProductoDesde`/`precioProductoHasta`, dejando claro en el helperText
  que este rango es sobre el **precio de catálogo del producto**, no sobre el monto de la venta
  (para no confundirlo con `montoMinimo`/`montoMaximo`, que queda igual).
- `PrevisualizarReglaDialog.jsx`: sin cambios funcionales necesarios, pero conviene revisar que
  el conteo de "productos afectados" ya filtre por categoría cuando la regla la tenga (usa el
  mismo `where` que `calcularPrecio`, así que debería salir gratis).

### 11.6 Riesgos y notas

- **Recálculo de grilla de cuotas**: al filtrar `generarGrillaCuotas` por categoría/precio,
  productos que hoy tienen filas en `producto_precio_cuota` para un plan pueden dejar de
  calificar el día que alguien le asigne categoría/rango a ese plan (comportamiento esperado,
  es la funcionalidad pedida, pero el endpoint de recálculo debe reportar explícitamente cuántas
  filas se generaron y cuántas se removieron, para que no parezca un bug).
- **No confundir con `lista_precios`**: `lista_precios`/`lista_precios_productos` ya tiene su
  propio mecanismo de alcance por cliente/canal/zona — esta fase no lo toca; el alcance nuevo es
  específico de `reglas_precio` y `planes_cuotas`.
- **Categorías con más de 2 niveles**: el modelo `categorias` permite jerarquías de cualquier
  profundidad vía `padre_id`, pero el matching de esta fase solo mira **un nivel hacia arriba**
  (categoría del producto y su padre directo), igual que la UI de `ProductosTab.jsx` hoy. Si en
  el futuro se necesitan 3+ niveles, es un cambio acotado a la función de resolución de
  categoría (recorrer la cadena de `padre_id` completa), sin tocar el resto del diseño.
- **Marca**: `reglas_precio` ya tiene `alcance='marca'` modelado igual que categoría; queda
  fuera de esta fase a propósito (ver §11.2, punto 4) — se puede habilitar después reutilizando
  el mismo patrón de especificidad.

### 11.7 Checklist de implementación (completado 2026-08-28)

- [x] Migración: 3 columnas nullable + índice en `planes_cuotas`
      (`prisma/migrations/20260827_planes_cuotas_alcance_categoria/`).
- [x] `PreciosEngineService.calcularPrecio()`: quitado el hardcode `alcance: 'global'`, agregada
      resolución de categoría (específica + padre) y el orden de especificidad del §11.2.
- [x] `PreciosEngineService.generarGrillaCuotas()`: aplicado el filtro de inclusión del §11.3, más
      la desactivación de filas obsoletas — con guard para nunca tocar filas creadas por el CRUD
      manual (`producto-precio-cuotas.service.ts`), ver §11.8.
- [x] DTOs de `planes_cuotas` (`crear-plan-cuotas.dto.ts`/`actualizar-plan-cuotas.dto.ts`):
      agregados `categoriaId?`, `precioProductoDesde?`, `precioProductoHasta?`.
- [x] `ReglaPrecioFormDialog.jsx` + `ReglasPrecioTemplate.jsx`: selector de categoría + columna
      de alcance en la tabla.
- [x] `PlanesCuotasTab.jsx`: selector de categoría + rango de precio de producto.
- [x] `previsualizar` (reglas) ahora filtra también por alcance de categoría — encontrado como gap
      real recién en la revisión final de rama (ningún task del plan original lo cubría pese a
      estar en este checklist), corregido en el fix wave, ver §11.8.
- [x] Probado con datos reales de Kety: regla por categoría específica, regla por categoría
      padre, y plan de cuotas acotado por categoría+rango conviviendo con un plan global — los 4
      escenarios pasaron, ver §11.8.

### 11.8 Verificación end-to-end, revisión final y fix wave (2026-08-28)

Implementado con Subagent-Driven Development: un implementador + un revisor por tarea (10 tareas
del checklist §11.7), más una revisión final de rama completa (backend y frontend por separado,
modelo más capaz) antes de dar la fase por cerrada.

**Verificación E2E contra Kety** (empresa `c9a80e4a-f640-4588-a80e-b0c706f6cd28`), vía llamadas
autenticadas directas al API: regla de categoría específica aplicada correctamente sobre una
global (`reglaAplicadaId` = la regla de categoría), regla de categoría padre aplicada igual,
plan de cuotas acotado por categoría conviviendo con un plan global en la misma grilla de
producto, y retrocompatibilidad confirmada (un producto de otra categoría solo recibe el plan
global; productos de control no relacionados quedaron con `updated_at` sin cambios). Los 4
escenarios pasaron. Datos de prueba quedaron en la base, prefijados `"TEST SDD - "`, para que el
equipo decida si los limpia.

**La revisión final de rama encontró 2 bugs críticos que ninguna de las 10 revisiones por tarea
había detectado** (ambos corregidos en una única ronda de fix wave, re-revisada y verificada
limpia antes de cerrar):

1. **Frontend — el selector de categoría nunca funcionó.** `CategoriaAutocomplete.jsx` leía
   `data?.items` de la respuesta de `GET /categorias`, pero el controller real envuelve la
   respuesta como `{ data: items, ... }` — el error viene de una verificación propia hecha contra
   `categorias.service.ts` (capa de servicio, que sí devuelve `items`) sin chequear el
   `categorias.controller.ts` (que renombra `items` a `data` antes de mandarlo por HTTP). Ese
   error se coló en la tarea de implementación y en las 3 revisiones de tarea siguientes porque
   se les indicó explícitamente que `.items` era el valor "verbatim correcto" — nada lo
   contradijo hasta que la revisión final chequeó el contrato real contra otros consumidores del
   mismo endpoint en el código (`ProductosTab.jsx`, `StockTab.jsx`), en vez de confiar en el
   brief. El síntoma (selector vacío, sin error visible) imitaba perfectamente el comportamiento
   de degradación por falta de permiso que sí era el diseño esperado — por eso pasó
   desapercibido en cualquier prueba manual superficial. **Lección:** verificar la forma real de
   una respuesta HTTP contra el controller, no solo contra la capa de servicio.
2. **Backend — el barrido de limpieza de `generarGrillaCuotas` podía desactivar filas de cuotas
   creadas a mano.** Existe un CRUD manual separado (`producto-precio-cuotas.service.ts`) que
   permite nombres libres (ej. "PRECIO CONTADO", "3 cuotas sin interés"); el barrido de filas
   obsoletas de esta fase no distinguía esas filas de las generadas por el motor, así que un
   recálculo cualquiera podía desactivar silenciosamente un plan de financiación cargado a mano
   — la tienda pública filtra por `activo=true`, así que desaparecería de la vista del cliente
   sin ningún error. Verificado antes de corregir: **ningún dato real se llegó a dañar** (0 filas
   desactivadas en las últimas 24h al momento de encontrar el bug), pero había 5 filas reales en
   Kety con nombres no generados por el motor que hubieran quedado expuestas en el próximo
   recálculo de cualquier producto. Corregido con un guard de patrón de nombre (solo el motor
   puede tocar filas nombradas `"N cuota(s)"`) — se evaluó la alternativa más robusta (agregar
   `plan_cuota_id` a `producto_precio_cuota` para dejar de depender del nombre) y se descartó por
   implicar una segunda migración sin una segunda ronda de revisión disponible; queda anotada
   como mejora futura recomendada, no bloqueante.

La revisión final también encontró y corrigió en el mismo fix wave: `previsualizar()` no filtraba
por categoría (gap real del checklist §11.7 que ningún task cubrió), falta de validación
`precioProductoDesde ≤ precioProductoHasta` en `planes_cuotas`, y que `PlanesCuotasTab.jsx` no
podía limpiar un alcance ya guardado (mandaba `undefined` en vez de `null`, que el backend ignora
silenciosamente en un update parcial).

**Deferido, no bloqueante** (documentado para quien retome el trabajo):
- Colisión de nombre en `producto_precio_cuota`: dos planes activos con la misma cantidad de
  cuotas colapsan en una sola fila (el nombre es clave única de la tabla). Se resuelve
  definitivamente con el mismo `plan_cuota_id` mencionado arriba.
- "Flip-flop" de la grilla entre listas de precios: `producto_precio_cuota` no tiene
  `lista_precios_id`, así que recalcular la lista A y luego la B puede activar/desactivar filas
  de forma dependiente de cuál se corrió último. Preexistente en el diseño (ya pasaba con el
  precio antes de esta fase), esta fase le agrega el mismo comportamiento sobre `activo`.
- `alcance='marca'`/`'producto'` en `reglas_precio` siguen aceptándose mudos (no hacen nada) —
  comportamiento heredado de antes de esta fase, no una regresión. Decisión confirmada con el
  usuario (2026-08-28): no se va a implementar alcance por marca.

**Corregido después del cierre inicial (2026-08-28, mismo día):** la falta de filtro por empresa
en `calcularPrecio()` (`GET /reglas-precio/producto/:id/sugerido`) — cualquier usuario autenticado
con el permiso podía consultar `precio_costo` y el precio sugerido de un producto de **otra
empresa** adivinando el UUID (bug preexistente, no introducido por Fase 6, pero encontrado durante
su revisión final). Fix: `calcularPrecio()` ahora recibe `empresaId` y filtra producto/lista/
reglas por tenant (`productos.findFirst`/`lista_precios.findFirst` con `empresa_id`, en vez de
`findUnique` solo por `id`); los 2 callers (`recalcularLista`, el controller) ya tenían `empresaId`
disponible. 3 tests nuevos de aislamiento multi-tenant agregados a
`precios-engine.service.spec.ts`.

### 11.9 Verificación de los flujos consumidores: Solicitud de Crédito y Facturación (2026-08-28)

El filtro de categoría/precio de Fase 6 vive en `generarGrillaCuotas()`, que puebla
`producto_precio_cuota`. Verificamos con qué pantallas de venta real ese filtro efectivamente
llega — el resultado no es obvio porque **hay dos caminos independientes para elegir cuotas en
ambas pantallas**, y solo uno de ellos hereda el filtro:

1. **Selector de cuotas por producto individual** (lee `producto_precio_cuota` vía
   `GET /producto-precio-cuotas/por-producto/:productoId`) — **sí** refleja el alcance de Fase 6.
   Lo usan tanto `NuevaSolicitudCredito.jsx` (`handleSelectProducto`) como
   `ProductSearch.jsx`/POS (cuando la condición de pago es crédito). Verificado en vivo contra
   Kety: el producto "TV MAST 14"" (categoría Televisores) muestra el diálogo "Seleccionar plan
   de cuotas" con exactamente 6/12/18 cuotas (6 = plan `TESTSDD-CAT-TV`, acotado a Televisores;
   12 y 18 = planes globales) — **en ambas pantallas, de forma idéntica**.
2. **"Plan de cuotas automático"** (selector separado en la misma pantalla, lee
   `GET /planes-cuotas/empresa` sin ningún filtro) — **no** refleja el alcance de Fase 6; lista
   todos los planes activos de la empresa sin importar categoría/precio del producto. Es el mismo
   endpoint en ambas pantallas, y es una limitación preexistente a Fase 6, no introducida por ella.

**Flujo completo probado en vivo contra Kety** (empresa real, cliente real DERLIS ADRIANO
DAMACENO, producto real TV MAST 14"):
- **Solicitud de crédito → aprobación → facturación**: se creó la solicitud 001-002-0000308
  eligiendo el plan de 6 cuotas (acotado por categoría) en el selector por producto, se aprobó, y
  se facturó desde el POS ("Solicitudes Crédito" → cargar solicitud → Registrar Pago). La factura
  resultante (001-003-0000063) quedó con el monto y las 6 cuotas exactos de la solicitud
  (Gs. 68.000 × 5 + Gs. 68.600), y `solicitud_credito.estado` pasó a `"facturada"` con
  `factura_id` correctamente enlazado. (La vista previa del PDF falló con un 502 transitorio —
  el event loop del backend se bloqueó ~16s por el hot-reload de `nest start --watch` en ese
  instante, confirmado en el log de arranque — pero la factura ya estaba creada antes de ese paso;
  no es un bug de Fase 6.)
- **Facturación directa (sin solicitud de crédito)**: en el mismo POS, seleccionando "6 cuotas"
  como condición de pago y agregando el mismo producto, aparece el mismo selector con las mismas
  3 opciones y el mismo precio financiado (Gs. 408.600). No se completó una segunda factura real
  para no volver a descontar stock de un producto que ya había quedado en 0 tras la primera prueba
  — la paridad ya quedó confirmada visualmente sin necesidad de un segundo movimiento real.

**Nota menor observada, no bloqueante:** en la factura creada vía "Facturar Solicitud", la fila de
`factura_det` quedó con `producto_precio_cuota_id: null` (a diferencia de lo que
`POSAdminTemplate.jsx:1640` hace cuando el cajero elige la cuota directo en el carrito, que sí
setea ese FK). El monto y el cronograma de cuotas de la factura son correctos igual — el plan
elegido ya quedó fijado en `solicitud_credito.cronograma` al aprobar la solicitud, así que no
depende de ese FK para calcular nada — pero es una asimetría entre los dos caminos que alguien
podría querer revisar si en el futuro se necesita trazar, desde una factura, exactamente qué fila
de `producto_precio_cuota` (y por lo tanto qué plan y con qué alcance) la originó.

---

## 12. FASE 7 — Plan de cuotas automático: alcance por categoría, trazabilidad y consistencia con SIFEN

> Estado: **implementado y verificado el 2026-08-29** (sin commitear — ver §12.7). Diseñado el
> 2026-08-28. Investigado con el usuario a partir
> de una pregunta de diseño ("¿cómo se administra el plan automático cuando la condición de pago
> es crédito? ¿cómo lo resuelven ERPs más profesionales?"), con research externo (Odoo, VTEX+MODO,
> Tiendanube) y una investigación de código que reveló un problema más grande que el gap de
> categoría original: el plan que efectivamente se aplica en una venta **no se persiste en ningún
> lado**, y el número de cuotas que se declara a SIFEN puede desincronizarse del número real de
> cuotas facturadas. Alcance confirmado con el usuario: las 3 fases completas.

**Nota de catálogo (2026-08-28, ya aplicada, no forma parte del código de las 3 fases):** al
discutir este diseño surgió que "Planes de Cuotas Automáticos" ya es un addon pago
(`VENTA_PLANES_CUOTAS`, `es_addon: true`, Gs. 80.000 — `seguridad.seed-data.ts:212`, ya lo hacía
cumplir `PermissionGuard` automáticamente, sin código nuevo) pero "Reglas de Precio" no lo era —
venía incluido gratis en INVENTARIO. Se decidió con el usuario dar el mismo tratamiento: se marcó
`INV_REGLAS_PRECIO` con `es_addon: true, precio_addon: 80000` (mismo precio). Kety ya tenía
contratado el addon de Planes de Cuotas; se le otorgó también el de Reglas de Precio
(`suscripcion_submodulos`) para no cortarle acceso a una funcionalidad que ya venía usando. Ningún
otro tenant se ve afectado — quien no tenga el addon simplemente deja de ver "Reglas de Precio" en
el menú y sus endpoints devuelven 403, igual que ya pasa hoy con Planes de Cuotas.

### 12.1 Problema

Hoy conviven **tres conceptos débilmente acoplados** en el sistema:

1. **`condiciones_pago`** — catálogo manual por empresa ("Contado", "6 cuotas", "12 cuotas"...).
   Existe por una razón fiscal: alimenta `iCondOpe`/`iCondCred`/`dCuotas` en el XML de SIFEN
   (`sifen-payload.service.ts:170,176,178`). No hay ningún seed automático — cada fila la crea la
   empresa a mano vía `POST /condiciones-pago`, y el modelo **no tiene** ninguna columna que la
   vincule a un `plan_cuota_id`.
2. **`planes_cuotas`** — la definición comercial real (interés, cuota inicial, y desde Fase 6 el
   alcance por categoría/precio). El selector "Plan de cuotas automático" (`PaymentPanel.jsx`,
   también presente en `NuevaSolicitudCredito.jsx`) trae **todos** los planes activos de la
   empresa sin filtrar por categoría/precio — a diferencia del selector por producto, que sí hereda
   el filtro de Fase 6 vía `producto_precio_cuota`.
3. **`producto_precio_cuota`** — la grilla precalculada por producto, ya category-aware desde
   Fase 6.

La investigación de código encontró tres problemas concretos, en orden de severidad:

- **El plan elegido en "Plan de cuotas automático" nunca se guarda.** Ni en `factura_cab`, ni en
  `factura_cuotas`, ni de forma confiable en `cuotas_calculadas` (el frontend no manda
  `clienteId`/`facturaId` a `calcularCuotas`, aunque el DTO los acepta). Es estado de UI que se
  pierde apenas se cierra la pantalla. Peor: `PaymentPanel.jsx` ya expone un callback
  `onPlanCuotaChange(plan)` pensado exactamente para esto, pero `POSAdminTemplate.jsx` nunca lo
  conecta (no está en la lista de props que le pasa en `POSAdminTemplate.jsx:2681-2697`) — es un
  enganche a medio construir, no un diseño nuevo.
- **`dcuotas` (el número de cuotas que se declara a SIFEN) sale de una fuente distinta a la
  cantidad real de cuotas facturadas.** `POSAdminTemplate.jsx:1796-1800` arma
  `dcuotas: esCuotasCredito ? condicionPagoActiva?.cuotas : null` — el número de la fila de
  `condiciones_pago` elegida a mano — mientras que el cronograma real que termina en
  `factura_cuotas` (`facturas.service.ts:1362-1374`) sale del state `cuotas` (calculado por el
  plan automático, o por producto, o cargado manual). Nada obliga a que ambos números coincidan;
  si un plan calcula 6 cuotas pero la condición de pago elegida dice "12 cuotas", SIFEN recibe un
  `dCuotas` que no corresponde a lo realmente facturado.
- **El gap de categoría original** (el selector automático no filtra por categoría/precio).

`NuevaSolicitudCredito.jsx` repite exactamente el mismo patrón en paralelo (su propio
`planAutomaticoId`, nunca incluido en el payload de creación de la solicitud;
`solicitud_credito.condicion_pago_id` y el plan elegido son totalmente independientes).

### 12.2 Qué dicen ERPs/plataformas más maduras (research)

- **Odoo** (ERP contable): funde "installment plan" y "payment term" en un solo concepto — un
  término de pago con varias líneas. No tiene noción de elegibilidad por categoría de producto
  (no la necesita: es contabilidad pura, sin inventario de por medio).
- **VTEX + MODO** ("Cuotas por SKU", el caso más parecido al de este sistema): confirman que el
  patrón correcto para financiamiento retail con elegibilidad por SKU/categoría es tener una
  condición comercial por cada combinación de alcance + plan — exactamente lo que `planes_cuotas`
  con `categoria_id`/`precio_producto_desde/hasta` ya modela desde Fase 6. A nivel carrito, la
  práctica estándar es mostrar dinámicamente el **máximo de cuotas disponible según lo que hay en
  el carrito en ese momento**, no forzar un plan único para todo.
- **Ecommerce base (Tiendanube y similares)**: ni siquiera soportan cuotas diferenciadas por
  categoría — el piso estándar de mercado. Lo ya construido en Fase 6 está por encima de ese piso.

**Conclusión de diseño:** no conviene fusionar `condiciones_pago` con `planes_cuotas` (una es
fiscal, la otra comercial, y fusionarlas requeriría tocar el corazón del armado de SIFEN sin una
razón de negocio que lo justifique). Sí conviene: (a) que el plan elegido quede persistido y
trazable, y (b) que el número que se declara a SIFEN se derive de la cuota real facturada, no de
una fila de catálogo elegida a mano por separado.

### 12.3 Fase A — El selector automático filtra por categoría/precio del carrito

**Lógica compartida, sin duplicar código.** Se extrae el predicado de matching que hoy vive
inline en `generarGrillaCuotas` (`precios-engine.service.ts:563-569`) a una función pura en
`src/common/utils/plan-cuotas-matching.util.ts` (mismo patrón ya usado por
`calculo-interes.util.ts` en esa carpeta) — evita acoplar el módulo `planes-cuotas` al módulo
`reglas-precio` vía inyección de dependencias:

```ts
// src/common/utils/plan-cuotas-matching.util.ts
export type PlanConAlcance = {
  categoria_id: string | null;
  precio_producto_desde: unknown; // Prisma.Decimal | null
  precio_producto_hasta: unknown;
};

/**
 * 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
 * padre directo (un solo nivel — mismo criterio que calcularPrecio, Fase 6).
 */
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 || precioContado >= Number(plan.precio_producto_desde);
  const matchHasta = plan.precio_producto_hasta == null || precioContado <= Number(plan.precio_producto_hasta);
  return matchCategoria && matchDesde && matchHasta;
}
```

`generarGrillaCuotas` (`precios-engine.service.ts:563-569`) pasa a llamar esta función en vez de
repetir la lógica inline — mismo comportamiento, cero cambio funcional, solo elimina la
duplicación antes de que exista una segunda copia.

**Nuevo método en `PlanesCuotasService`** (`src/planes-cuotas/planes-cuotas.service.ts`, cerca de
`obtenerPlanes`, línea 96-119):

```ts
// Sin tipo de retorno explícito, igual que obtenerPlanes() — el tipo lo infiere
// TypeScript desde el findMany() de Prisma.
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,
      );
    }),
  );
}
```

**Por qué exige que TODOS los ítems matcheen** (`items.every(...)`, no `.some(...)`): el plan
automático financia el **monto total del carrito**, no producto por producto — a diferencia del
selector por producto (`producto_precio_cuota`), que sí es correcto mostrar por ítem individual.
Ofrecer un plan "solo Zapateros" cuando el carrito tiene además un Televisor sería financiar el
Televisor con condiciones pensadas para otra categoría. Esto replica exactamente el comportamiento
"máximo de cuotas según lo que hay en el carrito" confirmado como estándar en VTEX/MODO: agregar un
ítem de categoría no cubierta reduce las opciones automáticas disponibles, nunca las fuerza.

**Nuevo endpoint** `POST /planes-cuotas/aplicables` (`planes-cuotas.controller.ts`, cerca de
`@Get('empresa')`, línea 66-92), body `{ items: [{ productoId, precioContado }] }`, mismo permiso
`VEN_PCA_PLAN_VER` que `obtenerPlanes`.

**Frontend:** `POSAdminTemplate.jsx:247-249` (`useQuery` de `planesCuotasActivos`) pasa a llamar
`obtenerPlanesAplicables` con los `items` del carrito (ya tiene `producto_id`/`precio_unitario` por
línea, confirmado en `invoiceCalculations.js:218-223`) en vez de `getPlanesEmpresa` sin filtro —
`queryKey` incluye la firma de productos+precios del carrito para que se recalcule al cambiar.
Mismo cambio en `NuevaSolicitudCredito.jsx:1247-1253`.

### 12.4 Fase B — Persistir qué plan se usó realmente

**Migración** (nueva, sin tocar columnas existentes): `plan_cuota_id UUID NULL` en `factura_cab` y
en `solicitud_credito`, ambas con FK a `planes_cuotas(id)` `ON DELETE SET NULL` (un plan
desactivado o borrado no debe romper facturas históricas), más un índice por empresa+plan en cada
tabla para reportes futuros ("¿cuántas ventas usaron este plan?").

**Backend:**
- `FacturaCabDto` (`create-factura.dto.ts`, cerca de `dcuotas` en la línea 324): nuevo campo
  opcional `plan_cuota_id?: string` (`@IsOptional() @IsUUID()`).
- `FacturasService.create()` (`facturas.service.ts:721-724`, junto a `dcuotas: cabecera?.dcuotas ?? null,`):
  agrega `plan_cuota_id: cabecera?.plan_cuota_id ?? null,` al `data` del `factura_cab.create`.
- `CrearSolicitudCreditoDto` (`create-solicitud-credito.dto.ts`, cerca de `condicion_pago_id` en
  la línea 131): mismo campo opcional.
- `SolicitudesCreditoService` — método de creación (`solicitudes-credito.service.ts:56-69`, junto
  a `condicion_pago_id: dto.condicion_pago_id ?? null,`): agrega
  `plan_cuota_id: dto.plan_cuota_id ?? null,`. Y en el método de actualización (línea ~203, mismo
  patrón condicional `!== undefined` que ya usa `condicion_pago_id`).

**Frontend — conectar el enganche que ya existe pero no se usa:**
- `POSAdminTemplate.jsx:2681-2697`: agrega `onPlanCuotaChange={setPlanCuotaSeleccionado}` a
  `<PaymentPanel>` (prop que `PaymentPanel.jsx` ya emite) + nuevo estado
  `const [planCuotaSeleccionado, setPlanCuotaSeleccionado] = useState(null);`. El payload de
  creación de factura agrega `plan_cuota_id: planCuotaSeleccionado?.id || undefined` a `cabecera`.
- `NuevaSolicitudCredito.jsx`: el estado `planAutomaticoId` (línea 1244) ya existe — el payload de
  creación (línea ~1864-1918) agrega `plan_cuota_id: planAutomaticoId || undefined`.
- Cuando la venta usa el selector **por producto** (no el automático) no hay un `plan_cuota_id`
  único de carrito que asignar a nivel factura — cada línea ya tiene su propio
  `producto_precio_cuota_id` (que si se quiere trazar hasta el plan, se resuelve con un join, no
  necesita este campo nuevo). `plan_cuota_id` en `factura_cab`/`solicitud_credito` es
  específicamente para el camino del plan automático de carrito completo.

### 12.5 Fase C — `dcuotas` deja de poder desincronizarse

**Cambio acotado y de bajo riesgo** (es una corrección de qué dato se usa, no una lógica nueva):
en `POSAdminTemplate.jsx:1796-1800`, `dcuotas` pasa de leerse de `condicionPagoActiva?.cuotas` a
leerse de `cuotas.length` — el array que ya es la fuente real de las filas que se van a crear en
`factura_cuotas` (`facturas.service.ts:1362-1374`), sin importar si esas cuotas vinieron del plan
automático, del selector por producto, o de carga manual (`generarCuotasLocal()`). En los tres
casos, `cuotas.length` es, por definición, el número real de cuotas que se está facturando —
`condicionPagoActiva?.cuotas` es solo lo que alguien tipeó al crear esa fila del catálogo, sin
ninguna garantía de que siga coincidiendo.

```jsx
// antes (POSAdminTemplate.jsx:1796-1800)
dcuotas: esCuotasCredito ? condicionPagoActiva?.cuotas : null,
// después
dcuotas: esCuotasCredito ? cuotas.length : null,
```

`icondcred` (1 o 2, contado/crédito) no cambia — sigue derivándose de `esCuotasCredito`, que ya es
correcto (es una clasificación fiscal binaria, no depende de cuántas cuotas haya).

**Por qué es más seguro de lo que parece:** no introduce una fuente de datos nueva ni cambia
ninguna otra parte del armado de SIFEN — sustituye una fuente de datos que ya se sabía que podía
estar desincronizada por la fuente que ya es la verdad operativa del sistema. El riesgo real está
en la superficie de prueba (facturación electrónica), no en la lógica del cambio en sí.

### 12.6 Riesgos y notas

- **`plan_cuota_id` es opcional en todo el flujo** — una factura/solicitud sin plan automático
  (selector por producto, o carga manual sin plan) sigue funcionando exactamente igual que hoy,
  con el campo en `null`. Retrocompatible, sin migración de datos existentes.
- **La Fase C toca el armado del payload SIFEN.** Aunque el cambio es acotado, amerita: (a) un
  caso de prueba explícito confirmando que `dcuotas` coincide con `factura_cuotas.count()` para
  los 3 orígenes de cuotas (plan automático, por producto, manual), y (b) que quien lo revise
  tenga en mente que este campo viaja al XML real de SIFEN — no es un campo cosmético.
- **`obtenerPlanesAplicables` no reemplaza `obtenerPlanes`** — el endpoint viejo
  (`GET /planes-cuotas/empresa`) queda intacto para otros consumidores (ej. la pantalla de admin
  de Configuración → Planes de Cuotas, que necesita ver TODOS los planes, no solo los aplicables a
  un carrito puntual).
- **No se toca `condiciones_pago`** — sigue siendo el catálogo manual que alimenta `iCondOpe` y el
  desplegable de "Condición de Pago". Fusionarlo con `planes_cuotas` fue evaluado y descartado (ver
  §12.2) por tocar código de cumplimiento fiscal sin necesidad de negocio clara.
- **Carritos sin ningún plan aplicable** (ningún plan cubre todos los ítems): el selector muestra
  solo "Sin plan (división simple, sin interés)" — comportamiento ya existente, sin cambios.

### 12.7 Checklist de implementación (completado 2026-08-29)

Ejecutado con Subagent-Driven Development (9 tasks de código + 1 de verificación manual), con
revisión por task, revisión final de toda la rama y una ronda de fix. **Sin commits** — por
instrucción explícita del usuario, todo quedó en el working tree de ambos repos (rama
`reglas-precios` en `novasispy-backend-api` y en `novasispy-erp`); el usuario maneja el git a mano.

- [x] `src/common/utils/plan-cuotas-matching.util.ts`: extraer `planAplicaAProducto()`.
- [x] `generarGrillaCuotas()`: usar la función extraída en vez de la lógica inline.
- [x] `PlanesCuotasService.obtenerPlanesAplicables()` + `POST /planes-cuotas/aplicables`.
- [x] `PaymentPanel.jsx`/`POSAdminTemplate.jsx` y `NuevaSolicitudCredito.jsx`: consumir el nuevo
      endpoint en vez de `getPlanesEmpresa` sin filtro, con la firma del carrito en la `queryKey`.
- [x] Migración: `plan_cuota_id` nullable + FK + índice en `factura_cab` y `solicitud_credito`
      (`20260828_plan_cuota_id_trazabilidad`, idempotente, aplicada y verificada en Postgres).
- [x] `FacturaCabDto`/`CrearSolicitudCreditoDto`: campo `plan_cuota_id?`.
- [x] `FacturasService.create()`/`SolicitudesCreditoService` (crear y actualizar): persistir
      `plan_cuota_id`.
- [x] `POSAdminTemplate.jsx`: conectar `onPlanCuotaChange`, mandar `plan_cuota_id` en el payload.
- [x] `NuevaSolicitudCredito.jsx`: mandar `plan_cuota_id`.
- [x] `POSAdminTemplate.jsx`: `dcuotas` pasa a derivarse de la cantidad real de cuotas facturadas.
- [x] Verificación E2E con datos reales de Kety: los 5 escenarios PASS (filtro por carrito mixto en
      POS y Solicitud; `plan_cuota_id` persistido en factura y solicitud; `dcuotas` == cuotas reales
      en plan automático y selector por producto; flujo completo solicitud→facturación heredando el
      plan; retrocompatibilidad contado sin plan).

**Bugs encontrados y corregidos durante la ejecución** (ninguno estaba en el diseño original):

1. **Aislamiento multi-tenant** (Important, task review): `obtenerPlanesAplicables` consultaba
   `productos.findMany` sin `empresa_id`. Venía del snippet de §12.3 de este documento. Corregido +
   test de aislamiento.
2. **Condición de carrera en la herencia del plan** (Important, task review): al facturar una
   solicitud, `PaymentPanel.jsx` buscaba el plan heredado dentro de la lista YA FILTRADA por
   carrito — si el plan estaba desactivado o fuera de alcance, reintentaba para siempre sin
   encontrarlo y la factura perdía la trazabilidad. Corregido pidiendo el plan directo por id
   (`getPlanPorId`), sin depender de la lista filtrada.
3. **Tabla física en plural** (migración): el SQL de §12.4 decía `ALTER TABLE solicitud_credito`,
   pero ese modelo Prisma mapea (`@@map`) a `solicitudes_credito`. Corregido.
4. **`dcuotas` off-by-one en la rama con solicitud** (Important, revisión final): esa rama seguía
   derivando de `cant_cuotas_total + 1 si hay entrega`, pero `cant_cuotas_total` YA incluye la fila
   de entrega cuando viene de cronograma persistido → `dcuotas = cuotas_reales + 1`, y ese número
   viaja al XML de SIFEN. Era preexistente (no lo introdujo ninguna task), pero contradecía el
   objetivo fiscal de la fase, así que se unificaron ambas ramas sobre la cantidad real facturada.
5. **Precio financiado colándose al filtro** (Important, revisión final): `itemsParaPlanes` mandaba
   `precio_unitario`, que puede haber sido sobrescrito por el toggle de precio financiado. Ahora usa
   `precio_original`. (Y en `NuevaSolicitudCredito.jsx` se agregó ese campo, que no existía.)
6. **`precio_original` se perdía al editar cantidad/precio** (residual de la revisión final):
   `construirItemDetalle` solo preservaba `id`/`_oferta`. Corregido.

### 12.8 Ajustes de producto posteriores a la implementación (2026-08-29)

Surgieron de probar la pantalla con datos reales, no del diseño original. Todos en el working tree,
sin commitear.

**a) Plan automático y cuotas por producto pasan a ser excluyentes.** Convivían y podían aplicarse
a la vez, produciendo **doble financiación**: las cuotas por producto ya dejan el
`precio_unitario` con el precio financiado incorporado, y el plan automático calculaba su interés
*sobre ese precio ya financiado*. Ahora, con un plan activo: no se ofrece el selector por producto
al agregar un ítem, y los ítems ya cargados con cuota por producto vuelven a precio de catálogo.
En el POS la exclusión ya ocurría de casualidad (el filtro comparaba contra `c.plan_cuota_id`,
columna que `producto_precio_cuota` **no tiene**, así que nunca matcheaba) — se hizo explícita y se
borró el filtro muerto.

**b) La condición de pago deja de competir con el plan.** Se veía *Condición: "10 cuotas"* junto a
*Plan: "12 Cuotas"* y *Plan de pago: 12* — el número de la condición quedaba muerto (al haber plan,
la UI usa `cuotasPlanElegidas`). Con plan activo la condición pasa a un campo de solo lectura
**"Crédito"**, con la ayuda *"La cantidad de cuotas la define el plan automático"*, y ya no pisa las
cuotas del plan. Si la condición elegida era "Contado", se cambia sola a una de crédito para que
`iCondOpe`/`iCondCred` salgan bien. **`condiciones_pago` sigue intacto** como catálogo fiscal.

**c) Aviso cuando los productos traen planes de distinta cantidad de cuotas.** La solicitud tiene
UN solo cronograma, pero `addItem` dejaba la cantidad del **último producto agregado** — un plazo
elegido por orden de carga. Ahora se avisa, se listan los productos con su cantidad, y se ofrecen
chips para elegir el plazo a conciencia (o separar en dos solicitudes).

**d) Permisos nuevos para proteger precios.** Se detectó que se podía confirmar una solicitud con
precio 0 (total Gs. 0) y que la pestaña Precios del producto solo pedía `INV_PRD_PRODUCTO_EDITAR`:

| Privilegio | Qué protege | Estado |
|---|---|---|
| `VEN_SOL_PRECIO_EDITAR` | Editar el precio unitario en la solicitud de crédito | nuevo |
| `INV_PRD_COSTO_EDITAR` | Campo "Precio costo" del producto | ya existía en el catálogo, **el frontend no lo usaba** |
| `INV_PRD_PRECIO_BASE_EDITAR` | Campo "Precio" (base) del producto | nuevo |
| `INV_PRD_CUOTA_EDITAR` | CRUD de "Precios a Cuotas" del producto (no tenía ninguna protección) | nuevo |

Además, un **piso de precio independiente del permiso**: no se puede confirmar una solicitud con
precio 0 ni por debajo del `precio_costo` del producto, aunque el usuario tenga
`VEN_SOL_PRECIO_EDITAR` — el permiso define *quién* edita, el piso define *hasta dónde* (mismo
criterio que el tope de descuento del POS). Requirió agregar `precio_costo` al `select` de
`GET /productos/search`, que no lo devolvía (sin eso el piso por costo nunca se disparaba, en
silencio). Solo el perfil **Administrador** (selector `'*'`) recibe los privilegios nuevos
automáticamente; **Vendedor** usa códigos explícitos, así que por defecto no puede editar precios.

**e) Convención de tasa aclarada + tope de 100% eliminado.** Comparando contra calculadoras de
"sistema francés" los números no coincidían. No era un error de cálculo: es una diferencia de
convención.

- **Nuestro**: la tasa cargada es **Tasa Efectiva Anual (TEA)**; la del período se deriva por
  equivalencia (`(1+TEA)^(dias/365) − 1`, `calculo-interes.util.ts:124`). 96% TEA → **5,6869%** por
  30 días.
- **Las calculadoras** (argentinas) piden **TNA**, y la dividen por 12. 96% TNA → **8%**.

Ambas correctas, pero muy distintas: 96% TEA ≈ 68,24% TNA, y 96% TNA ≈ 155,07% TEA. Se mantuvo la
convención TEA (decisión del usuario) porque es el costo real del año y mantiene coherentes los
planes semanales/quincenales — que fue el motivo por el que se eligió (ver el comentario del util).
Cambios: el campo pasó a llamarse **"Tasa Efectiva Anual — TEA (%)"**, muestra la conversión en vivo
(*"155.07% TEA = 8.00% por período de 30 días (≈ 97.33% TNA)"*), y se **eliminó el tope de 100%**
(ahora hasta 1000%, en ambos DTOs y en el input) — en plazas de alta inflación una TEA de 3 dígitos
es normal. Verificado: cargando 155,07% TEA el simulador reproduce la calculadora externa (interés
1ª cuota 80.001 vs 80.000; la diferencia restante es el redondeo comercial deliberado de las cuotas).

**f) Errores del backend dejaban de verse.** Al elegir un plan cuyo monto mínimo no se alcanzaba,
el frontend mostraba *"No se pudieron calcular las cuotas"* y tiraba el motivo real que sí mandaba
el backend (*"El monto total debe ser mayor a Gs. 500.000"*). Ahora se muestra el mensaje real.

### 12.9 Pendientes conocidos

Ninguno bloquea el uso de la fase; están acá para no perderlos.

1. **El selector de plan automático no filtra por monto mínimo/máximo del plan.** Un plan aparece
   aunque el carrito no llegue a su mínimo, y recién falla al elegirlo (ahora al menos con el motivo
   correcto, ver 12.8.f). La pantalla de edición de producto sí filtra (*"No aplican por monto: 12
   Cuotas"*). Arreglo: mandar el monto del carrito a `POST /planes-cuotas/aplicables` y filtrar
   también por `monto_minimo`/`monto_maximo`.
2. **Los 4 privilegios nuevos no se probaron con un usuario restringido.** El usuario de prueba de
   Kety es Administrador (`'*'`), así que los tiene todos: solo se verificó el camino "con permiso".
   Falta probar con un perfil Vendedor que los campos queden efectivamente bloqueados.
3. **Caso de borde de `dcuotas` en solicitudes legacy**: una solicitud vieja **sin cronograma
   persistido**, con `cant_cuotas_total = 1` y entrega inicial > 0, genera 2 filas de cuota
   (entrega + 1) → se declara `icondcred: "1"` (a plazo) junto con `dcuotas: 2`. Coherente sería
   derivar también `icondcred` de la cantidad real, o exigir `esCuotas`.
4. **Sin debounce en la query de planes aplicables**: cada cambio de precio de un ítem genera una
   `queryKey` nueva → request nuevo y remontaje del bloque (parpadeo). Sugerido:
   `placeholderData: keepPreviousData` + debounce sobre la firma del carrito.
5. **Dato de prueba**: el plan `PLAN-02` de Kety quedó en **155,07%** por la verificación de la
   convención de tasa (era 96%). Facturas de prueba `0000064`–`0000067` y solicitud
   `001-002-0000309` también quedaron cargadas.
