# Plan de Implementación — Módulo Ecommerce

**Fecha**: Junio 2026 · **Última auditoría**: Julio 2026
**Referencia funcional**: `plan-ecommerce.md`
**Propósito**: Hoja de ruta técnica, fase por fase, para conectar la tienda pública (`novasis-ecommerce-store`) con el backend ERP y el panel de administración. Nada debe quedar hardcodeado en la tienda.

---

## ✅ Estado actual — LISTO PARA MVP (auditoría Julio 2026)

| Fase | Estado |
|---|---|
| 1 — Config pipeline | ✅ Completa |
| 2 — Catálogo desde ERP | ✅ Completa |
| 3 — Diseño | ✅ Completa (incluye el preview iframe) |
| 4 — Promociones y novedades | ✅ Completa |
| 5 — Checkout y pagos | ✅ Completa (Novasis Pay end-to-end + fallback manual) |
| 6 — Operación post-compra | ✅ Funcional (bandeja unificada) |
| 7.5 — Reserva de stock pre-pago | ✅ Completa |
| 7.1–7.4 — B2B / SEO / analítica / dominios | ⛔ Fuera del MVP (SEO parcial: sitemap + metadata dinámica hechos) |

### Bugs de integridad corregidos en la auditoría (Julio 2026)

La auditoría end-to-end encontró **4 fallas que corrompían stock y plata**. Todas corregidas:

1. **Cancelar un pedido pagado no liberaba el stock reservado.** `changeEstado` nunca llamaba a `PedidosService.cancelar()`, así que cada cancelación dejaba stock trabado para siempre. → Ahora libera vía `cancelarPedidoErpVinculado()`.
2. **El pago online confirmaba el pedido sin crear el pedido ERP ni reservar stock.** `syncPaymentStatusLazy` hacía un `UPDATE` crudo, salteándose el flujo real → se podía vender dos veces lo mismo. → Ahora delega en `confirmarPagoDesdeGateway()`.
3. **El webhook de Novasis Pay no impactaba en el ecommerce.** Sólo actualizaba `novasis_pay_cobro`, y el checkout ecommerce no crea cobros → el pago quedaba en `pendiente_pago` para siempre si el cliente no volvía a la tienda. → Ahora resuelve la sesión por `payment_intent_id`.
4. **Reintentar "Crear pedido ERP" duplicaba pedidos borrador**, porque el `pedido_id` se vinculaba recién después de `confirmar()`. → Ahora se vincula antes y el reintento reanuda sobre el mismo borrador.

### Permisos (Julio 2026)

El módulo tenía **sólo `@RequireModule('ECOMMERCE')`**: cualquier usuario con la tienda activa podía confirmar pagos y cancelar pedidos. Se sembraron privilegios y se separaron los endpoints por sensibilidad:

- `ECOMMERCE_PEDIDOS`: `EC_PED_PEDIDO_VER`, `EC_PED_PAGO_CONFIRMAR`, `EC_PED_PEDIDO_PROCESAR`, `EC_PED_PEDIDO_CANCELAR`, `EC_PED_PEDIDO_ERP_CREAR`
- `ECOMMERCE_PAGOS`: `EC_PAG_PAGO_VER`, `EC_PAG_PAGO_SINCRONIZAR`
- `ECOMMERCE_TIENDA`: `EC_CFG_TIENDA_VER`, `EC_CFG_TIENDA_EDITAR`, `EC_CFG_TIENDA_PUBLICAR`
- `ECOMMERCE_NOVEDADES`: `EC_NOV_NOVEDAD_VER`, `EC_NOV_NOVEDAD_EDITAR`

Confirmar pago y cancelar viven en endpoints propios (`POST :id/confirmar-pago`, `POST :id/cancelar`) — si siguieran en `PATCH :id/estado`, quien pudiera "preparar" podría además dar por cobrado un pedido.

### Lo que queda pendiente (no bloquea el MVP)

- Bandejas separadas por rol (hoy: bandeja unificada con filtros; los permisos ya lo habilitan).
- Tracking con mapa Leaflet / bitácora de eventos.
- Fase 7.1 (B2B), 7.3 (analítica), 7.4 (dominios propios).

---

## Estado inicial (punto de partida)

### Lo que ya existe

| Componente | Estado |
|---|---|
| `novasis-ecommerce-store` | Tienda Next.js funcional con diseño completo hardcodeado en `defaultStoreConfig.ts` |
| Hero slider + aside + banners | Implementado, datos estáticos |
| Buscador con debounce + dropdown | Implementado, llama a `GET /api/v1/store/productos/search` vía readonly DB |
| Curtain loader + efecto cortina | Implementado |
| Lightbox de imágenes + galería | Implementado |
| Efectos hover de banners (11 efectos) | Implementados y validados en panel y tienda |
| Panel admin `Ecommerce.jsx` | Tabs presentes; solo `DesignPanel` tiene contenido real (estado local, sin API) |
| `store-public` module (backend) | `GET /api/v1/store/productos/search?q=&subdominio=` operativo con readonly DB |
| `empresas.subdominio` | Columna agregada, migración aplicada |
| `ecommerce_config` schema | Definido en `plan-ecommerce.md`, **aún no implementado** |

### Lo que está hardcodeado en la tienda

- Colores, tipografía, logo, nombre, slogan → `defaultStoreConfig.ts`
- Productos, categorías, marcas → arrays estáticos en `defaultStoreConfig.ts`
- Categorías del mega menú → mismo archivo
- Temporadas y overrides de tema → mismo archivo
- Promociones (topBar, hero, carousel) → mismo archivo
- Layout home (displayMode, columnas, pageSize) → mismo archivo
- Checkout config (delivery, términos, pago) → mismo archivo
- Footer y páginas de contenido → mismo archivo

---

## Principios de implementación

1. **El diseño actual de la tienda es el estándar** — los valores hardcodeados en `defaultStoreConfig.ts` son los defaults que se pre-cargan al crear una nueva config ecommerce.
2. **La tienda nunca tiene estado propio** — todo lo que se muestra viene de `GET /api/v1/ecommerce/public/config` resuelto por subdominio.
3. **El panel admin es la única fuente de verdad visual** — lo que se guarda/publica en `ecommerce_config` es lo que renderiza el storefront.
4. **Borrador vs publicado** — los cambios en el panel se guardan como borrador; el storefront solo consume la versión publicada.
5. **Reutilizar, no duplicar** — productos, categorías, stock, precios y ofertas vienen del ERP, no de tablas paralelas ecommerce.

---

## Fase 1 — Config pipeline: de panel a tienda

**Objetivo**: que la pestaña Diseño del panel guarde en DB y la tienda lo consuma. El diseño actual queda como default. Sin esto, nada de lo demás tiene sentido.

### 1.1 Backend — módulo `ecommerce`

**Tablas** (migración `20260619_ecommerce_config`):

```sql
CREATE TYPE ecommerce_estado AS ENUM ('borrador', 'activa', 'pausada');

CREATE TABLE ecommerce_config (
  id                   UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
  empresa_id           UUID NOT NULL REFERENCES empresas(id) ON DELETE CASCADE,
  subdominio           VARCHAR(80) NOT NULL,
  estado               ecommerce_estado NOT NULL DEFAULT 'borrador',
  nombre_comercial     VARCHAR(160),
  slogan               VARCHAR(255),
  logo_url             VARCHAR(500),
  favicon_url          VARCHAR(500),
  sucursal_id          UUID REFERENCES empresas_sucursales(id),
  deposito_id          UUID REFERENCES depositos(id),
  lista_precios_id     UUID REFERENCES lista_precios(id),
  moneda               VARCHAR(3) NOT NULL DEFAULT 'PYG',
  theme_config         JSONB NOT NULL DEFAULT '{}'::jsonb,
  layout_config        JSONB NOT NULL DEFAULT '{}'::jsonb,
  catalog_config       JSONB NOT NULL DEFAULT '{}'::jsonb,
  promotion_slots      JSONB NOT NULL DEFAULT '{}'::jsonb,
  seasonal_templates   JSONB NOT NULL DEFAULT '[]'::jsonb,
  checkout_config      JSONB NOT NULL DEFAULT '{}'::jsonb,
  footer_config        JSONB NOT NULL DEFAULT '{}'::jsonb,
  content_pages_config JSONB NOT NULL DEFAULT '[]'::jsonb,
  draft_config         JSONB,
  published_version_id UUID,
  published_at         TIMESTAMP,
  created_at           TIMESTAMP DEFAULT NOW(),
  updated_at           TIMESTAMP DEFAULT NOW(),
  UNIQUE (empresa_id),
  UNIQUE (subdominio)
);

CREATE TABLE ecommerce_config_version (
  id                      UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
  ecommerce_config_id     UUID NOT NULL REFERENCES ecommerce_config(id) ON DELETE CASCADE,
  empresa_id              UUID NOT NULL REFERENCES empresas(id) ON DELETE CASCADE,
  version                 INT NOT NULL,
  config_snapshot         JSONB NOT NULL,
  published_by            UUID REFERENCES usuario(id),
  published_at            TIMESTAMP DEFAULT NOW(),
  rollback_from_version   INT,
  UNIQUE (ecommerce_config_id, version)
);
```

**Módulo NestJS** `src/ecommerce/`:

```
src/ecommerce/
├── ecommerce.module.ts
├── admin/
│   ├── ecommerce-admin.controller.ts   # JWT + ModuleGuard ECOMMERCE
│   ├── ecommerce-admin.service.ts
│   └── dto/
│       ├── upsert-ecommerce-config.dto.ts
│       └── publish-config.dto.ts
└── public/
    ├── ecommerce-public.controller.ts  # AppTokenGuard
    ├── ecommerce-public.service.ts
    └── dto/
        └── public-config-response.dto.ts
```

**Endpoints admin** (JWT + `RequireModule('ECOMMERCE')`):

| Método | Endpoint | Descripción |
|---|---|---|
| GET | `/api/v1/ecommerce/config` | Config actual + borrador de la empresa |
| PUT | `/api/v1/ecommerce/config` | Guarda borrador (no publica) |
| POST | `/api/v1/ecommerce/config/publish` | Publica borrador → crea versión congelada |
| GET | `/api/v1/ecommerce/config/versions` | Lista versiones anteriores |
| POST | `/api/v1/ecommerce/config/rollback/:version` | Restaura versión publicada anterior |

**Endpoint público** (`AppTokenGuard`):

| Método | Endpoint | Descripción |
|---|---|---|
| GET | `/api/v1/ecommerce/public/config?subdominio=` | Config publicada del tenant; 404 si no existe o está pausada |

La respuesta de config pública incluye:
- `identity`: nombre, slogan, logo, favicon
- `theme`: colores, tipografía, background
- `layout`: displayMode, columnas, banner hover effect, home layout
- `catalog`: card size, image ratio, page size, show stock/brand/category
- `promotion_slots`: topBar, hero, carousel
- `seasonal_templates`: array versionado con overrides por fecha
- `checkout`: delivery, términos, provider label
- `footer`: links, contacto, redes
- `content_pages`: páginas editoriales

### 1.2 Panel admin — pestaña Diseño

Expandir `DesignPanel` para manejar toda la config visual (ya no solo banner hover):

**Secciones a agregar en Diseño:**

- **Identidad**: nombre comercial, slogan, logo, favicon (upload a DO Spaces)
- **Colores**: 8 pickers (primary, secondary, accent, background, surface, text, muted, success, danger)
- **Tipografía**: selector de fuente Google, escala (compact/normal/amplia), pesos
- **Efectos hover**: (ya existe) → conectar al borrador
- **Layout home**: displayMode (carouseles / grilla), hero position
- **Temporadas**: CRUD de templates con date pickers y overrides de colores

**Flujo de guardado:**
```
Usuario edita → onChange local → botón "Guardar borrador" → PUT /ecommerce/config
→ toast "Guardado como borrador" → chip [BORRADOR] en header del panel
→ botón "Publicar" → POST /ecommerce/config/publish → chip [PUBLICADO · v3]
```

**Datos iniciales**: al crear una config nueva (primera vez), el backend pre-carga los defaults del `defaultStoreConfig.ts` actual como valores iniciales del `theme_config` y `layout_config`.

### 1.3 Tienda — reemplazar `defaultStoreConfig` con API

Crear `src/lib/storeConfig.ts` en la tienda:

```ts
// Server-side: se llama en el Server Component raíz
export async function loadStoreConfig(subdominio: string): Promise<StoreConfig> {
  const res = await fetch(
    `${process.env.BACKEND_URL}/api/v1/ecommerce/public/config?subdominio=${subdominio}`,
    { headers: { 'x-app-key': process.env.STORE_APP_TOKEN! }, next: { revalidate: 60 } }
  );
  if (!res.ok) notFound(); // 404 si el subdominio no existe o está pausado
  return res.json();
}
```

En `app/page.tsx` (o layout raíz):
- Extraer subdominio de `headers().get('host')`
- Llamar `loadStoreConfig(subdominio)`
- Pasar config como prop a `StorePage`
- Eliminar `DEFAULT_STORE_CONFIG` del import y de `StorePage`

`defaultStoreConfig.ts` queda solo como contrato de tipos TypeScript y como fuente de los defaults que el backend pre-carga.

**Resultado de Fase 1**: cualquier cambio de color, tipografía o efecto hover publicado desde el panel se refleja en la tienda en menos de 60 segundos (Next.js ISR revalidate).

**Definición de done**:
- [ ] Migración aplicada, tablas creadas
- [ ] Endpoints admin y público operativos
- [ ] Panel guarda y publica colores + hover effect
- [ ] Tienda consume config desde API, no hardcode
- [ ] CSS variables aplicadas desde config dinámica

---

## Fase 2 — Catálogo desde el ERP

**Objetivo**: reemplazar `DEFAULT_PRODUCTS`, `DEFAULT_CATEGORIES` y `DEFAULT_FEATURED_CATEGORIES` con datos reales del ERP.

### 2.1 Backend — endpoints públicos de catálogo

Agregar a `ecommerce-public.controller.ts`:

| Método | Endpoint | Descripción |
|---|---|---|
| GET | `/api/v1/ecommerce/public/categorias/tree` | Árbol activo de categorías (hasta 3 niveles) |
| GET | `/api/v1/ecommerce/public/catalogo` | Productos con filtros: `categoria_id`, `include_descendants`, `marca_id`, `q`, `page`, `page_size`, `sort` |
| GET | `/api/v1/ecommerce/public/catalogo/:id` | Ficha pública: galería, descripción, especificaciones, precio lista, stock comercial, relacionados |
| GET | `/api/v1/ecommerce/public/marcas` | Marcas activas de la empresa (para filtros) |

Todos resuelven tenant por `subdominio` (header o query param).  
Todos usan la conexión readonly (`AiReadonlyDbService` o nueva conexión análoga).  
Precios: usar `lista_precios_id` de `ecommerce_config`; si no hay lista, usar `productos.precio` directo.

**Lógica de descendientes** (`include_descendants=true`):
```sql
WITH RECURSIVE cat_tree AS (
  SELECT id FROM categorias WHERE id = $categoria_id
  UNION ALL
  SELECT c.id FROM categorias c
  INNER JOIN cat_tree ct ON c.padre_id = ct.id
)
SELECT * FROM productos WHERE categoria_id IN (SELECT id FROM cat_tree)
```

### 2.2 Tienda — reemplazar datos estáticos

- `HomeCatalogSection.tsx`: consumir `/api/ecommerce/catalogo` (Next.js API route proxy)
- Mega menú en `StoreHeader.tsx`: consumir `/api/ecommerce/categorias/tree`
- `ProductDetailSection.tsx`: consumir `/api/ecommerce/catalogo/:id`
- `StoreSearchBar.tsx`: ya conectado a readonly DB — mantener, ya funciona

**Next.js API routes a crear/actualizar:**

```
src/app/api/ecommerce/
├── config/route.ts          → proxy GET /ecommerce/public/config
├── categorias/route.ts      → proxy GET /ecommerce/public/categorias/tree
├── catalogo/route.ts        → proxy GET /ecommerce/public/catalogo
├── catalogo/[id]/route.ts   → proxy GET /ecommerce/public/catalogo/:id
└── marcas/route.ts          → proxy GET /ecommerce/public/marcas
```

Todos pasan `x-app-key` y extraen `subdominio` del `Host` automáticamente.

### 2.3 Panel admin — pestaña Catálogo

Reemplazar `PlaceholderPanel` del tab Catálogo con una fachada real:

- **Categorías**: árbol visual con acciones para activar/desactivar en ecommerce
- **Productos**: tabla filtrable con columnas ecommerce (visible, destacado, stock, precio público)
- **Configuración de catálogo**: card size, display mode, columnas, page size, image ratio → guarda en `catalog_config` del borrador

**Definición de done**:
- [x] Catálogo real desde ERP (bootstrap: productos + categorías + destacadas en una llamada)
- [x] Fichas de producto con galería + especificaciones reales (endpoint detalle)
- [x] Carruseles por categoría y grilla con productos reales (la tienda filtra/pagina client-side)
- [x] Precio efectivo desde `lista_precios_id` de la config (fallback `productos.precio`)
- [x] Stock real desde `stock_deposito` (por depósito configurado o suma de todos)
- [x] Panel pestaña Catálogo: card size, display mode, columnas, page size, image ratio, toggles → `catalog_config`
- [x] Árbol de categorías multinivel en mega menú (raíz → hijos, clickeable a cualquier nivel)
- [x] Filtro por categoría padre incluye descendientes (vía `categoryPath` por producto)

### Notas de implementación Fase 2 (Junio 2026)

- **Endpoints** (en módulo `store-public`, readonly DB, x-app-key):
  - `GET /store/bootstrap?subdominio=&limit=` → `{ products, categories, featuredCategories }`
  - `GET /store/productos/:id?subdominio=` → ficha con galería + specs
  - (ya existía) `GET /store/productos/search?q=&subdominio=`
- **Tienda**: `src/lib/storeCatalog.ts` (`loadStoreCatalog`, ISR 60s) carga server-side; `StorePage` recibe prop `catalog`. `openProductDetail` enriquece con `/api/store/producto/[id]` si el id es UUID.
- **Cambio de tipo**: `Product.id` y `ProductReview.id` pasaron de `number` a `string` (UUIDs del ERP). `updateQuantity`/`onUpdateQuantity` ahora reciben `string`.
- **Árbol multinivel (implementado)**: `buildCategoryStructures()` arma el árbol desde `categorias.padre_id`, con conteo de subárbol y poda de ramas vacías. El bootstrap agrega `categoryPath` (nombres raíz→hoja) por producto y `categoryTree` anidado. La tienda filtra por cualquier nivel con `productMatchesCategory()` (nombre exacto o ruta). Mega menú renderiza raíz→hijos, todo clickeable. La empresa demo (Derlis) ya tenía jerarquía real de 3 niveles (16 raíces, ej: Electrodomésticos con 54 hijos).
- **Fallback**: si el backend falla, la tienda cae a `DEFAULT_PRODUCTS/CATEGORIES`. Un tenant válido pero vacío muestra catálogo vacío (correcto).
- **Demo data**: `novasis-demo` apunta a una empresa de prueba con 1 producto sin imagen/categoría. Para una demo rica se necesita repuntar a una empresa con catálogo poblado (requiere autorización del usuario) o cargar productos con categoría e imagen.

---

## Fase 3 — Diseño completo: colores, tipografía, layout builder

**Objetivo**: que el panel tenga control total sobre la apariencia visual. La tienda refleja cualquier cambio en ≤60s tras publicar.

### 3.1 Panel — expandir pestaña Diseño

Secciones nuevas en `DesignPanel` (todas conectadas a borrador):

**Colores** (`theme_config`):
- 9 color pickers con preview en tiempo real
- Modo claro/oscuro toggle
- Preset de paletas (ej: "Verde Novasis", "Azul corporativo", "Neutro")

**Tipografía** (`theme_config`):
- Selector de familia Google Fonts (lista curada: Inter, Poppins, Roboto, Playfair Display, etc.)
- Escala: compact / normal / amplia
- Preview de headings + body + price con la fuente seleccionada

**Layout home** (`layout_config`):
- Modo de catálogo: carruseles por categoría / grilla paginada
- Variante del hero: fotografía inmersiva / texto e imagen
- Cabecera: compacta / estándar, sticky opcional
- Fondo de secciones: uniforme / alternado
- Movimiento: none / subtle / dynamic; velocidad fast / normal / relaxed
- Columnas desktop/tablet/mobile (sliders 1–6)
- Mostrar/ocultar: stock, marca, categoría, badge de promoción, cuotas

**Card de producto** (`layout_config`):
- Tamaño: compact / normal / large
- Ratio de imagen: 1:1 / 4:3 / 3:4
- Efecto hover de card: lift / zoom / glow / none

**Buscador** (`layout_config`):
- Posición: header / hero / sidebar / sticky
- Min chars, sugerencias, búsquedas recientes

**Temporadas** (`seasonal_templates`):
- CRUD de templates con date pickers
- Override de colores por temporada
- Decoración: nieve / verano / primavera / otoño / ninguna
- Preview en tiempo real de cómo se ve la tienda con esa temporada activa

### 3.2 Tienda — consumir todos los campos de config

Actualizar `StorePage.tsx` para derivar todo de la config recibida:
- CSS variables: ya existe la lógica, solo cambiar la fuente de datos
- `displayMode`: ya existe el toggle, conectar al valor de config
- `seasonal_templates`: ya existe la lógica de temporadas, conectar al array de config
- Fuente tipográfica: cargar desde Google Fonts dinámicamente (Next.js font loader o link tag)
- Efecto hover de banners: pasar `layout_config.bannerHoverEffect` al `data-banner-effect` attr
- Animaciones de entrada al viewport con fallback sin movimiento y respeto de `prefers-reduced-motion`

### 3.3 Preview en panel

Agregar iframe embebido en el panel que apunte a `https://{subdominio}.novasis.com?preview=1&token={token}`:
- La tienda en modo `preview` consume el borrador en lugar del publicado
- Token de preview con TTL corto (15 min), generado desde el backend admin

**Definición de done**:
- [x] Colores editables en el panel (9 pickers + presets rápidos) → publicar → tienda ≤60s
- [x] Tipografía funcional end-to-end (Google Fonts dinámico)
- [x] Temporadas: editor CRUD completo (fechas, prioridad, colores, decoración, textos hero) → la tienda aplica la activa por fecha
- [x] Efecto hover de banners conectado
- [x] Hero inmersivo, presets de movimiento, fondos de sección, cabecera y hover de cards parametrizados
- [x] Preview iframe en el panel (implementado — `iframeRef` con auto-resize sobre `?preview=true`)

### Notas Fase 3 (Junio 2026)
- Panel Diseño: identidad, **6 presets de paleta**, 9 color pickers, fuente+escala, 11 efectos hover, y **editor de temporadas** (agregar/quitar, enable, fechas, prioridad, override de colores, decoración, topBar/heroTitle). Marca la temporada "Vigente hoy".
- La tienda ya consumía colores/fuente/temporadas vía el mapper de Fase 1; ahora son editables.
- Preview: la tienda acepta `?preview=true` (devuelve borrador); falta solo embeber el iframe en el panel — diferido.

---

## Fase 4 — Promociones y slots dinámicos

**Objetivo**: hero, topBar y banners del panel impulsan el contenido real de la tienda.

### 4.1 Backend — tablas y endpoints

Migración `ecommerce_novedad`:
- Ver schema completo en `plan-ecommerce.md` sección 3

Endpoints:

| Método | Endpoint | Descripción |
|---|---|---|
| GET/POST/PUT/DELETE | `/api/v1/ecommerce/novedades` | CRUD admin |
| GET | `/api/v1/ecommerce/public/novedades?ubicacion=hero` | Novedades activas por ubicación |

`promotion_slots` en `ecommerce_config`:
- `topBar`: texto + color + link
- `hero_slides`: array de slides (imagen, título, CTA, categoría o producto destino)
- `aside_promos`: hasta 2 cards del aside (imagen, título, link)
- `bottom_banners`: hasta 3 banners inferiores

### 4.2 Panel — pestaña Promociones

Reemplazar `OfertasTab` placeholder con panel real de slots:

- **Top bar**: editor de texto + color picker + toggle activo
- **Hero slides**: hasta 5 slides, cada uno con: imagen (upload/URL), título, subtítulo, CTA label + link
- **Aside promos**: 2 cards configurables
- **Bottom banners**: 3 banners con imagen y link
- **Novedades/Anuncios**: tabla CRUD con fecha desde/hasta, prioridad, tipo, imagen, CTA, segmento

### 4.3 Tienda — hero dinámico

El `HomeCatalogSection.tsx` actualmente construye los slides desde datos estáticos de productos.  
Después de Fase 4, los slides vienen de `promotion_slots.hero_slides` de la config + novedades activas del tipo `hero`.

**Definición de done**:
- [x] Top bar editable desde panel → refleja en tienda (`promotion_slots.topBar`)
- [x] Hero editable desde panel (título, subtítulo, imagen, badge, textos de oferta) → hero de la tienda
- [ ] Hero slides múltiples / aside / bottom banners como array (pendiente — por ahora un hero principal editable)
- [x] Novedades/anuncios con tabla `ecommerce_novedades` (implementado — modelo, service, controller y pestaña del panel)

### Notas Fase 4 (Junio 2026)
- Panel pestaña Promociones: editor de barra superior + banner hero (título, subtítulo, badge, imagen con preview, textos de oferta) → `promotion_slots`. Debajo, el `OfertasTab` existente (ofertas reales de inventario).
- Promoción de ingreso parametrizable en `promotion_slots`: activación, contenido, imagen, CTA, categoría destino, vigencia y frecuencia (`oncePerSession`, `oncePerDay`, `always`). El storefront controla la repetición por tenant/campaña y no interrumpe enlaces profundos.
- El catálogo por categorías limita las secciones visibles en portada mediante `catalog_config.maxCategoriesOnHome` (6 por defecto), manteniendo todas las categorías disponibles en navegación, filtros y catálogo paginado.
- El storefront incluye acciones flotantes reutilizables: volver arriba y acceso a WhatsApp cuando `footer.contact.whatsapp` está configurado.
- `footer_config` se administra íntegramente desde la pestaña Páginas: contacto, mapa, etiquetas visibles, CRUD de columnas/enlaces, medios de pago y redes sociales. Grupos, enlaces y redes soportan visibilidad individual sin intervención del desarrollador.
- La tienda ya consumía `promotions` (hero + topBar) vía el mapper de Fase 1; ahora son editables end-to-end.
- Hero slides múltiples, aside promos y bottom banners como arrays configurables quedan para una iteración posterior (hoy el hero principal es editable y la tienda arma slides combinando hero + categorías destacadas).

---

## Fase 5 — Checkout y pedidos

**Objetivo**: carrito validado en backend, pago Novasis Pay, pedido ERP creado.

### Estado Fase 5 (Junio 2026) — FLUJO MANUAL implementado y verificado

**✅ Implementado y verificado end-to-end (flujo de pago MANUAL)**:
- `POST /store/cart/validate` → recalcula precios reales + verifica stock, devuelve `{valid, lines, subtotal, issues}`.
- `POST /store/checkout` → valida carrito, calcula totales (subtotal + envío de `checkout_config.deliveryCost`), genera código `ECOM-YYYY-NNNN`, crea `ecommerce_checkout_session` (estado `pendiente_pago`). Verificado: ECOM-2026-0001 (delivery), ECOM-2026-0002 (pickup sin envío).
- `GET /store/checkout/:token` → estado público del pedido para el cliente.
- **Tabla `ecommerce_checkout_session`**: snapshot autocontenido (comprador, entrega, items validados, totales). No crea cliente/pedido ERP todavía — eso es la confirmación manual del operador (Fase 6).
- **Tienda**: formulario de checkout enlazado (nombre/email/teléfono/documento/dirección), `confirmCheckout()` → `/api/store/checkout`, muestra código real, limpia carrito.
- **Panel Configuración**: subdominio, estado (borrador/activa/pausada), y defaults de sucursal/depósito/lista de precios (`GET /ecommerce/config/opciones` sin exigir INVENTARIO).

**Decisión cliente invitado**: Opción A (cliente genérico "Consumidor Final – Ecommerce" por empresa) elegida para el flujo manual. La creación del `cliente`+`pedido` ERP se hará en la confirmación del operador (Fase 6), evitando crear registros malformados a ciegas (clientes exige `persona_id`+`tipo_operacion_id`).

**⛔ Diferido (no bloquea el MVP manual)**:
- **Pago online Novasis Pay**: el cliente `novasis-pay.client.ts` solo tiene `getProviders`/`testConnection`. Falta el contrato del endpoint de creación de pago + credenciales. Cuando esté, se agrega como `metodo_pago='novasis_pay'` además del manual.
- **Creación automática de pedido ERP**: requiere resolver el cliente genérico + DTO de pedido completo + reserva de stock vía `PedidosService.confirmar()`. Se implementa en Fase 6 (confirmación del operador).

### 5.1 Backend

Migración `ecommerce_checkout_session` — ver schema en `plan-ecommerce.md`.

Endpoints:

| Método | Endpoint | Descripción |
|---|---|---|
| POST | `/api/v1/ecommerce/public/checkout/session` | Valida carrito, crea sesión y genera intención de pago |
| POST | `/api/v1/ecommerce/public/webhook/novasis-pay` | Pago aprobado → crea pedido confirmado (idempotente) |
| GET | `/api/v1/ecommerce/public/checkout/session/:token` | Estado de la sesión de checkout |

**Validaciones al crear sesión:**
1. Subdominio activo y ecommerce en estado `activa`
2. Todos los productos activos, no eliminados, con stock suficiente
3. Recalcular precios desde `lista_precios_id` de la config
4. Calcular totales con impuestos (IVA según `productos.porcentaje_iva`)
5. Crear intención de pago en Novasis Pay
6. Guardar snapshot del carrito, cliente, precios y totales

**Al aprobar pago (webhook):**
1. Verificar firma de Novasis Pay
2. Marcar `ecommerce_checkout_session.estado = 'pago_aprobado'`
3. Llamar `PedidosService.create()` con `origen_tipo = 'ecommerce'`
4. Confirmar pedido con `PedidosService.confirmar()` → reserva stock
5. Marcar sesión `estado = 'pedido_creado'`
6. Si reserva falla por stock → `estado = 'error'` + gestión manual

### 5.2 Tienda — checkout conectado

Reemplazar el checkout mock actual:
- `CartDrawerSection`: validar stock antes de proceder (llamar a `/api/ecommerce/catalogo` para stock actual)
- `CheckoutSection`: POST al backend, redirigir a Novasis Pay URL
- Página de resultado: polling a `/api/ecommerce/checkout/session/:token` hasta estado `pedido_creado` o `error`

**Definición de done** — ✅ COMPLETA (Julio 2026):
- [x] Checkout valida stock en backend (`validateCart`) y además lo **reserva** (Fase 7.5)
- [x] Pago Novasis Pay funcional end-to-end (intent + hosted checkout + webhook + sync lazy + reconciliación manual desde el panel)
- [x] Pedido creado en ERP (se identifica por `referencia_interna = ECOM-YYYY-NNNN`; `pedidos` no tiene `origen_tipo`)
- [x] Stock reservado al confirmar pedido (y desde el checkout, con TTL)
- [x] Idempotencia de webhook verificada (`event_id` único + guard por `pedido_id` en la sesión)

---

## Fase 6 — Operación post-compra

**Objetivo**: el equipo operativo gestiona pedidos ecommerce desde bandejas por rol en el panel ERP.

### 6.1 Backend

Migración `ecommerce_pedido_tracking` + `ecommerce_pedido_evento` — ver schema en `plan-ecommerce.md`.

Endpoints operativos (JWT + permiso `ECOMMERCE` + subpermiso por rol):

- `GET /api/v1/ecommerce/pedidos` — bandeja general con filtros
- `GET /api/v1/ecommerce/pedidos/:id` — ficha completa
- `PATCH /api/v1/ecommerce/pedidos/:id/verificacion`
- `PATCH /api/v1/ecommerce/pedidos/:id/empaque`
- `PATCH /api/v1/ecommerce/pedidos/:id/despacho`
- `POST /api/v1/ecommerce/pedidos/:id/eventos`
- `GET /api/v1/ecommerce/public/tracking/:token` — estado público por token

### 6.2 Panel — pestañas operativas

Reemplazar los `PlaceholderPanel` actuales:

**Verificación**: bandeja de pedidos `recibido` / `en_verificacion`. Ficha con datos del cliente, pago, dirección, stock disponible. Botones: Verificar / Incidencia.

**Empaque**: bandeja de pedidos `verificado` / `preparando`. Lista de productos a preparar. Marcar como empaquetado, faltantes, listo para retiro/despacho.

**Despacho**: bandeja de pedidos `empaquetado`. Asignar repartidor, guía, paso a `en_reparto` o `entregado`.

**Seguimiento**: vista de timeline por pedido + mapa Leaflet (opcional, flag de config).

**Pedidos**: tabla unificada con todos los estados + filtros avanzados.

### 6.3 Tienda — seguimiento público

Página `/seguimiento` con input de token → llama a `/api/ecommerce/tracking/:token` → muestra estado, historial y mapa si está habilitado.

**Definición de done**:
- [x] Pedido del checkout aparece en la bandeja del panel (pestaña Pedidos)
- [x] Máquina de estados con transiciones validadas y ramas independientes para retiro y delivery.
- [x] Confirmación de pago manual registra `confirmado_por` + `confirmado_at`
- [x] Endpoint público de seguimiento por token (`GET /store/checkout/:token` + proxy `/api/store/checkout/[token]`)
- [x] Crear pedido ERP + reserva de stock al confirmar (implementado; en la auditoría de Julio se corrigió que el pago **online** no lo disparaba)
- [ ] Vistas guardadas por rol sobre la bandeja unificada. Los privilegios `EC_PED_*` ya separan quién confirma pagos de quién procesa pedidos.
- [ ] Tracking con mapa Leaflet (diferido a Fase 7+).

### Estado Fase 6 (Junio 2026) — bandeja operativa implementada y verificada

**✅ Implementado y verificado**:
- Estados operativos agregados al enum `ecommerce_checkout_estado` (migración 20260620): `preparando`, `listo`, `entregado`.
- Backend `src/ecommerce/pedidos/` (JWT + `@RequireModule('ECOMMERCE')`):
  - `GET /ecommerce/pedidos?estado=&q=` → bandeja + conteo por estado (`resumen`)
  - `GET /ecommerce/pedidos/:id` → ficha (cliente, entrega, items, totales, próximos estados)
  - `PATCH /ecommerce/pedidos/:id/estado` → avanza estado validando transiciones
- Panel pestaña **Pedidos**: lista filtrable por estado con conteos, ficha en diálogo (cliente/entrega/items/totales) y botones de acción según el estado (Confirmar pago, Iniciar preparación, Marcar listo, Marcar entregado, Cancelar).
- Verificado: ECOM-2026-0001 recorrió el flujo completo pendiente_pago → … → entregado; transiciones inválidas bloqueadas.

**✅ Creación de pedido ERP al confirmar pago (Junio 2026)** — implementado y verificado:
- Al pasar a `pago_confirmado`, se crea el pedido ERP best-effort: resuelve/crea el cliente genérico "Consumidor Final - Ecommerce" (persona + cliente tipo B2C), arma el pedido con los defaults de la config (sucursal/depósito/lista), items con `precio_negociado` bloqueado al precio del checkout, `referencia_interna = ECOM-YYYY-NNNN` y datos del comprador en observaciones. Llama `PedidosService.create()` + `confirmar()` → **reserva stock**. Vincula `session.pedido_id`.
- **Idempotente**: si la sesión ya tiene `pedido_id`, no duplica.
- **Best-effort**: si falla (ej. stock), el pago queda confirmado y el operador reintenta con `POST /ecommerce/pedidos/:id/crear-pedido-erp` (botón "Crear pedido ERP" en el panel).
- Verificado: ECOM-2026-0002 → pedido `PED-0000007` confirmado, cliente genérico, precio bloqueado, stock reservado (cantidad_reservada: 1). Reintento devuelve el mismo pedido.
- `EcommerceModule` importa `PedidosModule` (exporta `PedidosService`). `pedidos` no tiene `origen_tipo`; se identifica por `referencia_interna`.

**Extensión de workflow parametrizable (Julio 2026)**:

- `ecommerce_config.workflow_config` guarda preset, pasos opcionales y política de cancelación por empresa.
- Cada `ecommerce_checkout_session` congela `workflow_snapshot` al crearse para evitar que un cambio de configuración modifique pedidos abiertos.
- Retiro: `pago_confirmado` → pasos opcionales → `preparando` → `listo_retiro` → `retirado`.
- Delivery: `pago_confirmado` → pasos opcionales → `preparando` → `listo_despacho` → `en_camino` opcional → `entregado`.
- La transición a `en_verificacion` es atómica y registra el operador asignado; dos verificadores no pueden tomar el mismo pedido.
- Toda transición registra un evento de auditoría con estado anterior, estado nuevo, usuario y tipo de entrega.
- Barreras fijas impiden mezclar estados de retiro/delivery, preparar antes del pago o avanzar desde un estado final.

**Diferido**: tracking con mapa y facturación automática desde el pedido (el pedido confirmado ya se puede facturar con el flujo estándar del ERP que vincula `factura_cab.pedido_id`).

---

## Fase 7 — B2B, SEO, analítica y escala

**Objetivo**: capacidades avanzadas para empresas que maduran en ecommerce.

### 7.1 B2B (login cliente)

- Tabla `ecommerce_cliente_session`
- Endpoint `POST /api/v1/ecommerce/public/auth/cliente` — login por email + código SMS/email
- Lista de precios B2B por cliente (`lista_precios_id` en `clientes`)
- Historial de pedidos en la tienda (requiere sesión)
- "Repetir pedido" desde historial

### 7.2 SEO

- `app/[subdominio]/page.tsx` → metadata dinámica desde `ecommerce_config.seo_*`
- Rutas `/categoria/[slug]`, `/producto/[slug]` con SSR y metadata específica
- `sitemap.xml` generado dinámicamente por tenant
- OpenGraph y Twitter card con imagen del producto/categoría

### 7.3 Analítica

- Tabla `ecommerce_evento_analitica`
- Eventos desde tienda: `page_view`, `product_view`, `add_to_cart`, `checkout_start`, `purchase`
- Dashboard en panel: funnel, conversión, productos más vistos, ingresos por período

### 7.4 Dominios propios

- Agregar campo `dominio_custom` a `ecommerce_config`
- Configuración de DNS CNAME en Vercel/Nginx
- Resolución de tenant por `Host` completo si no matchea patrón wildcard

### 7.5 Stock reserva pre-pago — ✅ IMPLEMENTADA (Julio 2026)

Cierra la ventana de oversell: antes el stock se validaba en el checkout pero se
reservaba recién al confirmar el pago. En esa ventana (segundos con pago online,
horas o días con transferencia) el POS u otro pedido podía llevarse el stock.

- **Tabla `ecommerce_stock_reserva`** (migración `20260712_ecommerce_stock_reserva`).
- **`EcommerceStockReservaService`**:
  - `reservar()` — atómico con `SELECT ... FOR UPDATE` sobre `stock_deposito`. Sin
    ese lock, dos checkouts simultáneos por la última unidad la reservaban ambos.
    Si una línea no alcanza, libera **todo** y rechaza (no deja stock a medias).
  - `liberar()` — devuelve el stock (con `GREATEST(0, ...)` para que un descuadre
    previo no deje la reserva en negativo). Idempotente.
  - `expirarVencidas()` — expira sesiones y libera lo vencido.
- **Cron** `@Cron(EVERY_MINUTE)` con guard de reentrada (no BullMQ: `ScheduleModule`
  ya estaba en uso y el query es barato gracias al índice `(liberada, expira_at)`).
- **TTL** 15 min por defecto, configurable con `checkout_config.reservaMinutos`.

**Ciclo de vida**:

```
checkout          → reservar (TTL)
pago confirmado   → liberar → PedidosService.confirmar() re-reserva a nombre del pedido ERP
cancelado         → liberar
TTL vencido       → cron: sesión a 'expirado' + liberar
```

El paso clave es el **traspaso**: hay que liberar ANTES de `confirmar()`, si no la
misma mercadería queda reservada dos veces.

**Degradación segura**: si la config del ecommerce no define depósito, no se puede
reservar con precisión → no se reserva y se loguea un warning.

---

## Resumen de dependencias entre fases

```
Fase 1 (Config pipeline)
  └── Fase 2 (Catálogo desde ERP)
        └── Fase 3 (Diseño completo)
              └── Fase 4 (Promociones dinámicas)
                    └── Fase 5 (Checkout y pedidos)
                          └── Fase 6 (Operación post-compra)
                                └── Fase 7 (B2B / SEO / Escala)
```

Fase 1 desbloquea todo. Sin config pipeline, la tienda sigue siendo estática.  
Fases 2–4 pueden avanzar en paralelo una vez que Fase 1 está completa.  
Fase 5 requiere Fase 2 (para recalcular precios reales) y Fase 1 (para saber lista de precios y config checkout).

---

## Tablas DB por fase

| Fase | Tablas nuevas |
|---|---|
| 1 | `ecommerce_config`, `ecommerce_config_version` |
| 2 | (ninguna — reutiliza `productos`, `categorias`, `marcas`, `producto_imagenes`) |
| 3 | (ninguna — extiende `ecommerce_config.theme_config` y `layout_config`) |
| 4 | `ecommerce_novedad` |
| 5 | `ecommerce_checkout_session` |
| 6 | `ecommerce_pedido_tracking`, `ecommerce_pedido_evento` |
| 7 | `ecommerce_cliente_session`, `ecommerce_evento_analitica`, `ecommerce_stock_reserva` |

---

## Variables de entorno requeridas por fase

### Backend (`.env`)

```env
# Fase 1
# (ninguna nueva — usa las existentes: DATABASE_URL, AI_READONLY_DATABASE_URL, APP_PUBLIC_TOKEN)

# Fase 5
NOVASIS_PAY_WEBHOOK_SECRET=...
NOVASIS_PAY_API_URL=...
NOVASIS_PAY_API_KEY=...
```

### Tienda (`novasis-ecommerce-store/.env`)

```env
# Ya configurado (Fase 0 / store-public)
BACKEND_URL=http://localhost:3000
STORE_APP_TOKEN=smartfacvoice-app
STORE_SUBDOMINIO_DEV=empresa1   # solo desarrollo local

# Fase 5
NOVASIS_PAY_RETURN_URL=https://{subdominio}.novasis.com/checkout/resultado
```

---

## Checklist de Fase 1 — ✅ COMPLETADA (Junio 2026)

- [x] Crear migración `20260619_ecommerce_config` y aplicar
- [x] Crear `src/ecommerce/ecommerce.module.ts` con admin + public controllers
- [x] Implementar `GET/PUT /ecommerce/config` (admin, con guard ECOMMERCE)
- [x] Implementar `POST /ecommerce/config/publish` con snapshot versionado
- [x] Implementar `GET /ecommerce/public/config?subdominio=` (AppTokenGuard)
- [x] Seed de config inicial desde defaults del `defaultStoreConfig.ts` (extraído a `src/ecommerce/default-store-config.ts`)
- [x] Expandir `DesignPanel` en `Ecommerce.jsx`: identidad + colores + fuente + efectos → PUT borrador
- [x] Agregar botones "Guardar borrador" + "Publicar" en el panel
- [x] Crear `src/lib/storeConfig.ts` en la tienda con `loadStoreConfig()` (resuelve subdominio por Host)
- [x] Actualizar `app/page.tsx` y `(public)/store/page.tsx` para pasar config dinámica a `StorePage`
- [x] `StorePage` config-driven (`DEFAULT_STORE_CONFIG` solo como tipo + fallback)
- [x] Endpoints adicionales: `GET /config/versions`, `POST /config/rollback/:version`
- [x] Módulo `ECOMMERCE` agregado al seed de módulos
- [x] Verificado end-to-end: publicar config con color/fuente custom → endpoint público devuelve `StoreConfig` resuelto por subdominio; preview=draft; pausada=404; sin app-key=401

### Notas de implementación Fase 1

- **Mapeo JSONB**: `theme_config = { theme, typography }`, `layout_config = { layout, search }`, el resto de bloques 1:1. El helper `ecommerce-config.mapper.ts` ensambla el `StoreConfig` plano que consume la tienda, con fallback al diseño estándar por bloque vacío.
- **Subdominio**: se sincroniza en `empresas.subdominio` y `ecommerce_config.subdominio` al guardar borrador. La resolución de tenant usa `ecommerce_config.subdominio`.
- **ISR**: la tienda cachea la config 60s (`next: { revalidate: 60 }`).
- **Pendiente menor**: el panel todavía no edita footer, content pages ni temporadas (se hará en Fase 3); esos bloques se publican con el default sembrado.
