# Consolidar la configuración del taller en `taller_parametros` — Diseño

Fecha: 2026-09-23
Tiquete: Freshdesk #24482 (interno, no se cobra al cliente)
Relacionado: #24483 (facturación POS dividida producto/servicio — este diseño deja el
esquema preparado para eso, no lo implementa)

## 1. Contexto y objetivo

Hoy la configuración del módulo de talleres vive repartida en tres lugares sin
criterio común, y ninguno tiene pantalla de administración:

1. `tenant_config` (base de control): tres toggles operativos
   (`permite_tiendas`, `permite_productos_propios`, `factura_habilitada`) que **no
   bloquean nada** — sus únicos consumidores son el checklist operativo y el gate de
   `activarTenant` en `tenants-admin.service.ts:341-347`. `POST /auth/tiendas` y
   `POST /productos` funcionan igual con el toggle apagado.
2. Ocho columnas de WhatsApp en `tenant_config`, agregadas por
   `AddOperationalTogglesToTenantConfig`/migraciones previas, que **nadie lee**:
   `WhatsappService` lee `WHATSAPP_*` de variables de entorno de proceso vía
   `ConfigService` (`whatsapp.service.ts:255-268`), lo que en la práctica significa
   que WhatsApp solo puede estar configurado para **un** tenant a la vez — es un bug
   de producción, no una migración pendiente.
3. `taller_facturacion_pos_*` en `maetie` (`auth/entities/tienda.entity.ts:58-77`):
   correcto en que es por tienda, pero sin pantalla dedicada — el único punto de
   edición es un diálogo enterrado en `tiendas/page.tsx`.

Objetivo: una tabla `taller_parametros` en la base del tenant, con su servicio
tipado y su pantalla, como única fuente de configuración del módulo de talleres.
Los toggles bloquean de verdad. WhatsApp deja de depender de variables de entorno
por completo. La config de facturación POS se administra desde un solo lugar.

## 2. Alcance

**Entra:** tabla + servicio + catálogo tipado, migración de tabla + backfill de
`maetie`, puente de lectura perezosa para los 3 toggles y el histórico de WhatsApp,
gating real de los 3 toggles, sonda de conexión POSTouch, pantalla de
configuración, historial de cambios, validación derivada del catálogo,
script de siembra de WhatsApp para el cliente que ya lo usa.

**No entra:** validación de inventario en sí (solo el parámetro, sin consumidor —
ver §7.4), envío de facturación dividido por destino (#24483, aunque el esquema lo
soporta desde ahora — ver §3.3), exportar/importar configuración entre tenants
(evaluado y descartado por inflar el alcance; candidato a tiquete separado), borrar
las columnas viejas de `tenant_config`/`maetie` (tiquete futuro, después de
verificar en producción que nada las toca).

## 3. Esquema: `taller_parametros`

Tabla nueva en la base del **tenant** (no en `control-plane/migrations/`):

```
id             int PK auto_increment
clave          varchar(100) NOT NULL   -- clave del catálogo, ej. "facturacion.usa_facturacion"
ambito         enum('tenant','tienda') NOT NULL
tienda_id      int NOT NULL DEFAULT 0  -- 0 = no aplica (ámbito tenant)
destino        varchar(32) NOT NULL DEFAULT ''  -- '' = destino único (ver 3.3)
tipo_dato      enum('boolean','string','number','json') NOT NULL
valor          text NULL         -- valores no sensibles, siempre serializados a string
valor_cifrado  text NULL         -- CredentialsCryptoService, para claves sensibles
created_at     timestamp DEFAULT CURRENT_TIMESTAMP
updated_at     timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
UNIQUE KEY (clave, tienda_id, destino)
```

### 3.1 Por qué `tienda_id`/`destino` nunca son `NULL`

MySQL trata cada `NULL` como distinto de cualquier otro `NULL` dentro de un índice
`UNIQUE` — dos filas de ámbito tenant (`tienda_id=NULL`) para la misma `clave` NO
violarían `UNIQUE(clave, tienda_id, destino)` si esas columnas fueran nullable. Los
valores centinela (`0` / `''`) hacen que la unicidad la garantice la base de datos,
no una convención de la capa de aplicación.

### 3.2 Por qué `tipo_dato` es columna, no solo metadato del catálogo

El catálogo tipado (§4) ya conoce el tipo de cada clave — esta columna es
redundante para la aplicación misma. Se mantiene porque el tiquete la pide
explícitamente y porque hace la tabla auto-descriptiva para cualquiera que la
consulte directo por soporte, sin tener que cruzar contra el código. El servicio
valida en cada lectura que coincida con el catálogo; un desajuste (alguien editó la
fila a mano) es una señal de un problema real, no un caso a tolerar en silencio.

### 3.3 `destino`: preparado para #24483, sin implementarlo

Hoy toda fila de `facturacion_pos.*` de una tienda tiene `destino=''`
("el destino único de esta tienda"). #24483 va a necesitar que una tienda tenga
un destino de facturación para productos y otro para servicios — cuando eso pase,
simplemente agrega filas con `destino='productos'`/`destino='servicios'`, sin
migración de esquema ni reescritura de este diseño. No se implementa el envío
dividido acá: `FacturacionPosService` sigue leyendo el destino único
(`destino=''`) tal como hoy.

## 4. Catálogo tipado y `TallerParametrosService`

### 4.1 Catálogo (`backend/src/taller-parametros/taller-parametros.catalogo.ts`)

Objeto único, tipado como `const`, con una entrada por clave:

```ts
export const TALLER_PARAMETROS_CATALOGO = {
  "facturacion.usa_facturacion": {
    ambito: "tenant", tipo: "boolean", default: true, grupo: "facturacion",
    etiqueta: "Usar facturación automática",
    descripcion: "Si está apagado, ninguna OT terminada intenta enviarse a POSTouch.",
    sensible: false, comportamientoAnteFallo: "bloquear",
  },
  "inventario.usa_validacion": {
    ambito: "tenant", tipo: "boolean", default: false, grupo: "inventario",
    etiqueta: "Validar existencias antes de facturar",
    descripcion: "Reservado — hoy no hay ninguna verificación de inventario en el código.",
    sensible: false,
  },
  "productos.permite_crear": {
    ambito: "tenant", tipo: "boolean", default: true, grupo: "productos",
    etiqueta: "Permitir crear productos desde el taller",
    descripcion: "Si está apagado, el catálogo local queda de solo lectura.",
    sensible: false, comportamientoAnteFallo: "permitir",
  },
  "tiendas.permite_crear": {
    ambito: "tenant", tipo: "boolean", default: true, grupo: "tiendas",
    etiqueta: "Permitir crear tiendas desde el taller",
    descripcion: "Para tenants sin ERP/POSTouch real detrás.",
    sensible: false, comportamientoAnteFallo: "bloquear",
  },
  "notificaciones.usa_whatsapp": {
    ambito: "tenant", tipo: "boolean", default: false, grupo: "notificaciones",
    etiqueta: "Enviar notificaciones por WhatsApp",
    descripcion: "Si está apagado, los demás campos de WhatsApp no se piden ni se muestran.",
    sensible: false, comportamientoAnteFallo: "bloquear",
  },
  "notificaciones.whatsapp_phone_number_id": { ambito: "tenant", tipo: "string", grupo: "notificaciones", sensible: false, requeridoSi: "notificaciones.usa_whatsapp" },
  "notificaciones.whatsapp_access_token":    { ambito: "tenant", tipo: "string", grupo: "notificaciones", sensible: true,  requeridoSi: "notificaciones.usa_whatsapp" },
  "notificaciones.whatsapp_api_version":     { ambito: "tenant", tipo: "string", grupo: "notificaciones", sensible: false, default: "v25.0" },
  "notificaciones.whatsapp_template_cotizacion": { ambito: "tenant", tipo: "string", grupo: "notificaciones", sensible: false },
  "notificaciones.whatsapp_template_estado":     { ambito: "tenant", tipo: "string", grupo: "notificaciones", sensible: false },
  "notificaciones.whatsapp_template_language":   { ambito: "tenant", tipo: "string", grupo: "notificaciones", sensible: false, default: "es_GT" },
  "notificaciones.whatsapp_default_country_code":{ ambito: "tenant", tipo: "string", grupo: "notificaciones", sensible: false, default: "502" },

  "facturacion_pos.habilitada":       { ambito: "tienda", tipo: "boolean", default: false, grupo: "facturacion", sensible: false },
  "facturacion_pos.url":              { ambito: "tienda", tipo: "string",  grupo: "facturacion", sensible: false, requeridoSi: "facturacion_pos.habilitada" },
  "facturacion_pos.username":         { ambito: "tienda", tipo: "string",  grupo: "facturacion", sensible: false, requeridoSi: "facturacion_pos.habilitada" },
  "facturacion_pos.password":         { ambito: "tienda", tipo: "string",  grupo: "facturacion", sensible: true,  requeridoSi: "facturacion_pos.habilitada" },
  "facturacion_pos.computadora":      { ambito: "tienda", tipo: "string",  grupo: "facturacion", sensible: false, requeridoSi: "facturacion_pos.habilitada" },
  "facturacion_pos.precio_decimales": { ambito: "tienda", tipo: "number",  grupo: "facturacion", sensible: false, requeridoSi: "facturacion_pos.habilitada" },
} as const;

export type TallerParametroClave = keyof typeof TALLER_PARAMETROS_CATALOGO;
```

Un typo en una clave es un error de compilación en cualquier call site (uso de
`keyof typeof CATALOGO`), no un `undefined` en producción.

### 4.2 Dos superficies de acceso

`ProductosService`, `FacturacionPosService` y `WhatsappService` corren bajo
`TenantGuard` (contexto ambiental ya establecido — confirmado leyendo sus
controladores/consumidores). `AuthService` (rutas `/auth/tiendas*`) **no** tiene
`TenantGuard` — resuelve `tenantId` desde el JWT y hoy ya bypassea el contexto
ambiental para `Tienda`/`Usuario` (`auth.service.ts`, helper `withTenantRepo`).
`control-plane-admin` inspecciona tenants antes de `Activar`, sin contexto de
tenant tampoco.

Por eso `TallerParametrosService` expone dos familias de métodos sobre el mismo
núcleo de casteo/validación:

- **Ambiental** — `get(clave)`, `getParaTienda(tiendaId, clave, destino?)`,
  `set(clave, valor, usuarioId)`, `setParaTienda(...)` — vía
  `TenantRepositoryAccessor.repositoryFor(TallerParametro)`. Falla cerrado si no
  hay contexto (mismo comportamiento que el resto del código convertido).
- **Explícita** — `getExplicit(tenantId, clave)`,
  `getParaTiendaExplicit(tenantId, tiendaId, clave, destino?)`, más sus
  contrapartes de escritura — vía
  `TenantConnectionRegistry.withTenantRepositoryExplicit`. Para
  `control-plane-admin` se llama con `{ allowOnboarding: true }` y try/catch que
  devuelve `null` si la tabla todavía no existe (mismo patrón que
  `contarSeguro` en `getResumenOperativo`), para no romper el checklist de un
  tenant que aún no corrió esta migración.

Ningún otro punto del código importa `TallerParametro` ni construye un
`Repository<TallerParametro>` por su cuenta.

### 4.3 Caché

`Map<tenantId, TallerParametro[]>` en memoria dentro del servicio. Se llena en la
primera lectura de ese tenant y se invalida por completo (no por clave) en
cualquier escritura de ese tenant — sin TTL, la invalidación explícita en escritura
es exacta y no necesita expirar sola.

El valor resuelto por el puente de §6.3 (leído de `tenant_config`) entra a esta
misma caché de inmediato, en el momento de resolverlo — no solo si la escritura
de respaldo hacia `taller_parametros` tiene éxito. Así, si esa escritura falla,
la siguiente lectura del mismo tenant dentro de la vida de esta caché sirve desde
memoria y no vuelve a consultar el plano de control; ver detalle en §6.3.

### 4.4 Validación derivada del catálogo

`requeridoSi` en una entrada del catálogo apunta a otra clave booleana del mismo
grupo/ámbito: al escribir, el servicio exige que la clave dependiente tenga un
**valor efectivo** no vacío si la condición es verdadera.

Valor efectivo, no valor del payload: una escritura es un PATCH parcial (mismo
criterio que `updateTiendaFacturacionPos` ya usa hoy sobre `maetie`), así que el
validador arma primero el estado combinado — la fila ya guardada en
`taller_parametros` para esa clave, sobrescrita por lo que sí vino en este PATCH —
y valida sobre ESE estado, nunca sobre el payload crudo. Esto es necesario para
password (donde ausente/vacío siempre significa "no cambiar", nunca "borrar"),
pero aplica igual a cualquier otro campo `requeridoSi`: editar una tienda ya
configurada para solo cambiar `computadora`, sin reenviar `url`/`username`, tiene
que seguir pasando la validación porque esos valores ya están guardados.

Esto cubre tanto "si `facturacion_pos.habilitada`, entonces url/username/
password/computadora/precioDecimales son obligatorios" como el mismo patrón para
WhatsApp — una sola implementación genérica en el servicio, cero reglas
duplicadas en el DTO o el formulario. El DTO del endpoint de escritura llama al
mismo validador antes de persistir; el formulario del frontend implementa la
misma regla (mismo campo `requeridoSi` expuesto por un endpoint de metadata) para
dar el error antes del submit, pero el backend es quien de verdad la hace
cumplir.

### 4.5 Fail-closed / fail-open, decidido por clave

Solo aplica a las claves que gatean una acción (no a las cosméticas, que
simplemente muestran un estado de carga/error en pantalla si la tabla no
responde):

| Clave | Si `taller_parametros` no responde |
|---|---|
| `facturacion.usa_facturacion` | **bloquear** — no se envía a POSTouch |
| `tiendas.permite_crear` | **bloquear** — no se crea la tienda (deshacer una tienda ya creada, con su mapeo de caja en POSTouch, es más costoso que reintentar) |
| `productos.permite_crear` | **permitir** — el peor caso es una fila de catálogo local de más; bloquear trabajo cotidiano por un problema de infraestructura no relacionado es peor |
| `notificaciones.usa_whatsapp` | **bloquear** — forzado de todas formas, sin lectura exitosa no hay credenciales que usar |

`GET /taller-parametros/flags` (§7.5) reutiliza esta misma política por clave —
no hay una regla aparte para ese endpoint.

## 5. Historial de cambios

`auditoria.entity.ts` no encaja: exige `identidad_id` no nulo (identidad de
plataforma) y vive en la base de control; quien cambia un parámetro del taller es
un `Usuario` local del tenant. Tabla nueva en la base del tenant,
`taller_parametros_historial`:

```
id             int PK auto_increment
clave          varchar(100) NOT NULL
ambito         enum('tenant','tienda') NOT NULL
tienda_id      int NOT NULL DEFAULT 0
destino        varchar(32) NOT NULL DEFAULT ''
valor_anterior text NULL   -- NULL siempre para claves `sensible: true`
valor_nuevo    text NULL   -- NULL siempre para claves `sensible: true`
usuario_id     int NOT NULL   -- Usuario.id de este mismo tenant
created_at     timestamp DEFAULT CURRENT_TIMESTAMP
```

Denormalizada a propósito (clave/ámbito/tienda/destino en la fila, no una FK a
`taller_parametros.id`) para que el historial siga siendo legible aunque la fila
de configuración se reescriba. Se registra únicamente en las escrituras
explícitas hechas por un usuario a través de la pantalla/API de configuración
(`set`/`setParaTienda` con `usuarioId` real) — el puente de lectura perezosa
(§6.3) reubica almacenamiento, no representa una decisión de nadie, y no genera
entrada de historial.

## 6. Migraciones y transición de datos

### 6.1 Migración de tabla (base del tenant)

`backend/src/migrations/1780700000000-CreateTallerParametros.ts`: crea
`taller_parametros` y `taller_parametros_historial` con
`queryRunner.createTable(new Table(...), true)` (idempotente, mismo patrón que
`CreateTallerProductos`), `down` hace `dropTable(name, true)`.

### 6.2 Backfill de `maetie.taller_facturacion_pos_*`

Dentro de la misma migración (misma base de datos, una sola conexión): por cada
fila de `maetie` con alguna columna `taller_facturacion_pos_*` no nula/no cero,
inserta las filas correspondientes de `taller_parametros` (`ambito='tienda'`,
`tienda_id=maetie.tienda`, `destino=''`). La contraseña cifrada se copia tal cual
— nunca se descifra ni se re-cifra en la migración.

### 6.3 Puente perezoso para los 3 toggles de `tenant_config`

`TallerParametrosService` recibe `TenantConfigService` (control-plane) inyectado.
Para las 3 claves `facturacion.usa_facturacion` / `productos.permite_crear` /
`tiendas.permite_crear`: si no hay fila en `taller_parametros` para el tenant,
lee el valor de `tenant_config` y lo devuelve de inmediato — la persistencia de la
copia hacia `taller_parametros` corre **fuera** del camino de la respuesta:

```ts
this.persistirBridgeToggle(tenantId, clave, valor).catch((err) =>
  this.logger.warn(`No se pudo persistir el puente de ${clave} para tenant ${tenantId}: ${err?.message}`),
);
return valor; // ya resuelto, no espera la escritura
```

Nunca se propaga esa falla a quien llamó. Sin la memoización de §4.3 esto
tendría un problema real: si la escritura de respaldo falla siempre (plano de
control caído, permisos, lo que sea), cada request de ese tenant volvería a
consultar `tenant_config` de nuevo, para siempre. Por eso el valor resuelto entra
a la caché en memoria en el momento de resolverlo, independientemente de si la
escritura de respaldo después tiene éxito — la próxima lectura de ese tenant
dentro de la vida de esa entrada de caché sirve desde memoria, y el intento de
persistir hacia `taller_parametros` (que sí puede seguir reintentándose en cada
población de caché, ese costo es aceptable porque ya no bloquea lecturas) es el
único que se repite.

**Condición de borrado**: este puente (y el de WhatsApp, §6.4) se elimina en el
mismo tiquete futuro que borra las columnas viejas de `tenant_config` y `maetie`
— no antes, porque hasta ese momento son la única red de seguridad para un tenant
que todavía no pasó por esta migración.

### 6.4 WhatsApp: siembra automática, idempotente, dentro de `deploy.sh`

Corregido tras revisar `deploy.sh`: cada sitio desplegado es una instancia de
mbtaller apuntando a **un** tenant por defecto — `migration:run:prod` corre
contra `dist/config/typeorm.config.js`, que lee `TENANT_CLI_DB_HOST`/`_PORT`/
`_DATABASE`/`_USERNAME`/`_PASSWORD` (ver `docker-compose.yml`, servicio
`backend-tools`), no contra un tenant arbitrario resuelto por id desde el plano
de control. Eso significa que las variables `WHATSAPP_*` de un sitio dado ya
están, de hecho, acotadas al mismo tenant que ese sitio migra — no hace falta
resolver ni descifrar nada desde `mbinvtaller.tenants` (a diferencia de
`repair-tenant-maetie-facturacion-pos.ts`, que sí necesita eso porque actúa
sobre un tenant arbitrario elegido por id entre todos los existentes). El script
`backend/scripts/seed-whatsapp-desde-env.ts` se conecta directo con
`TENANT_CLI_DB_*` (mismas variables que `typeorm.config.ts`), sin argumentos, lee
los `WHATSAPP_*` actuales del proceso y escribe las filas `notificaciones.*`
correspondientes, cifrando el access token con `CredentialsCryptoService`.
Idempotente de verdad: si ya existe una fila para
`notificaciones.whatsapp_phone_number_id` en `taller_parametros`, no hace nada —
así no pisa un valor que un administrador ya haya cambiado desde la pantalla
nueva en un despliegue posterior. Si `WHATSAPP_ACCESS_TOKEN`/
`WHATSAPP_PHONE_NUMBER_ID` no están definidas en ese sitio, tampoco hace nada
(no todos los sitios usan WhatsApp).

**Supuesto asumido, no garantizado para siempre**: "un tenant por sitio" es
cierto hoy pero no es una garantía estructural. `typeorm.config.ts` lo dice en
su propio comentario — es "el escape-hatch manual mientras el runner del panel
no existe todavía", es decir, una solución de este momento, no una promesa de
arquitectura. `RUNBOOK-DEPLOY.md:463-468` ya advierte, sobre un puente
relacionado (resolución de login por username entre tenants), que este modelo
"NO escala más allá de un puñado de tenants" y que hoy hay "uno solo" tenant
activo por sitio — el mismo supuesto del que depende este script. El día que un
sitio sirva dos o más tenants activos, esta siembra solo alcanza al que apunte
`TENANT_CLI_DB_DATABASE`, que puede no ser el correcto (o dejar sin sembrar a
los demás) — el script tiene que **loguear explícitamente contra qué base
escribió** (`TENANT_CLI_DB_HOST`/`_DATABASE`, nunca la contraseña) para que ese
día el log del despliegue sea la primera pista de que la siembra fue parcial,
en vez de fallar en silencio.

Esto resuelve el problema de raíz en vez de pedir un paso manual coordinado con
el despliegue: la tabla nace con la migración de este mismo cambio (§6.1), así
que un paso "correr esto antes de desplegar" no tiene dónde pararse — la tabla
todavía no existe antes de ese despliegue. El hueco real está en `deploy.sh`
(construye → migra → **acá** → levanta contenedores). Se agrega como paso nuevo,
mismo servicio `backend-tools` que ya corren las migraciones, entre la línea 35
(migraciones) y la línea 37-38 (`docker compose up -d`):

```sh
echo "==> aplicando migraciones pendientes (tenant + plano de control)"
docker compose --profile tools run --rm --entrypoint sh backend-tools \
  -c "npm run migration:run:prod && npm run migration:cp:run:prod"

echo "==> sembrando config de WhatsApp desde variables de entorno (no-op si ya existe o si este sitio no usa WhatsApp)"
docker compose --profile tools run --rm backend-tools scripts/seed-whatsapp-desde-env.ts

echo "==> levantando contenedores con el codigo y el esquema ya al dia"
docker compose up -d
```

(`backend-tools` ya tiene `ENTRYPOINT ["npx", "ts-node"]` — invocar el script
directo, sin `--entrypoint sh`, es el mismo patrón documentado en el `Dockerfile`
para "cualquier script nuevo que se agregue a `scripts/`".) Corre en cada
despliegue, en todo sitio, siempre antes de que el código nuevo (sin fallback a
`ConfigService`) empiece a servir tráfico — sin coordinación manual y sin
depender de que alguien se acuerde de correrlo una vez contra el cliente
correcto.

## 7. Gating real

### 7.1 `POST /auth/tiendas`

`AuthService.createTienda` llama primero a
`tallerParametros.getExplicit(tenantId, "tiendas.permite_crear")`. En `false`:
`ForbiddenException` con mensaje que nombra el parámetro y dónde se cambia
("Este tenant no permite crear tiendas — activá 'Permitir crear tiendas' en
Configuración → Tiendas").

### 7.2 Creación/edición de productos locales

`ProductosService.create` (y el `update` equivalente) llama primero a
`tallerParametros.get("productos.permite_crear")` (contexto ambiental,
`ProductosController` ya corre bajo `TenantGuard`). Mismo patrón de 403 con
mensaje explícito.

### 7.3 Envío a POSTouch

`FacturacionPosService.enviarOrdenTerminada` verifica
`tallerParametros.get("facturacion.usa_facturacion")` antes de siquiera resolver
`getConfigForTienda` — en `false`, devuelve
`{ exito: false, error: "Facturación automática desactivada para este tenant — Configuración → Facturación" }`
sin tocar POSTouch.

### 7.4 Validación de inventario — explícitamente no implementada

`inventario.usa_validacion` existe en el catálogo, es legible desde el servicio,
y su `descripcion` (§4.1) deja explícito que hoy no tiene consumidor. No se
escribe lógica de stock ni ningún punto de conexión placeholder en el código.

### 7.5 `GET /taller-parametros/flags`

Endpoint tenant-scoped (contexto ambiental) que devuelve únicamente booleanos y
valores no sensibles — nunca credenciales, por construcción: solo expone las
claves del catálogo marcadas explícitamente para este endpoint
(`usaFacturacion`, `permiteTiendas`, `permiteProductosPropios`, `usaWhatsapp`).
El frontend lo consume una vez (en el layout del dashboard) para ocultar
"Facturar"/"Nueva tienda"/"Nuevo producto" en vez de solo bloquearlos server-side.

## 8. WhatsApp: eliminación completa del fallback a variables de entorno

`WhatsappService` deja de inyectar `ConfigService` para estos valores (puede
seguir usándolo para `JWT_SECRET`/`PUBLIC_APP_URL`, que no son de WhatsApp). Cada
llamada a `sendGraphMessage`/`sendTemplate`/`normalizePhone` lee de
`TallerParametrosService` (contexto ambiental). Si `notificaciones.usa_whatsapp`
es `false`, `sendConfiguredMessage` (y por lo tanto `sendCotizacion`/
`sendEstadoOt`) lanza `BadRequestException` de inmediato ("WhatsApp no está
activado para este tenant — Configuración → Notificaciones"), sin llamar a
`fetch`. `WHATSAPP_*` sale de `.env.example`.

## 9. Facturación POS

### 9.1 `PUT /auth/tiendas/:id/facturacion-pos` y el diálogo de `tiendas/page.tsx`

Ambos escriben exclusivamente en `taller_parametros` desde este mismo cambio —
`AuthService.updateTiendaFacturacionPos` deja de tocar `maetie` por completo. El
diálogo de `tiendas/page.tsx` se extrae a un componente compartido
(`FacturacionPosConfigForm` o similar) que también usa la pantalla nueva — un solo
formulario, dos puntos de entrada.

### 9.2 Hallazgo verificado en POSTouch: `ordenes: []` es seguro

Contra `/home/c/Documentos/POSTouch` (no una copia hipotética — el checkout real):

- `DomicilioController::createOrdenesEdngtAction` (`DomicilioController.php:395-417`)
  llama `$this->get('login')->login($parG)` **antes** de invocar
  `Domicilio::createOrdenesEdngt($data)`. Un `_username`/`_password` inválido
  lanza ahí, capturado por el try/catch externo → `manejoException()`, nunca
  llega a procesar `ordenes`.
- `Domicilio::createOrdenesEdngt` (`Domicilio.php:2352-2738`, verificado línea por
  línea con conteo de llaves hasta el cierre real de la función) hace
  `foreach ($data["ordenes"] as $oE) { ... }` — con `ordenes: []` el cuerpo no
  corre ni una vez. Todo efecto secundario real (búsqueda de PLU, creación de
  cliente, `PedidosBitacora`, `envioFacturaEdngt`, cualquier `flush()`) vive
  dentro de ese `foreach`. Fuera de él, el método solo arma
  `{"procesadas":0,"facturas":[]}` y retorna.
- `UsoGeneral::getBasicos()` (`UsoGeneral.php:23-49`), llamado antes del loop, es
  un resolver puro sobre la sesión de login ya establecida — su único modo de
  falla es lanzar si `computadora` no resuelve a una tienda/caja válida
  (`getParametrosTienda`), que es exactamente el error de configuración que la
  sonda debe revelar.

Conclusión verificada: `POST create_ordenes_edngt` con `ordenes: []` ejerce
autenticación real (usuario/contraseña/computadora) sin crear ninguna orden,
cliente o factura.

### 9.3 `PostouchClientService.probarConexion`

Método nuevo, independiente de `enviarOrden` — no comparte rama de código ni cae
a él bajo ningún error:

```ts
async probarConexion(config: FacturacionPosConfig): Promise<
  { ok: true } | { ok: false; detalle: string }
>
```

Envía `{ _username, _password, computadora, ordenes: [] }` al mismo `config.url`.
Interpretación de la respuesta, estrictamente positiva-por-excepción (nunca asume
éxito ante algo inesperado):

- `!response.ok` → `{ ok: false, detalle }`.
- `data?.error` presente → `{ ok: false, detalle }`.
- Cuerpo exactamente `{"procesadas":0,"facturas":[]}` (o forma compatible) →
  `{ ok: true }`.
- Cualquier otra forma de respuesta (JSON no parseable, campos inesperados) →
  `{ ok: false, detalle: "No se pudo verificar la respuesta de POSTouch" }` — nunca
  éxito por omisión.

Diagnóstico logueado en cada llamada (backend, nivel `debug`/`warn` según
resultado): payload enviado con `_password` reemplazada por `"***"`, y los
primeros ~300 caracteres del cuerpo de respuesta — mismo límite que ya usa
`enviarOrden` para sus mensajes de error. Nunca se loguea la contraseña real, acá
ni en ningún otro punto de este cambio (sonda, mensajes de error, historial).

Nota aparte, fuera de alcance de este tiquete: `DomicilioController.php:407`
loguea `json_encode($data)` completo (incluye `_password` en claro) del lado de
POSTouch en cada llamada a este endpoint, incluida esta sonda cada vez que se
use. Es un hallazgo de este trabajo, remediable únicamente en el repo de
POSTouch — no se toca nada de eso acá.

### 9.4 Endpoint

`POST /taller-parametros/tiendas/:id/facturacion-pos/probar`, en el controlador
nuevo del módulo de configuración (mismo `@RequirePermission("configuracion",
"write")` que el resto de las escrituras de este módulo — probar credenciales
es una operación de escritura en el sentido de permisos, aunque no persista):
recibe los campos del formulario (no necesariamente los ya guardados, para poder
probar antes de guardar), arma un `FacturacionPosConfig` efímero y llama a
`probarConexion`. Nunca persiste nada.

Mismo criterio de valor efectivo que §4.4: si el campo `password` del request
viene vacío, el endpoint descifra la contraseña ya guardada para esa tienda
(`facturacion_pos.password`) y la usa para la prueba — de lo contrario sería
imposible probar la conexión de una tienda ya configurada sin volver a teclear
la contraseña cada vez.

## 10. Bypass nuevo

Entrada a agregar en `backend/src/tenancy/tenant-aware-approved-bypasses.json`:

```json
"taller-parametros/taller-parametros.service.ts": {
  "count": 2,
  "justificacion": "AuthService resuelve tenantId desde el JWT sin TenantGuard (mismo motivo que su bypass existente para Tienda/Usuario) y necesita leer/escribir taller_parametros para /auth/tiendas*. control-plane-admin inspecciona tenants antes de Activar (mismo motivo que provisionarPrimerAcceso), con allowOnboarding:true, para el checklist operativo."
}
```

(Mostrado aquí antes de agregarlo, según lo pedido.)

## 11. Pantalla `/dashboard/configuracion`

Entrada en `dashboard-shell.tsx` (`secondaryNavigation`, junto a Tiendas línea
~69), `permission: "configuracion"` para ocultarla del nav — el guard real es
de backend (§12). No `"usuarios"`: ese permiso y `ensureCanManageUsers`
(`auth.controller.ts:41`, que gatea hoy `PUT /auth/tiendas/:id/facturacion-pos`
en la línea 161) ya son dos mecanismos contradictorios entre sí —
`DEFAULT_PERMISSIONS_BY_ROLE[ADMIN].usuarios` dice `"read"` mientras
`ensureCanManageUsers` deja pasar `ADMIN` igual que `SUPER_ADMIN`. Preexistente,
no se arregla en este tiquete, pero ya que este cambio crea el módulo
`configuracion` en `PermissionGuard` de cero, usa ese y no hereda la ambigüedad.

- Agrupada por `grupo` del catálogo: facturación, inventario, productos,
  tiendas, notificaciones.
- Cada control muestra la `etiqueta`/`descripcion` del catálogo junto al campo —
  nunca solo el nombre técnico de la clave.
- Banner de resumen arriba ("facturación activada pero 2 de 3 tiendas sin
  configurar"), calculado a partir de `requeridoSi` no satisfecho por tienda, con
  enlace directo a la tienda/campo que falta.
- Botón "Probar conexión" en la config de facturación POS de cada tienda (§9.4),
  deshabilitado hasta que los campos obligatorios del formulario estén completos.
- Grupo de WhatsApp: si `notificaciones.usa_whatsapp` está apagado, los demás
  campos del grupo no se renderizan — no aparecen deshabilitados, no aparecen.
- Contraseñas: nunca se precargan con el valor guardado (ni cifrado ni
  descifrado); campo vacío al guardar = "no cambiar", mismo criterio que
  `ocultarCredencialesTienda` aplica hoy a las respuestas que cargan `tienda`.
- Validación de formulario espejando `requeridoSi` del catálogo (vía un endpoint
  de metadata que expone el catálogo sin los valores), y el mensaje de error del
  backend se muestra tal cual si la validación de igual forma falla ahí. Igual
  que el backend (§4.4), valida sobre el estado combinado: una tienda ya
  configurada carga con un indicador "contraseña ya configurada" en vez del
  valor, y ese indicador cuenta como valor efectivo presente aunque el campo se
  vea vacío.
- El diálogo de `tiendas/page.tsx` se reemplaza por el componente compartido de
  §9.1.

## 12. Permisos

Nuevo módulo `configuracion` en `PermissionGuard`
(`auth/guards/permission.guard.ts`, `DEFAULT_PERMISSIONS_BY_ROLE`): `write` para
`SUPER_ADMIN`/`ADMIN`, `none` para `OPERATIVO`/`RECEPCION`/`MECANICO`. Controlador
nuevo bajo `@UseGuards(JwtAuthGuard, TenantGuard, PermissionGuard)` con
`@RequirePermission("configuracion", "read"|"write")` por ruta, mismo patrón que
`productos.controller.ts:54` (nivel de controller) y `:104`/`:112` (nivel de
ruta, para separar lectura de escritura).

**Decisión consciente, no arrastrada sin pensar**: hoy `ADMIN` ya puede cambiar
la credencial de POSTouch de una tienda — `PUT /auth/tiendas/:id/facturacion-pos`
está gateado por `ensureCanManageUsers` (`auth.controller.ts`), que acepta
`SUPER_ADMIN` u `ADMIN` por igual, no por `PermissionGuard`. El mapa por defecto
de `PermissionGuard` (`ADMIN.usuarios = "read"`) es un sistema de permisos
distinto y más viejo que ese endpoint nunca usó — la inconsistencia entre ambos
sistemas ya existe hoy, no la introduce este cambio. Darle `configuracion:write`
a `ADMIN` mantiene el nivel de acceso que `ADMIN` ya tiene sobre esta misma
credencial; negárselo sería una restricción nueva no pedida por el tiquete
("solo para usuarios administradores" — plural, igual que la pantalla de
Tiendas). Si se quiere que `ADMIN` deje de poder tocar credenciales de POSTouch,
es una decisión de producto aparte, no un efecto colateral de esta
reorganización.

## 13. Pruebas (junto con el código, no al final)

- `TallerParametrosService`: defaults del catálogo, casteo por `tipo_dato`,
  invalidación de caché en escritura, las dos superficies (ambiental/explícita),
  `requeridoSi` evaluado sobre estado combinado (edición parcial sin reenviar
  password u otros campos ya guardados sigue pasando la validación), fail-closed/
  fail-open por clave (§4.5), historial (incluyendo que una clave `sensible`
  nunca persiste su valor en el historial). Puente de toggles: la escritura de
  respaldo falla repetidamente (mock que siempre rechaza) y se verifica que solo
  la PRIMERA lectura de ese tenant consulta `tenant_config` — las siguientes
  sirven desde la caché en memoria, no repiten la consulta.
- Migración: contra un tenant con datos existentes en `maetie` (backfill
  correcto, contraseña intacta) y contra un tenant limpio (no-op idempotente
  corriéndola dos veces).
- Puente perezoso: no bloquea la respuesta si la escritura de respaldo falla
  (mock que rechaza la escritura, se verifica que la lectura igual resuelve con
  el valor correcto y que no hay unhandled rejection).
- Gating: los 3 endpoints devuelven 403 con el mensaje esperado cuando el toggle
  está apagado, y funcionan igual que hoy cuando está encendido (tenant ya
  configurado se comporta igual después de migrar).
- `probarConexion`: casos `ok`, `!response.ok`, `data.error`, y respuesta con
  forma inesperada — ninguno de los tres casos de error cae a `enviarOrden` ni
  asume éxito. Endpoint de prueba: password vacío en el request usa la
  contraseña guardada, no falla la validación como si faltara.
- `seed-whatsapp-desde-env.ts`: no-op si ya existe la fila de
  `notificaciones.whatsapp_phone_number_id` (no pisa un valor editado desde la
  pantalla), no-op si `WHATSAPP_ACCESS_TOKEN`/`WHATSAPP_PHONE_NUMBER_ID` no están
  en el entorno, siembra correcta contra una base limpia, loguea host+nombre de
  base destino (nunca credenciales) en los tres casos.
- `GET /taller-parametros/flags`: nunca incluye una clave marcada `sensible`.
- Frontend: formulario no permite guardar POS habilitada sin los campos
  requeridos; grupo de WhatsApp no se renderiza con el toggle apagado.

## 14. Listo cuando

- Toda la configuración del módulo de talleres se ve y se cambia desde
  `/dashboard/configuracion`.
- Ningún valor quedó hardcodeado ni leído de variables de entorno por tenant
  (WhatsApp incluido, sin excepción de fallback).
- Los 3 toggles bloquean de verdad, con mensaje que dice dónde cambiarlo.
- La sonda de POSTouch permite verificar credenciales antes de guardar, sin
  riesgo de crear una orden real.
- Un cliente nuevo se configura completo sin tocar la base de datos a mano — la
  siembra de WhatsApp es un paso automático de `deploy.sh` (no-op para un sitio
  sin `WHATSAPP_*` en su entorno), no una intervención manual.
- Los clientes que ya operan se comportan igual después de la migración
  (verificado con pruebas contra datos existentes, §13).
- `npm run build`, lint y pruebas pasan en `backend/` y `frontend/`.
