# Consolidar configuración del taller en `taller_parametros` — Plan de implementación

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

**Goal:** Reemplazar los tres focos dispersos de configuración del taller (`tenant_config`, variables de entorno de WhatsApp, columnas `maetie.taller_facturacion_pos_*`) por una tabla única `taller_parametros` en la base del tenant, con un servicio tipado, gates reales en los 3 toggles operativos, una sonda de conexión segura contra POSTouch, y una pantalla `/dashboard/configuracion`.

**Architecture:** Un módulo nuevo `taller-parametros/` expone un catálogo tipado (clave → tipo/ámbito/metadata) y `TallerParametrosService`, que es el único punto de lectura/escritura de la tabla, con dos superficies (ambiental vía `TenantRepositoryAccessor`, explícita vía `TenantConnectionRegistry`) y caché por tenant. Los módulos consumidores (`auth`, `productos`, `facturacion-pos`, `whatsapp`, `control-plane-admin`) importan `TallerParametrosModule` y dejan de leer sus fuentes viejas.

**Tech Stack:** NestJS + TypeORM (backend), Next.js App Router + React Query (frontend), Jest para pruebas backend, MySQL.

**Spec:** `docs/superpowers/specs/2026-09-23-taller-parametros-design.md` — este plan argumenta desde ese spec; cualquier ejecutor debe leer ambos documentos.

## Global Constraints

- Comentarios y mensajes de error en español, explicando el PORQUÉ de decisiones no obvias, nunca el qué (estilo ya establecido en `tenant-config.entity.ts`, `postouch-client.service.ts`).
- `maeplu` (`productos/entities/producto.entity.ts`) es de solo lectura — nunca se toca en este trabajo.
- Migraciones de tenant van en `backend/src/migrations/`, idempotentes (`getTable`/`findColumnByName` o `createTable(..., true)`), con `down` que revierte de verdad.
- Ningún punto del código fuera de `taller-parametros/` importa la entidad `TallerParametro`/`TallerParametroHistorial` ni construye un `Repository` para ellas directamente — todo pasa por `TallerParametrosService`. Única excepción, ya precedentada: `backend/scripts/*.ts` corre standalone, fuera del proceso de Nest, igual que `repair-tenant-maetie-facturacion-pos.ts` ya manipula `maetie` sin pasar por ninguna entidad/servicio — Task 15 sigue ese mismo patrón, no lo rompe.
- Ningún valor sensible (contraseñas, tokens) se loguea, se devuelve al frontend, ni se guarda en el historial — solo el hecho de que cambió.
- Las pruebas se escriben junto con cada tarea, no al final.
- Después de cada tarea de backend: `cd backend && npm run test -- <archivo>` para la prueba nueva, y antes de terminar el plan completo: `npm run build && npm run lint && npm run test` en `backend/`, y `npm run typecheck && npm run lint && npm run build` en `frontend/` (no hay `npm test` en frontend — no existe corredor de pruebas automatizado ahí).

---

## Mapa de archivos

**Nuevos (backend):**
- `backend/src/taller-parametros/entities/taller-parametro.entity.ts`
- `backend/src/taller-parametros/entities/taller-parametro-historial.entity.ts`
- `backend/src/taller-parametros/taller-parametros.catalogo.ts`
- `backend/src/taller-parametros/taller-parametros.catalogo.spec.ts`
- `backend/src/taller-parametros/taller-parametros.service.ts`
- `backend/src/taller-parametros/taller-parametros.service.spec.ts`
- `backend/src/taller-parametros/taller-parametros.mapeo.ts`
- `backend/src/taller-parametros/dto/actualizar-parametros-tenant.dto.ts`
- `backend/src/taller-parametros/dto/probar-conexion-pos.dto.ts`
- `backend/src/taller-parametros/taller-parametros.controller.ts`
- `backend/src/taller-parametros/taller-parametros.controller.spec.ts`
- `backend/src/taller-parametros/taller-parametros.module.ts`
- `backend/src/migrations/1780700000000-CreateTallerParametros.ts`
- `backend/scripts/seed-whatsapp-desde-env.ts`

**Modificados (backend):** `app.module.ts`, `auth/auth.module.ts`, `auth/auth.service.ts`, `auth/auth.service.spec.ts`, `auth/auth.controller.ts`, `productos/productos.module.ts`, `productos/productos.service.ts`, `productos/productos.service.spec.ts`, `facturacion-pos/facturacion-pos.module.ts`, `facturacion-pos/facturacion-pos.service.ts`, `facturacion-pos/facturacion-pos.service.spec.ts`, `facturacion-pos/postouch-client.service.ts`, `facturacion-pos/postouch-client.service.spec.ts`, `whatsapp/whatsapp.module.ts`, `whatsapp/whatsapp.service.ts`, `whatsapp/whatsapp.service.spec.ts`, `control-plane-admin/control-plane-admin.module.ts`, `control-plane-admin/tenants-admin.service.ts`, `control-plane-admin/tenants-admin.service.spec.ts`, `auth/guards/permission.guard.ts`, `tenancy/tenant-aware-approved-bypasses.json`, `.env.example` (raíz del repo), `deploy.sh`.

**Nuevos (frontend):** `frontend/src/components/facturacion-pos-config-form.tsx`, `frontend/src/app/dashboard/configuracion/page.tsx`.

**Modificados (frontend):** `frontend/src/lib/api.ts`, `frontend/src/app/dashboard/tiendas/page.tsx`, `frontend/src/components/dashboard-shell.tsx`.

---

## Task 1: Entidades y migración de `taller_parametros`

**Files:**
- Create: `backend/src/taller-parametros/entities/taller-parametro.entity.ts`
- Create: `backend/src/taller-parametros/entities/taller-parametro-historial.entity.ts`
- Create: `backend/src/migrations/1780700000000-CreateTallerParametros.ts`
- Test: `backend/src/migrations/verification/migrations-shape.spec.ts` (ya existe — cubre automáticamente cualquier archivo nuevo en `migrations/`, no requiere edición)

**Interfaces:**
- Produces: clase `TallerParametro` (columnas `id, clave, ambito, tiendaId, destino, tipoDato, valor, valorCifrado, createdAt, updatedAt`), clase `TallerParametroHistorial` (`id, clave, ambito, tiendaId, destino, valorAnterior, valorNuevo, usuarioId, createdAt`). Tabla `taller_parametros` con `UNIQUE(clave, tienda_id, destino)`. Ambas usadas por Task 3 en adelante.

- [ ] **Step 1: Crear la entidad `TallerParametro`**

```ts
// backend/src/taller-parametros/entities/taller-parametro.entity.ts
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, Unique, UpdateDateColumn } from "typeorm";

export type AmbitoParametro = "tenant" | "tienda";
export type TipoDatoParametro = "boolean" | "string" | "number" | "json";

// tienda_id=0 / destino='' (nunca NULL) a proposito: MySQL trata cada NULL
// como distinto de cualquier otro dentro de un indice UNIQUE, asi que dos
// filas de ambito tenant con tienda_id=NULL no chocarian contra
// UNIQUE(clave, tienda_id, destino) -- los centinelas hacen que la
// unicidad la garantice la base, no una convencion de la app. Ver spec
// §3.1. `destino` existe sin uso hoy (siempre '') para que #24483 (destino
// de facturacion dividido producto/servicio) agregue filas nuevas sin
// migrar el esquema -- ver spec §3.3.
@Entity("taller_parametros")
@Unique("UQ_taller_parametros_clave_tienda_destino", ["clave", "tiendaId", "destino"])
export class TallerParametro {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({ name: "clave", type: "varchar", length: 100 })
  clave: string;

  @Column({ name: "ambito", type: "enum", enum: ["tenant", "tienda"] })
  ambito: AmbitoParametro;

  @Column({ name: "tienda_id", type: "int", default: 0 })
  tiendaId: number;

  @Column({ name: "destino", type: "varchar", length: 32, default: "" })
  destino: string;

  @Column({ name: "tipo_dato", type: "enum", enum: ["boolean", "string", "number", "json"] })
  tipoDato: TipoDatoParametro;

  // Valores no sensibles, siempre serializados a string por TallerParametrosService.
  @Column({ name: "valor", type: "text", nullable: true })
  valor: string | null;

  // Cifrado con CredentialsCryptoService, para claves marcadas `sensible: true`
  // en el catalogo -- nunca ambas columnas pobladas a la vez para la misma fila.
  @Column({ name: "valor_cifrado", type: "text", nullable: true })
  valorCifrado: string | null;

  @CreateDateColumn({ name: "created_at" })
  createdAt: Date;

  @UpdateDateColumn({ name: "updated_at" })
  updatedAt: Date;
}
```

- [ ] **Step 2: Crear la entidad `TallerParametroHistorial`**

```ts
// backend/src/taller-parametros/entities/taller-parametro-historial.entity.ts
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from "typeorm";
import { AmbitoParametro } from "./taller-parametro.entity";

// Denormalizada a proposito (clave/ambito/tienda/destino en la fila, no una
// FK a taller_parametros.id) para que el historial siga siendo legible
// aunque la fila de configuracion se reescriba. auditoria.entity.ts no
// encaja aca: exige identidad_id (identidad de plataforma) y vive en la
// base de control -- quien cambia un parametro del taller es un Usuario
// LOCAL de este mismo tenant. Ver spec §5.
@Entity("taller_parametros_historial")
export class TallerParametroHistorial {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({ name: "clave", type: "varchar", length: 100 })
  clave: string;

  @Column({ name: "ambito", type: "enum", enum: ["tenant", "tienda"] })
  ambito: AmbitoParametro;

  @Column({ name: "tienda_id", type: "int", default: 0 })
  tiendaId: number;

  @Column({ name: "destino", type: "varchar", length: 32, default: "" })
  destino: string;

  // NULL siempre para claves `sensible: true` en el catalogo -- solo se
  // registra QUE cambio, nunca el valor. Ver spec §5.
  @Column({ name: "valor_anterior", type: "text", nullable: true })
  valorAnterior: string | null;

  @Column({ name: "valor_nuevo", type: "text", nullable: true })
  valorNuevo: string | null;

  @Column({ name: "usuario_id", type: "int" })
  usuarioId: number;

  @CreateDateColumn({ name: "created_at" })
  createdAt: Date;
}
```

- [ ] **Step 3: Escribir la migración con backfill de `maetie`**

```ts
// backend/src/migrations/1780700000000-CreateTallerParametros.ts
import { MigrationInterface, QueryRunner, Table } from "typeorm";

export class CreateTallerParametros1780700000000 implements MigrationInterface {
  name = "CreateTallerParametros1780700000000";

  public async up(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.createTable(
      new Table({
        name: "taller_parametros",
        columns: [
          { name: "id", type: "int", isPrimary: true, isGenerated: true, generationStrategy: "increment" },
          { name: "clave", type: "varchar", length: "100" },
          { name: "ambito", type: "enum", enum: ["tenant", "tienda"] },
          { name: "tienda_id", type: "int", default: 0 },
          { name: "destino", type: "varchar", length: "32", default: "''" },
          { name: "tipo_dato", type: "enum", enum: ["boolean", "string", "number", "json"] },
          { name: "valor", type: "text", isNullable: true },
          { name: "valor_cifrado", type: "text", isNullable: true },
          { name: "created_at", type: "timestamp", default: "CURRENT_TIMESTAMP" },
          {
            name: "updated_at",
            type: "timestamp",
            default: "CURRENT_TIMESTAMP",
            onUpdate: "CURRENT_TIMESTAMP",
          },
        ],
        uniques: [
          { name: "UQ_taller_parametros_clave_tienda_destino", columnNames: ["clave", "tienda_id", "destino"] },
        ],
      }),
      true,
    );

    await queryRunner.createTable(
      new Table({
        name: "taller_parametros_historial",
        columns: [
          { name: "id", type: "int", isPrimary: true, isGenerated: true, generationStrategy: "increment" },
          { name: "clave", type: "varchar", length: "100" },
          { name: "ambito", type: "enum", enum: ["tenant", "tienda"] },
          { name: "tienda_id", type: "int", default: 0 },
          { name: "destino", type: "varchar", length: "32", default: "''" },
          { name: "valor_anterior", type: "text", isNullable: true },
          { name: "valor_nuevo", type: "text", isNullable: true },
          { name: "usuario_id", type: "int" },
          { name: "created_at", type: "timestamp", default: "CURRENT_TIMESTAMP" },
        ],
      }),
      true,
    );

    await this.backfillFacturacionPos(queryRunner);
  }

  // Copia taller_facturacion_pos_* de maetie -> filas tienda-scope de
  // taller_parametros. Misma base, misma conexion: se copia tal cual, sin
  // descifrar la contraseña. INSERT IGNORE + UNIQUE(clave, tienda_id,
  // destino) hace esto seguro de reintentar. Si maetie todavia no tiene
  // esas columnas (tenant que nunca corrio AddTiendaFacturacionPosConfig),
  // el SELECT falla y se omite el backfill sin abortar la migracion --
  // la tabla nueva igual queda creada.
  private async backfillFacturacionPos(queryRunner: QueryRunner): Promise<void> {
    let filasMaetie: Array<Record<string, any>>;
    try {
      filasMaetie = await queryRunner.query(
        `SELECT tienda,
                taller_facturacion_pos_habilitada AS habilitada,
                taller_facturacion_pos_url AS url,
                taller_facturacion_pos_username AS username,
                taller_facturacion_pos_password_encrypted AS password_encrypted,
                taller_facturacion_pos_computadora AS computadora,
                taller_facturacion_pos_precio_decimales AS precio_decimales
         FROM maetie
         WHERE taller_facturacion_pos_habilitada = 1
            OR taller_facturacion_pos_url IS NOT NULL
            OR taller_facturacion_pos_username IS NOT NULL
            OR taller_facturacion_pos_password_encrypted IS NOT NULL
            OR taller_facturacion_pos_computadora IS NOT NULL
            OR taller_facturacion_pos_precio_decimales IS NOT NULL`,
      );
    } catch {
      return;
    }

    for (const t of filasMaetie) {
      const filas: Array<{ clave: string; tipo: string; valor: string | null; valorCifrado: string | null }> = [
        { clave: "facturacion_pos.habilitada", tipo: "boolean", valor: t.habilitada ? "true" : "false", valorCifrado: null },
        { clave: "facturacion_pos.url", tipo: "string", valor: t.url, valorCifrado: null },
        { clave: "facturacion_pos.username", tipo: "string", valor: t.username, valorCifrado: null },
        { clave: "facturacion_pos.password", tipo: "string", valor: null, valorCifrado: t.password_encrypted },
        { clave: "facturacion_pos.computadora", tipo: "string", valor: t.computadora, valorCifrado: null },
        {
          clave: "facturacion_pos.precio_decimales",
          tipo: "number",
          valor: t.precio_decimales !== null && t.precio_decimales !== undefined ? String(t.precio_decimales) : null,
          valorCifrado: null,
        },
      ];

      for (const fila of filas) {
        if (fila.valor === null && fila.valorCifrado === null) continue;
        await queryRunner.query(
          `INSERT IGNORE INTO taller_parametros (clave, ambito, tienda_id, destino, tipo_dato, valor, valor_cifrado)
           VALUES (?, 'tienda', ?, '', ?, ?, ?)`,
          [fila.clave, t.tienda, fila.tipo, fila.valor, fila.valorCifrado],
        );
      }
    }
  }

  public async down(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.dropTable("taller_parametros_historial", true);
    await queryRunner.dropTable("taller_parametros", true);
  }
}
```

- [ ] **Step 4: Verificar que la prueba de forma de migraciones la reconoce**

Run: `cd backend && npx jest src/migrations/verification/migrations-shape.spec.ts`
Expected: PASS (el archivo nuevo exporta una clase con `up()`/`down()`, la prueba existente lo detecta sin cambios).

- [ ] **Step 5: Build de TypeScript**

Run: `cd backend && npm run build`
Expected: sin errores de compilación en las dos entidades ni en la migración.

- [ ] **Step 6: Commit**

```bash
git add backend/src/taller-parametros/entities backend/src/migrations/1780700000000-CreateTallerParametros.ts
git commit -m "feat(taller-parametros): tabla taller_parametros + historial, con backfill de maetie"
```

---

## Task 2: Catálogo tipado

**Files:**
- Create: `backend/src/taller-parametros/taller-parametros.catalogo.ts`
- Test: `backend/src/taller-parametros/taller-parametros.catalogo.spec.ts`

**Interfaces:**
- Produces: `TALLER_PARAMETROS_CATALOGO` (objeto), tipos `TallerParametroClave`, `CatalogoEntrada`, `TipoDato`, `Ambito`, `ComportamientoAnteFallo`, `ValorDe<K>`. Usado por Task 3 en adelante.

- [ ] **Step 1: Escribir el catálogo**

```ts
// backend/src/taller-parametros/taller-parametros.catalogo.ts

export type TipoDato = "boolean" | "string" | "number" | "json";
export type Ambito = "tenant" | "tienda";
export type ComportamientoAnteFallo = "bloquear" | "permitir";

export interface CatalogoEntrada {
  ambito: Ambito;
  tipo: TipoDato;
  grupo: string;
  etiqueta: string;
  descripcion: string;
  sensible: boolean;
  default?: boolean | string | number;
  // Clave de otra entrada del catalogo, MISMO ambito, cuyo valor booleano
  // en true hace que esta clave sea obligatoria. Ver spec §4.4.
  requeridoSi?: string;
  // Solo relevante para claves booleanas que gatean una accion. Ver spec §4.5.
  comportamientoAnteFallo?: ComportamientoAnteFallo;
}

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,
    etiqueta: "Phone Number ID", descripcion: "ID del número de WhatsApp Business en Meta.",
    requeridoSi: "notificaciones.usa_whatsapp",
  },
  "notificaciones.whatsapp_access_token": {
    ambito: "tenant", tipo: "string", grupo: "notificaciones", sensible: true,
    etiqueta: "Access Token", descripcion: "Token de acceso de la app de Meta. Se guarda cifrado.",
    requeridoSi: "notificaciones.usa_whatsapp",
  },
  "notificaciones.whatsapp_api_version": {
    ambito: "tenant", tipo: "string", grupo: "notificaciones", sensible: false, default: "v25.0",
    etiqueta: "Versión de API", descripcion: "Versión de Graph API de Meta a usar.",
  },
  "notificaciones.whatsapp_template_cotizacion": {
    ambito: "tenant", tipo: "string", grupo: "notificaciones", sensible: false,
    etiqueta: "Plantilla de cotización",
    descripcion: "Nombre de plantilla aprobada en Meta. Vacío = envía texto libre.",
  },
  "notificaciones.whatsapp_template_estado": {
    ambito: "tenant", tipo: "string", grupo: "notificaciones", sensible: false,
    etiqueta: "Plantilla de estado de OT",
    descripcion: "Nombre de plantilla aprobada en Meta. Vacío = envía texto libre.",
  },
  "notificaciones.whatsapp_template_language": {
    ambito: "tenant", tipo: "string", grupo: "notificaciones", sensible: false, default: "es_GT",
    etiqueta: "Idioma de plantilla", descripcion: "Código de idioma de las plantillas de Meta.",
  },
  "notificaciones.whatsapp_default_country_code": {
    ambito: "tenant", tipo: "string", grupo: "notificaciones", sensible: false, default: "502",
    etiqueta: "Código de país por defecto",
    descripcion: "Se antepone a números locales de 8 dígitos.",
  },
  "facturacion_pos.habilitada": {
    ambito: "tienda", tipo: "boolean", default: false, grupo: "facturacion",
    etiqueta: "Facturación POS habilitada",
    descripcion: "Envía automáticamente cada OT terminada de esta tienda a POSTouch.",
    sensible: false,
  },
  "facturacion_pos.url": {
    ambito: "tienda", tipo: "string", grupo: "facturacion", sensible: false,
    etiqueta: "URL de POSTouch", descripcion: "Endpoint create_ordenes_edngt de esta tienda.",
    requeridoSi: "facturacion_pos.habilitada",
  },
  "facturacion_pos.username": {
    ambito: "tienda", tipo: "string", grupo: "facturacion", sensible: false,
    etiqueta: "Usuario de servicio", descripcion: "Usuario de POSTouch para esta tienda.",
    requeridoSi: "facturacion_pos.habilitada",
  },
  "facturacion_pos.password": {
    ambito: "tienda", tipo: "string", grupo: "facturacion", sensible: true,
    etiqueta: "Contraseña de servicio", descripcion: "Se guarda cifrada. Vacío al guardar = no cambiar.",
    requeridoSi: "facturacion_pos.habilitada",
  },
  "facturacion_pos.computadora": {
    ambito: "tienda", tipo: "string", grupo: "facturacion", sensible: false,
    etiqueta: "Computadora", descripcion: "Identificador de tienda/caja en POSTouch.",
    requeridoSi: "facturacion_pos.habilitada",
  },
  "facturacion_pos.precio_decimales": {
    ambito: "tienda", tipo: "number", grupo: "facturacion", sensible: false,
    etiqueta: "Decimales de precio",
    descripcion: "Decimales de redondeo configurados en POSTouch para esta tienda (ParametrosGlobales).",
    requeridoSi: "facturacion_pos.habilitada",
  },
} as const satisfies Record<string, CatalogoEntrada>;

export type TallerParametroClave = keyof typeof TALLER_PARAMETROS_CATALOGO;

type TipoTS<T extends TipoDato> = T extends "boolean"
  ? boolean
  : T extends "number"
    ? number
    : T extends "json"
      ? Record<string, unknown>
      : string;

export type ValorDe<K extends TallerParametroClave> = TipoTS<(typeof TALLER_PARAMETROS_CATALOGO)[K]["tipo"]>;

export function entradaDe(clave: TallerParametroClave): CatalogoEntrada {
  return TALLER_PARAMETROS_CATALOGO[clave];
}

export function clavesDelCatalogo(): TallerParametroClave[] {
  return Object.keys(TALLER_PARAMETROS_CATALOGO) as TallerParametroClave[];
}

export function clavesPorAmbito(ambito: Ambito): TallerParametroClave[] {
  return clavesDelCatalogo().filter((c) => TALLER_PARAMETROS_CATALOGO[c].ambito === ambito);
}
```

- [ ] **Step 2: Escribir la prueba de autoconsistencia del catálogo**

```ts
// backend/src/taller-parametros/taller-parametros.catalogo.spec.ts
import { clavesDelCatalogo, entradaDe, TALLER_PARAMETROS_CATALOGO } from "./taller-parametros.catalogo";

describe("TALLER_PARAMETROS_CATALOGO", () => {
  it("todo requeridoSi apunta a una clave real del catálogo", () => {
    for (const clave of clavesDelCatalogo()) {
      const entrada = entradaDe(clave);
      if (entrada.requeridoSi) {
        expect(TALLER_PARAMETROS_CATALOGO).toHaveProperty(entrada.requeridoSi);
      }
    }
  });

  it("requeridoSi siempre apunta a una clave booleana del MISMO ámbito", () => {
    for (const clave of clavesDelCatalogo()) {
      const entrada = entradaDe(clave);
      if (entrada.requeridoSi) {
        const gate = entradaDe(entrada.requeridoSi as any);
        expect(gate.tipo).toBe("boolean");
        expect(gate.ambito).toBe(entrada.ambito);
      }
    }
  });

  it("toda clave con comportamientoAnteFallo es booleana", () => {
    for (const clave of clavesDelCatalogo()) {
      const entrada = entradaDe(clave);
      if (entrada.comportamientoAnteFallo) {
        expect(entrada.tipo).toBe("boolean");
      }
    }
  });

  it("toda clave sensible es de tipo string (nunca boolean/number/json cifrado)", () => {
    for (const clave of clavesDelCatalogo()) {
      const entrada = entradaDe(clave);
      if (entrada.sensible) {
        expect(entrada.tipo).toBe("string");
      }
    }
  });

  it("las 4 claves de gating tienen comportamientoAnteFallo declarado", () => {
    const gates = [
      "facturacion.usa_facturacion",
      "tiendas.permite_crear",
      "productos.permite_crear",
      "notificaciones.usa_whatsapp",
    ] as const;
    for (const clave of gates) {
      expect(entradaDe(clave).comportamientoAnteFallo).toBeDefined();
    }
  });
});
```

- [ ] **Step 3: Correr la prueba**

Run: `cd backend && npx jest src/taller-parametros/taller-parametros.catalogo.spec.ts`
Expected: PASS, 5 pruebas.

- [ ] **Step 4: Commit**

```bash
git add backend/src/taller-parametros/taller-parametros.catalogo.ts backend/src/taller-parametros/taller-parametros.catalogo.spec.ts
git commit -m "feat(taller-parametros): catálogo tipado de parámetros"
```

---

## Task 3: `TallerParametrosService` — núcleo ambiental (lectura/escritura simple, caché)

Sin puente de `tenant_config`, sin validación `requeridoSi`, sin historial, sin
superficie explícita — eso llega en las Tasks 4-6. Esta tarea deja `get`,
`getParaTienda`, `set` y `setParaTienda` funcionando end-to-end contra la tabla,
con defaults del catálogo y caché.

**Files:**
- Create: `backend/src/taller-parametros/taller-parametros.service.ts`
- Create: `backend/src/taller-parametros/taller-parametros.service.spec.ts`

**Interfaces:**
- Consumes: `TenantRepositoryAccessor` (`../tenancy/tenant-repository.accessor`, `repositoryFor<T>(entity): Repository<T>`, `getCurrentTenantId(): number`), `CredentialsCryptoService` (`../control-plane/credentials-crypto.service`, `encrypt(texto): string`, `decrypt(blob): string`), `TALLER_PARAMETROS_CATALOGO`/`TallerParametroClave`/`ValorDe<K>`/`entradaDe` (Task 2), `TallerParametro` (Task 1).
- Produces: clase `TallerParametrosService` con `get<K>(clave: K): Promise<ValorDe<K>>`, `getParaTienda<K>(tiendaId: number, clave: K, destino?: string): Promise<ValorDe<K>>`, `set<K>(clave: K, valor: ValorDe<K>, usuarioId: number): Promise<void>`, `setParaTienda<K>(tiendaId: number, clave: K, valor: ValorDe<K>, usuarioId: number, destino?: string): Promise<void>`. Usada por Tasks 4-25.

- [ ] **Step 1: Escribir las pruebas del núcleo (defaults, casteo, caché)**

```ts
// backend/src/taller-parametros/taller-parametros.service.spec.ts
import { TallerParametrosService } from "./taller-parametros.service";

function createService(overrides: Record<string, any> = {}) {
  const filas = new Map<string, any>();

  const repo = {
    findOne: jest.fn(async ({ where }: any) => {
      const key = `${where.clave}:${where.tiendaId}:${where.destino}`;
      return filas.get(key) ?? null;
    }),
    create: jest.fn((data: any) => data),
    save: jest.fn(async (data: any) => {
      const saved = { id: filas.size + 1, ...data };
      filas.set(`${data.clave}:${data.tiendaId}:${data.destino}`, saved);
      return saved;
    }),
    update: jest.fn(async (id: number, data: any) => {
      for (const [key, fila] of filas.entries()) {
        if (fila.id === id) filas.set(key, { ...fila, ...data });
      }
    }),
    ...overrides.repo,
  };

  // Repo SEPARADO del de arriba a propósito, aunque esta tarea todavía no lo
  // usa (llega en Task 5): repositoryFor() diferencia por entidad desde el
  // principio para que agregar historial más adelante no pise silenciosamente
  // las filas de `repo` -- ambos usan la misma forma de clave interna
  // (clave:tiendaId:destino) y compartir un solo mock los mezclaría.
  const historialRepo = {
    create: jest.fn((data: any) => data),
    save: jest.fn(async (data: any) => ({ id: 1, ...data })),
    ...overrides.historialRepo,
  };

  const tenantAccessor = {
    getCurrentTenantId: jest.fn(() => 7),
    repositoryFor: jest.fn((entity: any) => (entity?.name === "TallerParametroHistorial" ? historialRepo : repo)),
    ...overrides.tenantAccessor,
  };

  const credentialsCrypto = {
    encrypt: jest.fn((plain: string) => `ENC(${plain})`),
    decrypt: jest.fn((blob: string) => blob.replace(/^ENC\(|\)$/g, "")),
    ...overrides.credentialsCrypto,
  };

  const tenantConfigService = { getTogglesOperativos: jest.fn(), ...overrides.tenantConfigService };

  const service = new TallerParametrosService(
    tenantAccessor as any,
    credentialsCrypto as any,
    tenantConfigService as any,
    (overrides.tenantConnectionRegistry as any) ?? (undefined as any), // ver Task 4
  );

  return { service, repo, historialRepo, tenantAccessor, credentialsCrypto, filas };
}

describe("TallerParametrosService — núcleo ambiental", () => {
  it("get() devuelve el default del catálogo cuando no hay fila", async () => {
    const { service } = createService();
    await expect(service.get("facturacion.usa_facturacion")).resolves.toBe(true);
    await expect(service.get("notificaciones.whatsapp_api_version")).resolves.toBe("v25.0");
  });

  it("get() castea boolean/number/json correctamente desde texto guardado", async () => {
    const { service } = createService();
    await service.set("productos.permite_crear", false, 1);
    await expect(service.get("productos.permite_crear")).resolves.toBe(false);
  });

  it("getParaTienda() castea number", async () => {
    const { service } = createService();
    await service.setParaTienda(9, "facturacion_pos.precio_decimales", 3, 1);
    await expect(service.getParaTienda(9, "facturacion_pos.precio_decimales")).resolves.toBe(3);
  });

  it("claves sensibles se cifran al escribir y se descifran al leer, nunca en texto plano en `valor`", async () => {
    const { service, filas } = createService();
    await service.setParaTienda(9, "facturacion_pos.password", "secreta123", 1);
    const fila = filas.get("facturacion_pos.password:9:");
    expect(fila.valor).toBeNull();
    expect(fila.valorCifrado).toBe("ENC(secreta123)");
    await expect(service.getParaTienda(9, "facturacion_pos.password")).resolves.toBe("secreta123");
  });

  it("set() invalida la caché del tenant: una lectura posterior refleja el nuevo valor sin volver a golpear el repo si no cambió, y SÍ refleja el cambio tras escribir", async () => {
    const { service, repo } = createService();
    await service.get("facturacion.usa_facturacion"); // primera lectura, popula caché
    await service.get("facturacion.usa_facturacion"); // segunda lectura, debería venir de caché
    expect(repo.findOne).toHaveBeenCalledTimes(1);

    await service.set("facturacion.usa_facturacion", false, 1);
    await expect(service.get("facturacion.usa_facturacion")).resolves.toBe(false);
  });
});
```

- [ ] **Step 2: Correr la prueba para verificar que falla (no existe el servicio)**

Run: `cd backend && npx jest src/taller-parametros/taller-parametros.service.spec.ts`
Expected: FAIL — `Cannot find module './taller-parametros.service'`.

- [ ] **Step 3: Implementar el núcleo del servicio**

```ts
// backend/src/taller-parametros/taller-parametros.service.ts
import { Injectable, Logger } from "@nestjs/common";
import { Repository } from "typeorm";
import { TenantRepositoryAccessor } from "../tenancy/tenant-repository.accessor";
import { CredentialsCryptoService } from "../control-plane/credentials-crypto.service";
import { TenantConfigService } from "../control-plane/tenant-config.service";
import { TenantConnectionRegistry } from "../tenancy/tenant-connection-registry.service";
import { TallerParametro } from "./entities/taller-parametro.entity";
import {
  ComportamientoAnteFallo,
  TALLER_PARAMETROS_CATALOGO,
  TallerParametroClave,
  ValorDe,
  entradaDe,
} from "./taller-parametros.catalogo";

@Injectable()
export class TallerParametrosService {
  private readonly logger = new Logger(TallerParametrosService.name);

  // Cache por tenant: Map<tenantId, Map<"clave:tiendaId:destino", valorTipado>>.
  // Se llena en la primera lectura y se invalida COMPLETA (no por clave) en
  // cualquier escritura de ese tenant -- ver spec §4.3.
  private readonly cache = new Map<number, Map<string, unknown>>();

  constructor(
    private readonly tenantAccessor: TenantRepositoryAccessor,
    private readonly credentialsCrypto: CredentialsCryptoService,
    private readonly tenantConfigService: TenantConfigService,
    private readonly tenantConnectionRegistry: TenantConnectionRegistry,
  ) {}

  async get<K extends TallerParametroClave>(clave: K): Promise<ValorDe<K>> {
    const tenantId = this.tenantAccessor.getCurrentTenantId();
    const repo = this.tenantAccessor.repositoryFor(TallerParametro);
    return this.resolver(tenantId, repo, clave, 0, "");
  }

  async getParaTienda<K extends TallerParametroClave>(
    tiendaId: number,
    clave: K,
    destino = "",
  ): Promise<ValorDe<K>> {
    const tenantId = this.tenantAccessor.getCurrentTenantId();
    const repo = this.tenantAccessor.repositoryFor(TallerParametro);
    return this.resolver(tenantId, repo, clave, tiendaId, destino);
  }

  async set<K extends TallerParametroClave>(clave: K, valor: ValorDe<K>, usuarioId: number): Promise<void> {
    const tenantId = this.tenantAccessor.getCurrentTenantId();
    const repo = this.tenantAccessor.repositoryFor(TallerParametro);
    await this.escribirFila(repo, clave, valor, 0, "");
    this.cache.delete(tenantId);
  }

  async setParaTienda<K extends TallerParametroClave>(
    tiendaId: number,
    clave: K,
    valor: ValorDe<K>,
    usuarioId: number,
    destino = "",
  ): Promise<void> {
    const tenantId = this.tenantAccessor.getCurrentTenantId();
    const repo = this.tenantAccessor.repositoryFor(TallerParametro);
    await this.escribirFila(repo, clave, valor, tiendaId, destino);
    this.cache.delete(tenantId);
  }

  // --- núcleo compartido, sin conocer si el llamador es ambiental o explícito ---

  private async resolver<K extends TallerParametroClave>(
    tenantId: number,
    repo: Repository<TallerParametro>,
    clave: K,
    tiendaId: number,
    destino: string,
  ): Promise<ValorDe<K>> {
    const cacheKey = this.claveCache(clave, tiendaId, destino);
    const tenantCache = this.cache.get(tenantId);
    if (tenantCache?.has(cacheKey)) {
      return tenantCache.get(cacheKey) as ValorDe<K>;
    }

    const fila = await repo.findOne({ where: { clave, tiendaId, destino } });
    const valor = this.deserializar(clave, fila);
    this.guardarEnCache(tenantId, cacheKey, valor);
    return valor;
  }

  private async escribirFila<K extends TallerParametroClave>(
    repo: Repository<TallerParametro>,
    clave: K,
    valor: ValorDe<K>,
    tiendaId: number,
    destino: string,
  ): Promise<void> {
    const entrada = entradaDe(clave);
    const { valor: v, valorCifrado } = this.serializar(clave, valor);
    const existente = await repo.findOne({ where: { clave, tiendaId, destino } });
    if (existente) {
      await repo.update(existente.id, { valor: v, valorCifrado, tipoDato: entrada.tipo });
    } else {
      const nueva = repo.create({
        clave,
        ambito: entrada.ambito,
        tiendaId,
        destino,
        tipoDato: entrada.tipo,
        valor: v,
        valorCifrado,
      });
      await repo.save(nueva);
    }
  }

  private serializar(
    clave: TallerParametroClave,
    valor: unknown,
  ): { valor: string | null; valorCifrado: string | null } {
    const entrada = entradaDe(clave);
    if (valor === null || valor === undefined) {
      return { valor: null, valorCifrado: null };
    }
    const texto = entrada.tipo === "json" ? JSON.stringify(valor) : String(valor);
    if (entrada.sensible) {
      return { valor: null, valorCifrado: this.credentialsCrypto.encrypt(texto) };
    }
    return { valor: texto, valorCifrado: null };
  }

  private deserializar<K extends TallerParametroClave>(clave: K, fila: TallerParametro | null): ValorDe<K> {
    const entrada = entradaDe(clave);
    let texto: string | null;

    if (fila) {
      texto = entrada.sensible ? (fila.valorCifrado ? this.credentialsCrypto.decrypt(fila.valorCifrado) : null) : fila.valor;
    } else {
      texto = entrada.default !== undefined ? String(entrada.default) : null;
    }

    if (texto === null) {
      // Sin fila y sin default: "vacio" tipado. precioDecimales -> 0 y
      // strings -> "" solo importan cuando el toggle que los requiere esta
      // apagado (requeridoSi los exige en escritura si no) -- ver spec §4.4.
      if (entrada.tipo === "boolean") return false as ValorDe<K>;
      if (entrada.tipo === "number") return 0 as unknown as ValorDe<K>;
      if (entrada.tipo === "json") return {} as unknown as ValorDe<K>;
      return "" as unknown as ValorDe<K>;
    }

    if (entrada.tipo === "boolean") return (texto === "true" || texto === "1") as ValorDe<K>;
    if (entrada.tipo === "number") return Number(texto) as ValorDe<K>;
    if (entrada.tipo === "json") return JSON.parse(texto) as ValorDe<K>;
    return texto as unknown as ValorDe<K>;
  }

  private claveCache(clave: string, tiendaId: number, destino: string): string {
    return `${clave}:${tiendaId}:${destino}`;
  }

  private guardarEnCache(tenantId: number, cacheKey: string, valor: unknown): void {
    if (!this.cache.has(tenantId)) this.cache.set(tenantId, new Map());
    this.cache.get(tenantId)!.set(cacheKey, valor);
  }

  private comportamiento(clave: TallerParametroClave): ComportamientoAnteFallo {
    return entradaDe(clave).comportamientoAnteFallo ?? "bloquear";
  }
}
```

- [ ] **Step 4: Correr las pruebas**

Run: `cd backend && npx jest src/taller-parametros/taller-parametros.service.spec.ts`
Expected: PASS, 5 pruebas.

- [ ] **Step 5: Commit**

```bash
git add backend/src/taller-parametros/taller-parametros.service.ts backend/src/taller-parametros/taller-parametros.service.spec.ts
git commit -m "feat(taller-parametros): núcleo ambiental del servicio (get/set, caché por tenant)"
```

---

## Task 4: Superficie explícita (`getExplicit`/`setExplicit`) + bypass aprobado

`AuthService` (rutas `/auth/tiendas*`, sin `TenantGuard`) y `control-plane-admin`
necesitan leer/escribir sin contexto ambiental. Ver spec §4.2 y §10.

**Files:**
- Modify: `backend/src/taller-parametros/taller-parametros.service.ts`
- Modify: `backend/src/taller-parametros/taller-parametros.service.spec.ts`
- Modify: `backend/src/tenancy/tenant-aware-approved-bypasses.json`

**Interfaces:**
- Consumes: `TenantConnectionRegistry.withTenantRepositoryExplicit<T,R>(tenantId, entity, fn, options?): Promise<R>` (ya inyectado desde Task 3).
- Produces: `getExplicit<K>(tenantId: number, clave: K, opciones?: { allowOnboarding?: boolean }): Promise<ValorDe<K>>`, `setExplicit<K>(tenantId: number, clave: K, valor: ValorDe<K>, usuarioId: number): Promise<void>`. Usados por Task 6 (puente), Task 7 (admin), Task 10 (AuthService.createTienda).

- [ ] **Step 1: Mostrar la justificación del bypass antes de agregarla**

Ajuste sobre el spec §10: en vez de que cada método explícito (`getExplicit`,
`getParaTiendaExplicit`, `setExplicit`, y los que llegan en Tasks 5-7) llame a
`TenantConnectionRegistry` por su cuenta, TODOS pasan por un único método
privado (`conRepositoriosExplicitos`, Step 5) que es el ÚNICO lugar del
archivo con una llamada literal a `withTenant...Explicit(` — el escáner
(`scan-tenant-unaware-access.ts`) cuenta coincidencias de texto, no usos
lógicos, así que consolidar en un solo punto dentro del archivo baja el
conteo real a **1**, no 2. Es un ajuste hacia abajo respecto de lo aprobado
en el spec (menos bypasses, misma justificación), así que se deja escrito
acá en vez de simplemente usarlo:

```json
"taller-parametros/taller-parametros.service.ts": {
  "count": 1,
  "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. Un único método privado (conRepositoriosExplicitos) concentra la única llamada a withTenantDataSourceExplicit del archivo -- toda la superficie explícita del servicio pasa por ahí."
}
```

- [ ] **Step 2: Agregar esa entrada al JSON**

Editar `backend/src/tenancy/tenant-aware-approved-bypasses.json` insertando la clave de arriba dentro de `"approved"` (mantener el resto del archivo intacto).

- [ ] **Step 3: Escribir las pruebas de la superficie explícita**

Agregar al final de `backend/src/taller-parametros/taller-parametros.service.spec.ts` (mismo `createService`, ahora pasando un mock real de `tenantConnectionRegistry`):

```ts
function createRepoFake() {
  const filas = new Map<string, any>();
  return {
    filas,
    findOne: jest.fn(async ({ where }: any) => filas.get(`${where.clave}:${where.tiendaId}:${where.destino}`) ?? null),
    create: jest.fn((d: any) => d),
    save: jest.fn(async (d: any) => {
      const saved = { id: filas.size + 1, ...d };
      filas.set(`${d.clave}:${d.tiendaId}:${d.destino}`, saved);
      return saved;
    }),
    update: jest.fn(async (id: number, data: any) => {
      for (const [k, f] of filas.entries()) if (f.id === id) filas.set(k, { ...f, ...data });
    }),
  };
}

describe("TallerParametrosService — superficie explícita", () => {
  it("getExplicit() usa TenantConnectionRegistry.withTenantDataSourceExplicit, no el contexto ambiental", async () => {
    const parametrosRepo = createRepoFake();
    const historialRepo = createRepoFake();
    const dataSource = {
      getRepository: jest.fn((entity: any) => (entity?.name === "TallerParametroHistorial" ? historialRepo : parametrosRepo)),
    };
    const tenantConnectionRegistry = {
      withTenantDataSourceExplicit: jest.fn(async (_tenantId: number, fn: any) => fn(dataSource)),
    };
    const { service, tenantAccessor } = createService({ tenantConnectionRegistry });

    await service.getExplicit(55, "tiendas.permite_crear");

    expect(tenantConnectionRegistry.withTenantDataSourceExplicit).toHaveBeenCalledWith(
      55,
      expect.any(Function),
      undefined,
    );
    expect(tenantAccessor.repositoryFor).not.toHaveBeenCalled();
  });

  it("setExplicit() invalida la caché de ESE tenantId, no la del contexto ambiental", async () => {
    const parametrosRepo = createRepoFake();
    const historialRepo = createRepoFake();
    const dataSource = {
      getRepository: jest.fn((entity: any) => (entity?.name === "TallerParametroHistorial" ? historialRepo : parametrosRepo)),
    };
    const tenantConnectionRegistry = {
      withTenantDataSourceExplicit: jest.fn(async (_tenantId: number, fn: any) => fn(dataSource)),
    };
    const { service } = createService({ tenantConnectionRegistry });

    await service.setExplicit(55, "tiendas.permite_crear", false, 1);
    await expect(service.getExplicit(55, "tiendas.permite_crear")).resolves.toBe(false);
  });
});
```

- [ ] **Step 4: Correr las pruebas para verificar que fallan**

Run: `cd backend && npx jest src/taller-parametros/taller-parametros.service.spec.ts`
Expected: FAIL — `service.getExplicit is not a function`.

- [ ] **Step 5: Implementar `getExplicit`/`setExplicit` sobre un único punto de acceso explícito**

Agregar a `taller-parametros.service.ts`. El import de `TallerParametroHistorial`
se usa recién en Task 5, pero el helper ya lo expone ahora para no tener que
tocar este único punto de acceso otra vez:

```ts
  async getExplicit<K extends TallerParametroClave>(
    tenantId: number,
    clave: K,
    opciones?: { allowOnboarding?: boolean },
  ): Promise<ValorDe<K>> {
    return this.conRepositoriosExplicitos(
      tenantId,
      ({ parametros }) => this.resolver(tenantId, parametros, clave, 0, ""),
      opciones,
    );
  }

  async getParaTiendaExplicit<K extends TallerParametroClave>(
    tenantId: number,
    tiendaId: number,
    clave: K,
    destino = "",
    opciones?: { allowOnboarding?: boolean },
  ): Promise<ValorDe<K>> {
    return this.conRepositoriosExplicitos(
      tenantId,
      ({ parametros }) => this.resolver(tenantId, parametros, clave, tiendaId, destino),
      opciones,
    );
  }

  async setExplicit<K extends TallerParametroClave>(
    tenantId: number,
    clave: K,
    valor: ValorDe<K>,
    usuarioId: number,
  ): Promise<void> {
    await this.conRepositoriosExplicitos(tenantId, ({ parametros }) =>
      this.escribirFila(parametros, clave, valor, 0, ""),
    );
    this.cache.delete(tenantId);
  }

  // Único lugar del archivo que llama a TenantConnectionRegistry -- ver
  // justificación del bypass en tenant-aware-approved-bypasses.json. Entrega
  // AMBOS repositorios (parametros + historial) en un solo acquire, en vez
  // de que cada método explícito abra el suyo -- withTenantDataSourceExplicit
  // es exactamente para esto (spec §4.2, comentario de
  // tenant-connection-registry.service.ts sobre "UN solo repositorio por
  // llamada... si necesita mas de una entidad, usa withTenantDataSourceExplicit").
  private async conRepositoriosExplicitos<R>(
    tenantId: number,
    fn: (repos: { parametros: Repository<TallerParametro>; historial: Repository<TallerParametroHistorial> }) => Promise<R>,
    opciones?: { allowOnboarding?: boolean },
  ): Promise<R> {
    return this.tenantConnectionRegistry.withTenantDataSourceExplicit(
      tenantId,
      (dataSource) =>
        fn({
          parametros: dataSource.getRepository(TallerParametro),
          historial: dataSource.getRepository(TallerParametroHistorial),
        }),
      opciones,
    );
  }
```

Agregar el import correspondiente al principio del archivo:

```ts
import { TallerParametroHistorial } from "./entities/taller-parametro-historial.entity";
```

- [ ] **Step 6: Correr las pruebas**

Run: `cd backend && npx jest src/taller-parametros/taller-parametros.service.spec.ts`
Expected: PASS, 7 pruebas en total.

- [ ] **Step 7: Commit**

```bash
git add backend/src/taller-parametros/taller-parametros.service.ts backend/src/taller-parametros/taller-parametros.service.spec.ts backend/src/tenancy/tenant-aware-approved-bypasses.json
git commit -m "feat(taller-parametros): superficie explícita (getExplicit/setExplicit) + bypass aprobado"
```

---

## Task 5: Validación `requeridoSi` (estado combinado) + historial de cambios

Reemplaza los cuerpos de `set`/`setParaTienda`/`setExplicit` escritos en Tasks
3-4 por versiones que delegan a escrituras en grupo con validación e historial
— así ninguna escritura real, de un solo campo o de varios, se salta la
validación. Ver spec §4.4 y §5.

**Files:**
- Modify: `backend/src/taller-parametros/taller-parametros.service.ts`
- Modify: `backend/src/taller-parametros/taller-parametros.service.spec.ts`

**Interfaces:**
- Produces: `setGrupo(valores: Partial<Record<TallerParametroClave, unknown>>, usuarioId: number): Promise<void>`, `setGrupoParaTienda(tiendaId: number, valores: Partial<Record<TallerParametroClave, unknown>>, usuarioId: number, destino?: string): Promise<void>`, `setGrupoParaTiendaExplicit(tenantId: number, tiendaId: number, valores: Partial<Record<TallerParametroClave, unknown>>, usuarioId: number, destino?: string): Promise<void>`. `set`/`setParaTienda`/`setExplicit` (Tasks 3-4) ahora son atajos de una sola clave sobre estos. Usados por Task 10 (`AuthService.updateTiendaFacturacionPos`), Task 20 (controlador).

- [ ] **Step 1: Escribir las pruebas de validación e historial**

Agregar al final de `taller-parametros.service.spec.ts`:

```ts
describe("TallerParametrosService — requeridoSi e historial", () => {
  it("rechaza activar facturacion_pos.habilitada sin los campos dependientes", async () => {
    const { service } = createService();
    await expect(
      service.setGrupoParaTienda(9, { "facturacion_pos.habilitada": true }, 1),
    ).rejects.toThrow(/es obligatorio porque/);
  });

  it("acepta activar facturacion_pos.habilitada cuando el grupo completo viene en la misma escritura", async () => {
    const { service } = createService();
    await expect(
      service.setGrupoParaTienda(
        9,
        {
          "facturacion_pos.habilitada": true,
          "facturacion_pos.url": "http://postouch/create_ordenes_edngt",
          "facturacion_pos.username": "22",
          "facturacion_pos.password": "secreta",
          "facturacion_pos.computadora": "5.5",
          "facturacion_pos.precio_decimales": 2,
        },
        1,
      ),
    ).resolves.toBeUndefined();
  });

  it("acepta reenviar solo un campo (ej. computadora) de una tienda YA configurada, sin repetir password", async () => {
    const { service } = createService();
    await service.setGrupoParaTienda(
      9,
      {
        "facturacion_pos.habilitada": true,
        "facturacion_pos.url": "http://postouch/create_ordenes_edngt",
        "facturacion_pos.username": "22",
        "facturacion_pos.password": "secreta",
        "facturacion_pos.computadora": "5.5",
        "facturacion_pos.precio_decimales": 2,
      },
      1,
    );

    await expect(
      service.setGrupoParaTienda(9, { "facturacion_pos.computadora": "9.1" }, 1),
    ).resolves.toBeUndefined();
    await expect(service.getParaTienda(9, "facturacion_pos.computadora")).resolves.toBe("9.1");
    await expect(service.getParaTienda(9, "facturacion_pos.password")).resolves.toBe("secreta");
  });

  it("desactivar facturacion_pos.habilitada NO exige los demás campos", async () => {
    const { service } = createService();
    await expect(
      service.setGrupoParaTienda(9, { "facturacion_pos.habilitada": false }, 1),
    ).resolves.toBeUndefined();
  });

  it("registra historial con valor anterior/nuevo para una clave NO sensible", async () => {
    const { service, historialRepo } = createService();
    await service.set("productos.permite_crear", false, 1);
    expect(historialRepo.save).toHaveBeenCalledWith(
      expect.objectContaining({ clave: "productos.permite_crear", valorAnterior: "true", valorNuevo: "false", usuarioId: 1 }),
    );
  });

  it("NUNCA registra el valor real de una clave sensible en el historial", async () => {
    const { service, historialRepo } = createService();
    await service.setParaTienda(9, "facturacion_pos.password", "secreta123", 1);
    expect(historialRepo.save).toHaveBeenCalledWith(
      expect.objectContaining({ clave: "facturacion_pos.password", valorAnterior: null, valorNuevo: null, usuarioId: 1 }),
    );
  });
});
```

- [ ] **Step 2: Correr las pruebas para verificar que fallan**

Run: `cd backend && npx jest src/taller-parametros/taller-parametros.service.spec.ts`
Expected: FAIL — `service.setGrupoParaTienda is not a function`.

- [ ] **Step 3: Implementar validación, historial y las escrituras en grupo**

Reemplazar en `taller-parametros.service.ts` los tres métodos `set`, `setParaTienda`,
`setExplicit` escritos en Tasks 3-4 por:

```ts
  async set<K extends TallerParametroClave>(clave: K, valor: ValorDe<K>, usuarioId: number): Promise<void> {
    await this.setGrupo({ [clave]: valor } as Partial<Record<TallerParametroClave, unknown>>, usuarioId);
  }

  async setParaTienda<K extends TallerParametroClave>(
    tiendaId: number,
    clave: K,
    valor: ValorDe<K>,
    usuarioId: number,
    destino = "",
  ): Promise<void> {
    await this.setGrupoParaTienda(
      tiendaId,
      { [clave]: valor } as Partial<Record<TallerParametroClave, unknown>>,
      usuarioId,
      destino,
    );
  }

  async setExplicit<K extends TallerParametroClave>(
    tenantId: number,
    clave: K,
    valor: ValorDe<K>,
    usuarioId: number,
  ): Promise<void> {
    await this.conRepositoriosExplicitos(tenantId, ({ parametros, historial }) =>
      this.escribirGrupo(
        parametros,
        historial,
        { [clave]: valor } as Partial<Record<TallerParametroClave, unknown>>,
        0,
        "",
        usuarioId,
      ),
    );
    this.cache.delete(tenantId);
  }

  async setGrupo(valores: Partial<Record<TallerParametroClave, unknown>>, usuarioId: number): Promise<void> {
    const tenantId = this.tenantAccessor.getCurrentTenantId();
    const parametros = this.tenantAccessor.repositoryFor(TallerParametro);
    const historial = this.tenantAccessor.repositoryFor(TallerParametroHistorial);
    await this.escribirGrupo(parametros, historial, valores, 0, "", usuarioId);
    this.cache.delete(tenantId);
  }

  async setGrupoParaTienda(
    tiendaId: number,
    valores: Partial<Record<TallerParametroClave, unknown>>,
    usuarioId: number,
    destino = "",
  ): Promise<void> {
    const tenantId = this.tenantAccessor.getCurrentTenantId();
    const parametros = this.tenantAccessor.repositoryFor(TallerParametro);
    const historial = this.tenantAccessor.repositoryFor(TallerParametroHistorial);
    await this.escribirGrupo(parametros, historial, valores, tiendaId, destino, usuarioId);
    this.cache.delete(tenantId);
  }

  async setGrupoParaTiendaExplicit(
    tenantId: number,
    tiendaId: number,
    valores: Partial<Record<TallerParametroClave, unknown>>,
    usuarioId: number,
    destino = "",
  ): Promise<void> {
    await this.conRepositoriosExplicitos(tenantId, ({ parametros, historial }) =>
      this.escribirGrupo(parametros, historial, valores, tiendaId, destino, usuarioId),
    );
    this.cache.delete(tenantId);
  }

  private async escribirGrupo(
    parametros: Repository<TallerParametro>,
    historial: Repository<TallerParametroHistorial>,
    valores: Partial<Record<TallerParametroClave, unknown>>,
    tiendaId: number,
    destino: string,
    usuarioId: number,
  ): Promise<void> {
    await this.validarRequeridoSi(parametros, valores, tiendaId, destino);

    for (const clave of Object.keys(valores) as TallerParametroClave[]) {
      const valorAnterior = await this.valorEfectivo(parametros, clave, {}, tiendaId, destino);
      const valorNuevo = valores[clave];
      await this.escribirFila(parametros, clave, valorNuevo, tiendaId, destino);
      await this.registrarHistorial(historial, clave, tiendaId, destino, valorAnterior, valorNuevo, usuarioId);
    }
  }

  // Revisa TODAS las claves del catálogo con requeridoSi que compartan el
  // ámbito de este grupo -- no solo las que vienen en `valoresNuevos`:
  // activar el gate (ej. habilitada=true) sin reenviar sus dependientes
  // tiene que fallar igual, y esas claves pueden no estar en el payload.
  private async validarRequeridoSi(
    parametros: Repository<TallerParametro>,
    valoresNuevos: Partial<Record<TallerParametroClave, unknown>>,
    tiendaId: number,
    destino: string,
  ): Promise<void> {
    const ambitoDeEsteGrupo: Ambito = tiendaId === 0 ? "tenant" : "tienda";
    const candidatas = clavesDelCatalogo().filter(
      (c) => entradaDe(c).ambito === ambitoDeEsteGrupo && entradaDe(c).requeridoSi,
    );

    for (const clave of candidatas) {
      const entrada = entradaDe(clave);
      const gate = entrada.requeridoSi as TallerParametroClave;
      const gateValor = await this.valorEfectivo(parametros, gate, valoresNuevos, tiendaId, destino);
      if (!gateValor) continue;

      const valorEfectivo = await this.valorEfectivo(parametros, clave, valoresNuevos, tiendaId, destino);
      if (valorEfectivo === "" || valorEfectivo === null || valorEfectivo === undefined) {
        throw new BadRequestException(
          `"${entrada.etiqueta}" es obligatorio porque "${entradaDe(gate).etiqueta}" está activado.`,
        );
      }
    }
  }

  // Estado combinado: lo que viene en ESTA escritura si está presente, o lo
  // ya guardado si no -- nunca solo el payload crudo. Necesario para que
  // editar un campo sin reenviar los demás (sobre todo password, donde
  // ausente siempre significa "no cambiar") siga pasando la validación.
  private async valorEfectivo(
    parametros: Repository<TallerParametro>,
    clave: TallerParametroClave,
    valoresNuevos: Partial<Record<TallerParametroClave, unknown>>,
    tiendaId: number,
    destino: string,
  ): Promise<unknown> {
    if (Object.prototype.hasOwnProperty.call(valoresNuevos, clave)) {
      return valoresNuevos[clave];
    }
    const fila = await parametros.findOne({ where: { clave, tiendaId, destino } });
    return this.deserializar(clave, fila);
  }

  private async registrarHistorial(
    historial: Repository<TallerParametroHistorial>,
    clave: TallerParametroClave,
    tiendaId: number,
    destino: string,
    valorAnterior: unknown,
    valorNuevo: unknown,
    usuarioId: number,
  ): Promise<void> {
    const entrada = entradaDe(clave);
    const nueva = historial.create({
      clave,
      ambito: entrada.ambito,
      tiendaId,
      destino,
      valorAnterior: entrada.sensible ? null : this.paraHistorial(valorAnterior),
      valorNuevo: entrada.sensible ? null : this.paraHistorial(valorNuevo),
      usuarioId,
    });
    await historial.save(nueva);
  }

  private paraHistorial(valor: unknown): string | null {
    if (valor === null || valor === undefined || valor === "") return null;
    return typeof valor === "object" ? JSON.stringify(valor) : String(valor);
  }
```

Agregar los imports que hagan falta al principio del archivo:

```ts
import { BadRequestException } from "@nestjs/common";
import { Ambito, clavesDelCatalogo } from "./taller-parametros.catalogo";
```

- [ ] **Step 4: Correr TODAS las pruebas del servicio (Tasks 3-5 juntas)**

Run: `cd backend && npx jest src/taller-parametros/taller-parametros.service.spec.ts`
Expected: PASS, 13 pruebas en total (5 de Task 3 + 2 de Task 4 + 6 de este Step).

- [ ] **Step 5: Commit**

```bash
git add backend/src/taller-parametros/taller-parametros.service.ts backend/src/taller-parametros/taller-parametros.service.spec.ts
git commit -m "feat(taller-parametros): validación requeridoSi sobre estado combinado + historial de cambios"
```

---

## Task 6: Puente perezoso desde `tenant_config` para los 3 toggles

Sin este puente, un tenant que todavía no pasó por la migración de Task 1 vería
los 3 toggles siempre en su default del catálogo (`true`), ignorando lo que
tenga guardado hoy en `tenant_config`. Ver spec §6.3.

**Files:**
- Modify: `backend/src/taller-parametros/taller-parametros.service.ts`
- Modify: `backend/src/taller-parametros/taller-parametros.service.spec.ts`

**Interfaces:**
- Consumes: `TenantConfigService.getTogglesOperativos(tenantId): Promise<{ permiteTiendas: boolean; permiteProductosPropios: boolean; facturaHabilitada: boolean }>` (ya inyectado desde Task 3).
- Produces: comportamiento de `get`/`getExplicit` sobre `"tiendas.permite_crear"`, `"productos.permite_crear"`, `"facturacion.usa_facturacion"` sin fila existente. No agrega métodos públicos nuevos.

- [ ] **Step 1: Escribir las pruebas del puente**

Agregar al final de `taller-parametros.service.spec.ts`:

```ts
describe("TallerParametrosService — puente de tenant_config para los 3 toggles", () => {
  it("get() de una clave-puente sin fila usa tenant_config y la sirve de inmediato", async () => {
    const tenantConfigService = {
      getTogglesOperativos: jest.fn(async () => ({
        permiteTiendas: false,
        permiteProductosPropios: true,
        facturaHabilitada: true,
      })),
    };
    const { service } = createService({ tenantConfigService });

    await expect(service.get("tiendas.permite_crear")).resolves.toBe(false);
    expect(tenantConfigService.getTogglesOperativos).toHaveBeenCalledWith(7);
  });

  it("intenta persistir la copia hacia taller_parametros, sin bloquear la respuesta", async () => {
    const tenantConfigService = {
      getTogglesOperativos: jest.fn(async () => ({
        permiteTiendas: false,
        permiteProductosPropios: true,
        facturaHabilitada: true,
      })),
    };
    const { service, repo } = createService({ tenantConfigService });

    await service.get("tiendas.permite_crear");
    await new Promise((resolve) => setImmediate(resolve)); // deja correr el fire-and-forget

    expect(repo.save).toHaveBeenCalled();
  });

  it("si la persistencia de respaldo falla, igual devuelve el valor correcto y no rompe la promesa", async () => {
    const tenantConfigService = {
      getTogglesOperativos: jest.fn(async () => ({
        permiteTiendas: true,
        permiteProductosPropios: true,
        facturaHabilitada: true,
      })),
    };
    const repoOverride = {
      save: jest.fn(async () => {
        throw new Error("DB caída");
      }),
    };
    const { service } = createService({ tenantConfigService, repo: repoOverride });

    await expect(service.get("tiendas.permite_crear")).resolves.toBe(true);
    await new Promise((resolve) => setImmediate(resolve)); // deja correr el .catch() interno
  });

  it("una segunda lectura del mismo tenant NO vuelve a consultar tenant_config -- sirve desde caché", async () => {
    const tenantConfigService = {
      getTogglesOperativos: jest.fn(async () => ({
        permiteTiendas: false,
        permiteProductosPropios: true,
        facturaHabilitada: true,
      })),
    };
    const { service } = createService({ tenantConfigService });

    await service.get("tiendas.permite_crear");
    await service.get("tiendas.permite_crear");

    expect(tenantConfigService.getTogglesOperativos).toHaveBeenCalledTimes(1);
  });

  it("una clave-puente CON fila ya guardada nunca toca tenant_config", async () => {
    const tenantConfigService = { getTogglesOperativos: jest.fn() };
    const { service } = createService({ tenantConfigService });

    await service.set("tiendas.permite_crear", true, 1);
    tenantConfigService.getTogglesOperativos.mockClear();

    await expect(service.get("tiendas.permite_crear")).resolves.toBe(true);
    expect(tenantConfigService.getTogglesOperativos).not.toHaveBeenCalled();
  });
});
```

- [ ] **Step 2: Correr las pruebas para verificar que fallan**

Run: `cd backend && npx jest src/taller-parametros/taller-parametros.service.spec.ts`
Expected: FAIL — sin el puente, `get("tiendas.permite_crear")` devuelve el
default del catálogo (`true`), no `false`; la primera prueba de este Step falla.

- [ ] **Step 3: Implementar el puente dentro de `resolver()`**

Reemplazar el método `resolver` de Task 3 por esta versión, y agregar
`resolverPuenteToggle` justo debajo:

```ts
  private async resolver<K extends TallerParametroClave>(
    tenantId: number,
    repo: Repository<TallerParametro>,
    clave: K,
    tiendaId: number,
    destino: string,
  ): Promise<ValorDe<K>> {
    const cacheKey = this.claveCache(clave, tiendaId, destino);
    const tenantCache = this.cache.get(tenantId);
    if (tenantCache?.has(cacheKey)) {
      return tenantCache.get(cacheKey) as ValorDe<K>;
    }

    const fila = await repo.findOne({ where: { clave, tiendaId, destino } });

    if (!fila && tiendaId === 0) {
      const campoLegacy = TallerParametrosService.PUENTE_TOGGLES[clave];
      if (campoLegacy) {
        const valor = await this.resolverPuenteToggle(tenantId, repo, clave, campoLegacy);
        this.guardarEnCache(tenantId, cacheKey, valor);
        return valor as ValorDe<K>;
      }
    }

    const valor = this.deserializar(clave, fila);
    this.guardarEnCache(tenantId, cacheKey, valor);
    return valor;
  }

  // Solo para las 3 claves-puente: SIN fila propia todavía, lee
  // tenant_config (plano de control) y sirve ese valor de inmediato. La
  // escritura de respaldo hacia taller_parametros corre FUERA del camino de
  // la respuesta (nunca `await`eada desde acá) y con su error atrapado --
  // si falla, la PRÓXIMA población de caché de este tenant (no cada
  // request, gracias a la caché de arriba) vuelve a intentarlo. Se borra
  // junto con las columnas viejas de tenant_config, tiquete futuro -- ver
  // spec §6.3.
  private async resolverPuenteToggle(
    tenantId: number,
    repo: Repository<TallerParametro>,
    clave: TallerParametroClave,
    campoLegacy: keyof TogglesOperativos,
  ): Promise<unknown> {
    const toggles = await this.tenantConfigService.getTogglesOperativos(tenantId);
    const valor = toggles[campoLegacy];

    this.escribirFila(repo, clave, valor, 0, "").catch((err) =>
      this.logger.warn(`No se pudo persistir el puente de ${clave} para tenant ${tenantId}: ${err?.message ?? err}`),
    );

    return valor;
  }
```

Agregar el mapa estático y el tipo auxiliar, dentro de la clase (el mapa) y
antes de ella (el tipo):

```ts
type TogglesOperativos = Awaited<ReturnType<TenantConfigService["getTogglesOperativos"]>>;
```

```ts
  private static readonly PUENTE_TOGGLES: Partial<Record<TallerParametroClave, keyof TogglesOperativos>> = {
    "tiendas.permite_crear": "permiteTiendas",
    "productos.permite_crear": "permiteProductosPropios",
    "facturacion.usa_facturacion": "facturaHabilitada",
  };
```

- [ ] **Step 4: Correr todas las pruebas del servicio**

Run: `cd backend && npx jest src/taller-parametros/taller-parametros.service.spec.ts`
Expected: PASS, 18 pruebas en total.

- [ ] **Step 5: Commit**

```bash
git add backend/src/taller-parametros/taller-parametros.service.ts backend/src/taller-parametros/taller-parametros.service.spec.ts
git commit -m "feat(taller-parametros): puente perezoso desde tenant_config para los 3 toggles, con memo en caché"
```

---

## Task 7: Fail-closed/fail-open por clave, `getFlags()`, y checklist para `control-plane-admin`

Ver spec §4.5 (tabla de comportamientoAnteFallo) y §7.5 (flags). El checklist
operativo de `control-plane-admin` usa `null` (no booleano) para "no se pudo
calcular" — es un contrato distinto al de `getFlags()`, no el mismo helper.

**Files:**
- Modify: `backend/src/taller-parametros/taller-parametros.service.ts`
- Modify: `backend/src/taller-parametros/taller-parametros.service.spec.ts`

**Interfaces:**
- Produces: `getBooleanConFallback(clave): Promise<boolean>`, `getBooleanConFallbackExplicit(tenantId, clave): Promise<boolean>`, `getFlags(): Promise<FlagsResumen>` (tipo `{ usaFacturacion: boolean; permiteTiendas: boolean; permiteProductosPropios: boolean; usaWhatsapp: boolean }`), `getTogglesOperativosAdmin(tenantId: number): Promise<{ permiteTiendas: boolean | null; permiteProductosPropios: boolean | null; facturaHabilitada: boolean | null }>`. `getBooleanConFallback(Explicit)` usados por Tasks 10-13, `getFlags` por Task 20, `getTogglesOperativosAdmin` por Task 9.

- [ ] **Step 1: Escribir las pruebas**

Agregar al final de `taller-parametros.service.spec.ts`:

```ts
describe("TallerParametrosService — comportamiento ante fallo", () => {
  it("getBooleanConFallback devuelve false (bloquear) para un gate que falla y bloquea por default", async () => {
    const repoQueFalla = {
      findOne: jest.fn(async () => {
        throw new Error("DB caída");
      }),
    };
    const { service } = createService({ repo: repoQueFalla });
    await expect(service.getBooleanConFallback("tiendas.permite_crear")).resolves.toBe(false);
  });

  it("getBooleanConFallback devuelve true (permitir) para un gate que falla y permite por default", async () => {
    const repoQueFalla = {
      findOne: jest.fn(async () => {
        throw new Error("DB caída");
      }),
    };
    const { service } = createService({ repo: repoQueFalla });
    await expect(service.getBooleanConFallback("productos.permite_crear")).resolves.toBe(true);
  });

  it("getFlags() nunca lanza -- cada clave aplica su propio comportamientoAnteFallo si falla", async () => {
    const repoQueFalla = {
      findOne: jest.fn(async () => {
        throw new Error("DB caída");
      }),
    };
    const { service } = createService({ repo: repoQueFalla });

    await expect(service.getFlags()).resolves.toEqual({
      usaFacturacion: false,
      permiteTiendas: false,
      permiteProductosPropios: true,
      usaWhatsapp: false,
    });
  });

  it("getFlags() en condiciones normales refleja los valores guardados", async () => {
    const { service } = createService();
    await service.set("facturacion.usa_facturacion", false, 1);

    await expect(service.getFlags()).resolves.toEqual({
      usaFacturacion: false,
      permiteTiendas: true,
      permiteProductosPropios: true,
      usaWhatsapp: false,
    });
  });

  it("getTogglesOperativosAdmin devuelve null por clave (no tumba las otras) si la tabla no existe todavía", async () => {
    const repoQueFalla = {
      findOne: jest.fn(async () => {
        throw new Error("Table 'taller_parametros' doesn't exist");
      }),
    };
    const dataSource = { getRepository: jest.fn(() => repoQueFalla) };
    const tenantConnectionRegistry = {
      withTenantDataSourceExplicit: jest.fn(async (_tenantId: number, fn: any) => fn(dataSource)),
    };
    const { service } = createService({ tenantConnectionRegistry });

    await expect(service.getTogglesOperativosAdmin(99)).resolves.toEqual({
      permiteTiendas: null,
      permiteProductosPropios: null,
      facturaHabilitada: null,
    });
  });

  it("getTogglesOperativosAdmin pide allowOnboarding:true", async () => {
    const repoOk = { findOne: jest.fn(async () => null) };
    const dataSource = { getRepository: jest.fn(() => repoOk) };
    const tenantConnectionRegistry = {
      withTenantDataSourceExplicit: jest.fn(async (_tenantId: number, fn: any) => fn(dataSource)),
    };
    const { service } = createService({ tenantConnectionRegistry });

    await service.getTogglesOperativosAdmin(99);

    expect(tenantConnectionRegistry.withTenantDataSourceExplicit).toHaveBeenCalledWith(99, expect.any(Function), {
      allowOnboarding: true,
    });
  });
});
```

- [ ] **Step 2: Correr las pruebas para verificar que fallan**

Run: `cd backend && npx jest src/taller-parametros/taller-parametros.service.spec.ts`
Expected: FAIL — `service.getBooleanConFallback is not a function`.

- [ ] **Step 3: Implementar**

Agregar a `taller-parametros.service.ts`:

```ts
  async getBooleanConFallback(clave: TallerParametroClave): Promise<boolean> {
    try {
      return Boolean(await this.get(clave));
    } catch (err: any) {
      this.logger.warn(`No se pudo leer ${clave}: ${err?.message ?? err}. Aplicando comportamientoAnteFallo.`);
      return entradaDe(clave).comportamientoAnteFallo === "permitir";
    }
  }

  async getBooleanConFallbackExplicit(tenantId: number, clave: TallerParametroClave): Promise<boolean> {
    try {
      return Boolean(await this.getExplicit(tenantId, clave));
    } catch (err: any) {
      this.logger.warn(
        `No se pudo leer ${clave} (tenant ${tenantId}): ${err?.message ?? err}. Aplicando comportamientoAnteFallo.`,
      );
      return entradaDe(clave).comportamientoAnteFallo === "permitir";
    }
  }

  async getFlags(): Promise<FlagsResumen> {
    const [usaFacturacion, permiteTiendas, permiteProductosPropios, usaWhatsapp] = await Promise.all([
      this.getBooleanConFallback("facturacion.usa_facturacion"),
      this.getBooleanConFallback("tiendas.permite_crear"),
      this.getBooleanConFallback("productos.permite_crear"),
      this.getBooleanConFallback("notificaciones.usa_whatsapp"),
    ]);
    return { usaFacturacion, permiteTiendas, permiteProductosPropios, usaWhatsapp };
  }

  // Contrato distinto a getBooleanConFallback: null significa "no se pudo
  // calcular", no una política de bloquear/permitir -- tenants-admin.service.ts
  // ya trata null como bloqueante en activarTenant() (ver Task 9). Cada clave
  // se atrapa por separado: si una falla, no tumba el resumen completo.
  // allowOnboarding:true porque este checklist corre ANTES de "Activar",
  // mismo motivo que provisionarPrimerAcceso.
  async getTogglesOperativosAdmin(
    tenantId: number,
  ): Promise<{ permiteTiendas: boolean | null; permiteProductosPropios: boolean | null; facturaHabilitada: boolean | null }> {
    const leer = async (clave: TallerParametroClave): Promise<boolean | null> => {
      try {
        return Boolean(await this.getExplicit(tenantId, clave, { allowOnboarding: true }));
      } catch (err: any) {
        this.logger.warn(
          `No se pudo leer ${clave} para tenant ${tenantId} (checklist operativo): ${err?.message ?? err}`,
        );
        return null;
      }
    };

    const [permiteTiendas, permiteProductosPropios, facturaHabilitada] = await Promise.all([
      leer("tiendas.permite_crear"),
      leer("productos.permite_crear"),
      leer("facturacion.usa_facturacion"),
    ]);

    return { permiteTiendas, permiteProductosPropios, facturaHabilitada };
  }
```

Agregar el tipo exportado, antes de la clase:

```ts
export type FlagsResumen = {
  usaFacturacion: boolean;
  permiteTiendas: boolean;
  permiteProductosPropios: boolean;
  usaWhatsapp: boolean;
};
```

- [ ] **Step 4: Correr todas las pruebas del servicio**

Run: `cd backend && npx jest src/taller-parametros/taller-parametros.service.spec.ts`
Expected: PASS, 24 pruebas en total.

- [ ] **Step 5: Commit**

```bash
git add backend/src/taller-parametros/taller-parametros.service.ts backend/src/taller-parametros/taller-parametros.service.spec.ts
git commit -m "feat(taller-parametros): fail-closed/fail-open por clave, getFlags(), checklist admin"
```

---

## Task 8: `TallerParametrosModule` y registro en `app.module.ts`

Solo el servicio por ahora — el controlador llega en Task 20, cuando también
hace falta resolver la dependencia circular con `FacturacionPosModule` (sonda
de conexión). Sin `TypeOrmModule.forFeature()`: `TallerParametro`/
`TallerParametroHistorial` se resuelven vía `dataSource.getRepository()`
directo (`TenantRepositoryAccessor`/`TenantConnectionRegistry`), nunca por DI
de Nest — confirmado que el glob de entidades del tenant
(`!(control-plane|control-plane-admin|tenancy)/**/*.entity{.ts,.js}`) ya
recoge `taller-parametros/entities/*.entity.ts` sin configuración adicional.

**Files:**
- Create: `backend/src/taller-parametros/taller-parametros.module.ts`
- Modify: `backend/src/app.module.ts`

**Interfaces:**
- Consumes: `TenancyModule` (`../tenancy/tenancy.module`, exporta `TenantRepositoryAccessor`, `TenantConnectionRegistry`, y re-exporta `ControlPlaneModule` — que a su vez exporta `TenantConfigService`).
- Produces: `TallerParametrosModule`, con `TallerParametrosService` en `providers` y `exports`. Consumido por Tasks 10-14 (cada uno agrega `TallerParametrosModule` a los `imports` de su propio módulo cuando lo necesita) y Task 20 (agrega el controlador a este módulo).

- [ ] **Step 1: Crear el módulo**

```ts
// backend/src/taller-parametros/taller-parametros.module.ts
import { Module } from "@nestjs/common";
import { TenancyModule } from "../tenancy/tenancy.module";
import { TallerParametrosService } from "./taller-parametros.service";

@Module({
  imports: [TenancyModule],
  providers: [TallerParametrosService],
  exports: [TallerParametrosService],
})
export class TallerParametrosModule {}
```

- [ ] **Step 2: Registrar en `app.module.ts`**

Agregar `TallerParametrosModule` a la lista de `imports`, junto a los demás
módulos de features (después de `TenancyModule`/`ControlPlaneAdminModule`, en
el mismo bloque que `AuthModule`/`ProductosModule`/etc.), y su import
correspondiente al principio del archivo.

- [ ] **Step 3: Build**

Run: `cd backend && npm run build`
Expected: sin errores — el módulo compila e inicializa sin dependencias
faltantes (nada lo consume todavía, así que no hay ciclo posible en este
punto).

- [ ] **Step 4: Commit**

```bash
git add backend/src/taller-parametros/taller-parametros.module.ts backend/src/app.module.ts
git commit -m "feat(taller-parametros): registrar TallerParametrosModule"
```

---

## Task 9: `tenants-admin.service.ts` lee y escribe los toggles vía `TallerParametrosService`

Fuente de verdad pasa de `tenant_config` (control-plane) a `taller_parametros`
(tenant). `TenantConfigService` deja de inyectarse en este archivo — ya no lo
usa nada acá (el puente que antes lo hubiera necesitado vive ahora DENTRO de
`TallerParametrosService`, Task 6). `getTogglesOperativosAdmin` devuelve
`boolean | null` por campo — hay que propagar el `null` como bloqueante en
`activarTenant()`, igual que ya se trata `tiendas`/`productos`/
`tiendasConFacturacionPos`.

**Files:**
- Modify: `backend/src/control-plane-admin/control-plane-admin.module.ts`
- Modify: `backend/src/control-plane-admin/tenants-admin.service.ts`
- Create: `backend/src/control-plane-admin/tenants-admin.service.spec.ts` (no existe hoy pese a que el archivo real tiene 490 líneas — esta tarea lo crea, acotado a `getResumenOperativo`/`activarTenant`/`updateTogglesOperativos`, no una suite completa del servicio)

**Interfaces:**
- Consumes: `TallerParametrosService.getTogglesOperativosAdmin(tenantId)`, `.setExplicit(tenantId, clave, valor, usuarioId)` (Tasks 4, 5, 7).
- Produces: `TenantsAdminService.getResumenOperativo(tenantId)` con `toggles: { permiteTiendas: boolean | null; permiteProductosPropios: boolean | null; facturaHabilitada: boolean | null }` (antes no-nullable). `updateTogglesOperativos` mantiene su firma pública sin cambios.

- [ ] **Step 1: Importar `TallerParametrosModule` en `control-plane-admin.module.ts`**

Agregar `TallerParametrosModule` a los `imports` de `ControlPlaneAdminModule`,
junto al import correspondiente al principio del archivo.

- [ ] **Step 2: Escribir el archivo de pruebas nuevo**

`getEstadoDetallado`/`getResumenOperativo` son métodos públicos que hacen su
propia conexión `mysql2/promise` (no vía TypeORM) — para probar la lógica de
`activarTenant` sobre sus resultados, sin reimplementar esa conexión, se los
mockea directo con `jest.spyOn` en las pruebas de `activarTenant`. La prueba
de `getResumenOperativo` en sí (que es la que de verdad ejercita mi cambio de
fuente de los toggles) sí simula `mysql2/promise` con `jest.mock`, mínimo
indispensable para que no intente conectarse a una base real.

```ts
// backend/src/control-plane-admin/tenants-admin.service.spec.ts
import * as mysql from "mysql2/promise";
import { TenantEstado } from "../control-plane/entities/tenant.entity";
import { TenantsAdminService } from "./tenants-admin.service";

jest.mock("mysql2/promise");

function createService(overrides: Record<string, any> = {}) {
  const tenant = {
    id: 42,
    codigo: "prueba",
    estado: TenantEstado.ONBOARDING,
    dbHost: "host",
    dbPort: 3306,
    dbUser: "user",
    dbPasswordEncrypted: "ENC(pass)",
    dbName: "db",
  };

  const tenantRecords = {
    findById: jest.fn(async () => tenant),
    updateEstado: jest.fn(async (id: number, estado: any) => ({ ...tenant, estado })),
    ...overrides.tenantRecords,
  };

  const crypto = {
    decrypt: jest.fn((blob: string) => blob.replace(/^ENC\(|\)$/g, "")),
    ...overrides.crypto,
  };

  const schemaCheck = {
    getPendingMigrations: jest.fn(async () => []),
    getColumnDeviations: jest.fn(async () => []),
    ...overrides.schemaCheck,
  };

  const tallerParametrosService = {
    getTogglesOperativosAdmin: jest.fn(async () => ({
      permiteTiendas: true,
      permiteProductosPropios: true,
      facturaHabilitada: true,
    })),
    setExplicit: jest.fn(async () => undefined),
    ...overrides.tallerParametrosService,
  };

  const service = new TenantsAdminService(
    tenantRecords as any,
    crypto as any,
    schemaCheck as any,
    {} as any, // identityService -- no usado en getResumenOperativo/activarTenant/updateTogglesOperativos
    {} as any, // tenantRegistry -- idem
    {} as any, // reconciliationService -- idem
    tallerParametrosService as any,
  );

  return { service, tenant, tenantRecords, crypto, schemaCheck, tallerParametrosService };
}

describe("TenantsAdminService.getResumenOperativo", () => {
  it("lee los toggles vía TallerParametrosService, no TenantConfigService", async () => {
    const mockConn = { query: jest.fn(async () => [[{ c: 0 }]]), end: jest.fn(async () => undefined) };
    (mysql.createConnection as jest.Mock).mockResolvedValue(mockConn);

    const { service, tallerParametrosService } = createService();

    const resumen = await service.getResumenOperativo(42);

    expect(tallerParametrosService.getTogglesOperativosAdmin).toHaveBeenCalledWith(42);
    expect(resumen.toggles).toEqual({
      permiteTiendas: true,
      permiteProductosPropios: true,
      facturaHabilitada: true,
    });
  });
});

describe("TenantsAdminService.activarTenant", () => {
  it("un toggle en null (no se pudo calcular) bloquea igual que un conteo en null", async () => {
    const { service } = createService();
    jest.spyOn(service, "getEstadoDetallado").mockResolvedValue({
      tenant: {} as any,
      acceso: { ok: true },
      migracionesPendientes: [],
      desviaciones: [],
    });
    jest.spyOn(service, "getResumenOperativo").mockResolvedValue({
      tiendas: 0,
      tiendasConFacturacionPos: 5,
      productos: 5,
      toggles: { permiteTiendas: null, permiteProductosPropios: true, facturaHabilitada: true },
    });

    await expect(service.activarTenant(42)).rejects.toThrow(/no hay ninguna tienda creada/);
  });

  it("un toggle en false SÍ se salta el chequeo correspondiente", async () => {
    const { service } = createService();
    jest.spyOn(service, "getEstadoDetallado").mockResolvedValue({
      tenant: {} as any,
      acceso: { ok: true },
      migracionesPendientes: [],
      desviaciones: [],
    });
    jest.spyOn(service, "getResumenOperativo").mockResolvedValue({
      tiendas: 0,
      tiendasConFacturacionPos: 5,
      productos: 5,
      toggles: { permiteTiendas: false, permiteProductosPropios: true, facturaHabilitada: true },
    });

    await expect(service.activarTenant(42)).resolves.toBeDefined();
  });
});

describe("TenantsAdminService.updateTogglesOperativos", () => {
  it("escribe en taller_parametros vía setExplicit, con el actor plataforma (0), no en tenant_config", async () => {
    const { service, tallerParametrosService } = createService();

    await service.updateTogglesOperativos(42, { permiteTiendas: false });

    expect(tallerParametrosService.setExplicit).toHaveBeenCalledWith(42, "tiendas.permite_crear", false, 0);
  });

  it("solo escribe los campos presentes en el DTO", async () => {
    const { service, tallerParametrosService } = createService();

    await service.updateTogglesOperativos(42, { facturaHabilitada: false });

    expect(tallerParametrosService.setExplicit).toHaveBeenCalledTimes(1);
    expect(tallerParametrosService.setExplicit).toHaveBeenCalledWith(
      42,
      "facturacion.usa_facturacion",
      false,
      0,
    );
  });
});
```

- [ ] **Step 3: Correr las pruebas para verificar que fallan**

Run: `cd backend && npx jest src/control-plane-admin/tenants-admin.service.spec.ts`
Expected: FAIL — el constructor real todavía espera `tenantConfigService`, no `tallerParametrosService`.

- [ ] **Step 4: Reescribir el constructor, `getResumenOperativo`, `activarTenant` y `updateTogglesOperativos`**

En `tenants-admin.service.ts`: quitar el import de `TenantConfigService` y
agregar el de `TallerParametrosService`/`TallerParametroClave`. En el
constructor, reemplazar el parámetro `tenantConfigService: TenantConfigService`
por `tallerParametrosService: TallerParametrosService`.

`getResumenOperativo` — reemplazar la línea
`const toggles = await this.tenantConfigService.getTogglesOperativos(tenantId);`
por:

```ts
    const toggles = await this.tallerParametrosService.getTogglesOperativosAdmin(tenantId);
```

Y el tipo de retorno del método, de
`toggles: { permiteTiendas: boolean; permiteProductosPropios: boolean; facturaHabilitada: boolean }`
a
`toggles: { permiteTiendas: boolean | null; permiteProductosPropios: boolean | null; facturaHabilitada: boolean | null }`.

En `activarTenant`, reemplazar el bloque de `faltantes`:

```ts
    const resumen = await this.getResumenOperativo(tenantId);
    const faltantes: string[] = [];
    // !== false (true O null) trata "no se pudo calcular" igual que "esta
    // prendido": el chequeo se aplica por las dudas, en vez de saltarselo en
    // silencio -- mismo criterio de "null es bloqueante" que ya usan
    // tiendas/productos/tiendasConFacturacionPos mas abajo.
    if (resumen.toggles.permiteTiendas !== false && (resumen.tiendas ?? 0) === 0) {
      faltantes.push("no hay ninguna tienda creada");
    }
    if (resumen.toggles.facturaHabilitada !== false && (resumen.tiendasConFacturacionPos ?? 0) === 0) {
      faltantes.push("ninguna tienda tiene facturacion POS habilitada");
    }
    if (resumen.toggles.permiteProductosPropios !== false && (resumen.productos ?? 0) === 0) {
      faltantes.push("no hay ningun producto propio creado");
    }
```

`updateTogglesOperativos` — reemplazar el cuerpo completo:

```ts
  // Actor "0" a propósito: esta escritura viene del panel de PLATAFORMA
  // (operador de MacroBase), no de un Usuario local del tenant -- 0 nunca es
  // un Usuario.id real (autoincrement empieza en 1), así que queda como
  // centinela distinguible en taller_parametros_historial, igual que
  // tienda_id=0 ya significa "no aplica" en esa misma tabla.
  private static readonly ACTOR_PLATAFORMA = 0;

  async updateTogglesOperativos(
    tenantId: number,
    dto: UpdateTogglesOperativosDto,
  ): Promise<{ permiteTiendas: boolean; permiteProductosPropios: boolean; facturaHabilitada: boolean }> {
    await this.findTenantOrThrow(tenantId);

    const mapa: Array<{ campo: keyof UpdateTogglesOperativosDto; clave: TallerParametroClave }> = [
      { campo: "permiteTiendas", clave: "tiendas.permite_crear" },
      { campo: "permiteProductosPropios", clave: "productos.permite_crear" },
      { campo: "facturaHabilitada", clave: "facturacion.usa_facturacion" },
    ];

    for (const { campo, clave } of mapa) {
      const valor = dto[campo];
      if (valor !== undefined) {
        await this.tallerParametrosService.setExplicit(
          tenantId,
          clave,
          valor,
          TenantsAdminService.ACTOR_PLATAFORMA,
        );
      }
    }

    const actualizado = await this.tallerParametrosService.getTogglesOperativosAdmin(tenantId);
    return {
      permiteTiendas: actualizado.permiteTiendas ?? true,
      permiteProductosPropios: actualizado.permiteProductosPropios ?? true,
      facturaHabilitada: actualizado.facturaHabilitada ?? true,
    };
  }
```

- [ ] **Step 5: Build y correr las pruebas**

Run: `cd backend && npm run build && npx jest src/control-plane-admin/tenants-admin.service.spec.ts`
Expected: build sin errores. Si `tenants-admin.controller.ts` (u otro
consumidor) tipa `toggles` como no-nullable en algún DTO de respuesta, el
build lo va a señalar ahí — ensanchar ese tipo a `boolean | null` en ese
mismo archivo antes de continuar. Pruebas: PASS.

- [ ] **Step 6: Commit**

```bash
git add backend/src/control-plane-admin/control-plane-admin.module.ts backend/src/control-plane-admin/tenants-admin.service.ts backend/src/control-plane-admin/tenants-admin.service.spec.ts
git commit -m "refactor(control-plane-admin): leer/escribir toggles operativos vía taller_parametros"
```

---

## Task 10: Gate real en `POST /auth/tiendas`

`AuthController`'s rutas de tiendas no tienen `TenantGuard` (confirmado
leyendo `auth.controller.ts` — solo `JwtAuthGuard`), así que `AuthService`
resuelve `tenantId` del JWT y usa la superficie EXPLÍCITA. Ver spec §7.1.

**Files:**
- Modify: `backend/src/auth/auth.module.ts`
- Modify: `backend/src/auth/auth.service.ts`
- Modify: `backend/src/auth/auth.service.spec.ts`

**Interfaces:**
- Consumes: `TallerParametrosService.getBooleanConFallbackExplicit(tenantId, clave): Promise<boolean>` (Task 7).
- Produces: `AuthService.createTienda` lanza `ForbiddenException` cuando `tiendas.permite_crear` es `false`.

- [ ] **Step 1: Importar `TallerParametrosModule` en `auth.module.ts`**

Agregar `TallerParametrosModule` a los `imports` de `AuthModule`, junto al
import correspondiente al principio del archivo.

- [ ] **Step 2: Escribir la prueba que falla**

Agregar a `auth.service.spec.ts`, junto al resto de `describe` (usar el mismo
`createService` del archivo, agregando `tallerParametros` a sus overrides y a
la construcción de `AuthService` — ver Step 4):

```ts
describe("AuthService.createTienda", () => {
  it("rechaza con ForbiddenException si tiendas.permite_crear está en false", async () => {
    const tallerParametros = { getBooleanConFallbackExplicit: jest.fn(async () => false) };
    const { service } = createService({ tallerParametros });

    await expect(
      service.createTienda(42, { nombre: "Sucursal nueva" }),
    ).rejects.toThrow(/no permite crear tiendas/);
    expect(tallerParametros.getBooleanConFallbackExplicit).toHaveBeenCalledWith(42, "tiendas.permite_crear");
  });

  it("crea la tienda normalmente cuando tiendas.permite_crear está en true", async () => {
    const tallerParametros = { getBooleanConFallbackExplicit: jest.fn(async () => true) };
    const { service, tiendaRepo } = createService({ tallerParametros });

    await service.createTienda(42, { nombre: "Sucursal nueva" });

    expect(tiendaRepo.save).toHaveBeenCalled();
  });
});
```

Actualizar `createService` en ese archivo: agregar un mock por defecto
`tallerParametros = { getBooleanConFallbackExplicit: jest.fn(async () => true), ...overrides.tallerParametros }`
y pasarlo como quinto argumento posicional a `new AuthService(tenantRegistry, identityService, {} as any, credentialsCrypto, tallerParametros)`,
agregándolo también al objeto que retorna la función.

- [ ] **Step 3: Correr las pruebas para verificar que fallan**

Run: `cd backend && npx jest src/auth/auth.service.spec.ts -t createTienda`
Expected: FAIL — `AuthService` no tiene un quinto parámetro todavía, la
llamada a `getBooleanConFallbackExplicit` nunca ocurre.

- [ ] **Step 4: Inyectar `TallerParametrosService` y gatear `createTienda`**

En `auth.service.ts`, agregar el import y el quinto parámetro del
constructor:

```ts
import { TallerParametrosService } from "../taller-parametros/taller-parametros.service";
```

```ts
  constructor(
    private readonly tenantRegistry: TenantConnectionRegistry,
    private readonly identityService: IdentityService,
    private jwtService: JwtService,
    private readonly credentialsCrypto: CredentialsCryptoService,
    private readonly tallerParametros: TallerParametrosService,
  ) {}
```

Reemplazar el cuerpo de `createTienda`:

```ts
  async createTienda(tenantId: number, dto: CreateTiendaDto) {
    const permiteTiendas = await this.tallerParametros.getBooleanConFallbackExplicit(
      tenantId,
      "tiendas.permite_crear",
    );
    if (!permiteTiendas) {
      throw new ForbiddenException(
        "Este tenant no permite crear tiendas -- activá 'Permitir crear tiendas' en Configuración → Tiendas.",
      );
    }

    return this.withTenantRepo(tenantId, Tienda, async (tiendaRepo) => {
      const raw = await tiendaRepo
        .createQueryBuilder("tienda")
        .select("MAX(tienda.id)", "max")
        .getRawOne<{ max: number | null }>();
      const nextId = (raw?.max || 0) + 1;

      const tienda = tiendaRepo.create({
        id: nextId,
        nombre: dto.nombre,
        codigo: dto.codigo ?? null,
        direccion: dto.direccion ?? null,
        telefonoImpresion: dto.telefonoImpresion ?? null,
        status: 0,
        alta: "S",
        esTransito: "N",
        creadaEnTaller: true,
      });

      const saved = await tiendaRepo.save(tienda);
      return this.serializeTienda(saved);
    });
  }
```

Agregar `ForbiddenException` al import existente de `@nestjs/common` en la
cabecera del archivo (hoy importa `ConflictException, BadRequestException,
Injectable, Logger, NotFoundException, UnauthorizedException` — agregar
`ForbiddenException` a esa misma línea).

- [ ] **Step 5: Correr las pruebas**

Run: `cd backend && npx jest src/auth/auth.service.spec.ts`
Expected: PASS (todo el archivo, no solo `createTienda` — el quinto parámetro
del constructor no debe romper ninguna prueba existente).

- [ ] **Step 6: Commit**

```bash
git add backend/src/auth/auth.module.ts backend/src/auth/auth.service.ts backend/src/auth/auth.service.spec.ts
git commit -m "feat(auth): POST /auth/tiendas rechaza si tiendas.permite_crear está apagado"
```

---

## Task 11: Gate real en crear/editar productos locales

`ProductosController` corre bajo `TenantGuard` (`@UseGuards(JwtAuthGuard,
TenantGuard, PermissionGuard)`), así que `ProductosService` usa la superficie
AMBIENTAL. No existe `productos.service.spec.ts` hoy — esta tarea lo crea,
acotado a la validación nueva (no es una suite completa del servicio, eso está
fuera de alcance de este tiquete). Ver spec §7.2.

**Files:**
- Modify: `backend/src/productos/productos.module.ts`
- Modify: `backend/src/productos/productos.service.ts`
- Create: `backend/src/productos/productos.service.spec.ts`

**Interfaces:**
- Consumes: `TallerParametrosService.getBooleanConFallback(clave): Promise<boolean>` (Task 7).
- Produces: `ProductosService.create`/`update` lanzan `ForbiddenException` cuando `productos.permite_crear` es `false`.

- [ ] **Step 1: Importar `TallerParametrosModule` en `productos.module.ts`**

Agregar `TallerParametrosModule` a los `imports` de `ProductosModule`, junto al import correspondiente.

- [ ] **Step 2: Escribir la prueba que falla**

```ts
// backend/src/productos/productos.service.spec.ts
import { ForbiddenException } from "@nestjs/common";
import { ProductosService } from "./productos.service";
import { ProductoLocal } from "./entities/producto-local.entity";
import { Producto } from "./entities/producto.entity";

function createService(overrides: Record<string, any> = {}) {
  const localRepo = {
    findOne: jest.fn(async () => null),
    create: jest.fn((d: any) => d),
    save: jest.fn(async (d: any) => ({ ...d, plu: d.plu ?? "TLLP-00000001" })),
    createQueryBuilder: jest.fn(() => ({
      where: jest.fn().mockReturnThis(),
      andWhere: jest.fn().mockReturnThis(),
      take: jest.fn().mockReturnThis(),
      getMany: jest.fn(async () => []),
      getOne: jest.fn(async () => null),
    })),
    ...overrides.localRepo,
  };

  const erpRepo = {
    findOne: jest.fn(async () => null),
    ...overrides.erpRepo,
  };

  const tenantAccessor = {
    repositoryFor: jest.fn((entity: any) => (entity === ProductoLocal ? localRepo : erpRepo)),
    ...overrides.tenantAccessor,
  };

  const maepluMirrorService = { insertIfMissing: jest.fn(async () => undefined), ...overrides.maepluMirrorService };

  const tallerParametros = {
    getBooleanConFallback: jest.fn(async () => true),
    ...overrides.tallerParametros,
  };

  const service = new ProductosService(tenantAccessor as any, maepluMirrorService as any, tallerParametros as any);

  return { service, localRepo, erpRepo, tenantAccessor, maepluMirrorService, tallerParametros };
}

describe("ProductosService — gate de productos.permite_crear", () => {
  it("create() rechaza con ForbiddenException si el toggle está apagado", async () => {
    const tallerParametros = { getBooleanConFallback: jest.fn(async () => false) };
    const { service } = createService({ tallerParametros });

    await expect(service.create({ desclarga: "Aceite 5W30" } as any)).rejects.toThrow(ForbiddenException);
    expect(tallerParametros.getBooleanConFallback).toHaveBeenCalledWith("productos.permite_crear");
  });

  it("update() rechaza con ForbiddenException si el toggle está apagado", async () => {
    const tallerParametros = { getBooleanConFallback: jest.fn(async () => false) };
    const { service } = createService({ tallerParametros });

    await expect(service.update("TLLP-1", { desclarga: "x" } as any)).rejects.toThrow(ForbiddenException);
  });

  it("create() procede normalmente cuando el toggle está en true", async () => {
    const { service, localRepo } = createService();

    await service.create({ desclarga: "Aceite 5W30" } as any);

    expect(localRepo.save).toHaveBeenCalled();
  });
});
```

Nota: si la firma real de `create()`/`update()` valida otros campos del DTO
antes de llegar al fondo del método (ver `productos.service.ts` actual), este
mock puede necesitar ajustes menores de forma (no de intención) para que las
pruebas de "toggle apagado" no fallen por un motivo distinto al gate — el
punto de la prueba es que el `ForbiddenException` se lance ANTES que
cualquier otra validación de negocio, así que alcanza con un DTO mínimo.

- [ ] **Step 3: Correr las pruebas para verificar que fallan**

Run: `cd backend && npx jest src/productos/productos.service.spec.ts`
Expected: FAIL — `ProductosService` no tiene un tercer parámetro todavía.

- [ ] **Step 4: Inyectar `TallerParametrosService` y gatear `create`/`update`**

En `productos.service.ts`, agregar el import y el tercer parámetro del
constructor:

```ts
import { ForbiddenException } from "@nestjs/common"; // agregar a la línea de import existente de @nestjs/common
import { TallerParametrosService } from "../taller-parametros/taller-parametros.service";
```

```ts
  constructor(
    private readonly tenantAccessor: TenantRepositoryAccessor,
    private readonly maepluMirrorService: MaepluMirrorService,
    private readonly tallerParametros: TallerParametrosService,
  ) {}
```

Al principio de `create(dto: CreateProductoDto)`:

```ts
  async create(dto: CreateProductoDto): Promise<ProductoView> {
    const permiteCrear = await this.tallerParametros.getBooleanConFallback("productos.permite_crear");
    if (!permiteCrear) {
      throw new ForbiddenException(
        "Este tenant no permite crear/editar productos propios -- activá 'Permitir crear productos desde el taller' en Configuración → Productos.",
      );
    }

    // ... resto del método sin cambios ...
```

Al principio de `update(plu: string, dto: UpdateProductoDto)`, el mismo bloque
(mismo mensaje, mismo toggle — ver spec §7.2, un solo toggle cubre crear y
editar):

```ts
  async update(plu: string, dto: UpdateProductoDto): Promise<ProductoView> {
    const permiteCrear = await this.tallerParametros.getBooleanConFallback("productos.permite_crear");
    if (!permiteCrear) {
      throw new ForbiddenException(
        "Este tenant no permite crear/editar productos propios -- activá 'Permitir crear productos desde el taller' en Configuración → Productos.",
      );
    }

    // ... resto del método sin cambios ...
```

- [ ] **Step 5: Correr las pruebas**

Run: `cd backend && npx jest src/productos/productos.service.spec.ts`
Expected: PASS.

- [ ] **Step 6: Correr el resto de la suite de productos para verificar que no se rompió nada**

Run: `cd backend && npx jest src/productos`
Expected: PASS (incluye `productos.readonly.spec.ts`, `maeplu-mirror.*.spec.ts`).

- [ ] **Step 7: Commit**

```bash
git add backend/src/productos/productos.module.ts backend/src/productos/productos.service.ts backend/src/productos/productos.service.spec.ts
git commit -m "feat(productos): crear/editar productos locales rechaza si productos.permite_crear está apagado"
```

---

## Task 12: Gate real en el envío a POSTouch + `getConfigForTienda` lee de `taller_parametros`

Dos cambios en el mismo archivo porque son la misma migración de fuente de
verdad: `enviarOrdenTerminada` gatea con `facturacion.usa_facturacion`, y
`getConfigForTienda` deja de leer columnas de `maetie` (`Tienda` entity) para
leer `facturacion_pos.*` de `taller_parametros`. `CredentialsCryptoService` se
saca del constructor — ya no lo usa nada en este archivo, el descifrado ahora
vive dentro de `TallerParametrosService`. Ver spec §3.3, §7.3.

**Files:**
- Modify: `backend/src/facturacion-pos/facturacion-pos.module.ts`
- Modify: `backend/src/facturacion-pos/facturacion-pos.service.ts`
- Modify: `backend/src/facturacion-pos/facturacion-pos.service.spec.ts`

**Interfaces:**
- Consumes: `TallerParametrosService.getBooleanConFallback(clave)`, `.getParaTienda(tiendaId, clave)` (Tasks 3, 7).
- Produces: `FacturacionPosService` con constructor `(tenantAccessor, maepluMirrorService, postouchClientService, tallerParametros)` (orden nuevo — antes tenía `credentialsCrypto` como segundo parámetro). Usado por Task 20 (referencia de tipo, no instanciación directa).

- [ ] **Step 1: Importar `TallerParametrosModule` en `facturacion-pos.module.ts`**

Agregar `TallerParametrosModule` a los `imports` de `FacturacionPosModule`,
junto al import correspondiente (el archivo ya importa `ControlPlaneModule,
ProductosModule, TenancyModule` — agregar uno más a esa misma lista).

- [ ] **Step 2: Reescribir por completo `facturacion-pos.service.spec.ts`**

```ts
// backend/src/facturacion-pos/facturacion-pos.service.spec.ts
import { FacturacionPosService } from "./facturacion-pos.service";

function createTallerParametrosMock(overrides: Record<string, any> = {}) {
  const valores: Record<string, any> = {
    "facturacion.usa_facturacion": true,
    "facturacion_pos.habilitada": true,
    "facturacion_pos.url": "http://postouch/create_ordenes_edngt",
    "facturacion_pos.username": "22",
    "facturacion_pos.password": "5551",
    "facturacion_pos.computadora": "5.5",
    "facturacion_pos.precio_decimales": 2,
    ...overrides.valores,
  };

  return {
    getBooleanConFallback: jest.fn(async (clave: string) => Boolean(valores[clave])),
    getParaTienda: jest.fn(async (_tiendaId: number, clave: string) => valores[clave]),
    ...overrides.tallerParametros,
  };
}

function createService(overrides: Record<string, any> = {}) {
  const otFacturacionRepository = {
    save: jest.fn(async (data: any) => ({ id: 1, ...data })),
    ...overrides.otFacturacionRepository,
  };

  const tenantAccessor = {
    getCurrentTenantId: jest.fn(() => 42),
    repositoryFor: jest.fn(() => otFacturacionRepository),
    ...overrides.tenantAccessor,
  };

  const maepluMirrorService = {
    insertIfMissing: jest.fn(async () => ({ inserted: true })),
    ...overrides.maepluMirrorService,
  };

  const postouchClientService = {
    enviarOrden: jest.fn(async () => ({
      tienda: 1,
      caja: 2,
      tipo: "FAC",
      serie: "A",
      transac: 555,
    })),
    ...overrides.postouchClientService,
  };

  const tallerParametros = createTallerParametrosMock(overrides);

  const service = new FacturacionPosService(
    tenantAccessor as any,
    maepluMirrorService as any,
    postouchClientService as any,
    tallerParametros as any,
  );

  return {
    service,
    tenantAccessor,
    tallerParametros,
    maepluMirrorService,
    postouchClientService,
    otFacturacionRepository,
  };
}

const otBase = {
  id: 7,
  numeroOt: "OT-1234",
  tiendaId: 3,
  clienteTarjeta: "2031",
  cliente: { nombre: "Juan Perez", nit: "CF", telefono: "5085-4657" },
  cotizacion: {
    lineas: [
      { plu: "2205", descripcion: "Aceite", precioUnitario: 100, cantidad: 2, esServicio: false },
      { plu: "TLLP-00000001", descripcion: "Mano de obra", precioUnitario: 250, cantidad: 1, esServicio: true, origenLocal: true },
    ],
  },
};

describe("FacturacionPosService.enviarOrdenTerminada", () => {
  it("retorna exito:false sin llamar a POSTouch si facturacion.usa_facturacion esta apagado", async () => {
    const { service, postouchClientService } = createService({
      valores: { "facturacion.usa_facturacion": false },
    });

    const result = await service.enviarOrdenTerminada(otBase);

    expect(result).toEqual({
      exito: false,
      error: "Facturación automática desactivada para este tenant -- Configuración → Facturación.",
    });
    expect(postouchClientService.enviarOrden).not.toHaveBeenCalled();
  });

  it("retorna exito:false sin llamar a POSTouch si la OT no tiene tienda asignada", async () => {
    const { service, postouchClientService } = createService();

    const result = await service.enviarOrdenTerminada({ ...otBase, tiendaId: null });

    expect(result).toEqual({ exito: false, error: "OT sin tienda asignada, no se puede facturar por POS" });
    expect(postouchClientService.enviarOrden).not.toHaveBeenCalled();
  });

  it("retorna exito:false sin llamar a POSTouch si la tienda no tiene facturacion habilitada", async () => {
    const { service, postouchClientService } = createService({
      valores: { "facturacion_pos.habilitada": false },
    });

    const result = await service.enviarOrdenTerminada(otBase);

    expect(result).toEqual({ exito: false, error: "Facturacion POS no habilitada para esta tienda" });
    expect(postouchClientService.enviarOrden).not.toHaveBeenCalled();
  });

  it("espeja los productos locales antes de enviar, y guarda la referencia al facturar con exito", async () => {
    const { service, maepluMirrorService, postouchClientService, otFacturacionRepository, tallerParametros } =
      createService();

    const result = await service.enviarOrdenTerminada(otBase);

    expect(result).toEqual({ exito: true });
    expect(tallerParametros.getParaTienda).toHaveBeenCalledWith(3, "facturacion_pos.password");
    expect(maepluMirrorService.insertIfMissing).toHaveBeenCalledTimes(1);
    expect(maepluMirrorService.insertIfMissing).toHaveBeenCalledWith({
      plu: "TLLP-00000001",
      desclarga: "Mano de obra",
      precio: 250,
      costo: 0,
      iva: 0,
      pagaiva: true,
      esServicio: true,
      usainventario: false,
    });
    expect(postouchClientService.enviarOrden).toHaveBeenCalledWith(
      expect.objectContaining({ url: "http://postouch/create_ordenes_edngt" }),
      expect.objectContaining({
        id: 7,
        cliente: { tarjeta: "2031", nombre: "Juan Perez", nit: "CF", telefono: "5085-4657" },
        lineas: [
          { plu: "2205", cantidad: 2, precio: 100 },
          { plu: "TLLP-00000001", cantidad: 1, precio: 250 },
        ],
      }),
    );
    expect(otFacturacionRepository.save).toHaveBeenCalledWith(
      expect.objectContaining({
        otId: 7,
        tienda: 1,
        caja: 2,
        tipo: "FAC",
        serie: "A",
        transac: 555,
      }),
    );
  });

  it("retorna exito:false con el mensaje de error si falla la resolucion de contexto de tenant", async () => {
    const { service, postouchClientService, otFacturacionRepository } = createService({
      tallerParametros: {
        getParaTienda: jest.fn(() => {
          throw new Error("No hay contexto de tenant resuelto");
        }),
      },
    });

    const result = await service.enviarOrdenTerminada(otBase);

    expect(result).toEqual({ exito: false, error: "No hay contexto de tenant resuelto" });
    expect(postouchClientService.enviarOrden).not.toHaveBeenCalled();
    expect(otFacturacionRepository.save).not.toHaveBeenCalled();
  });

  it("retorna exito:false con el mensaje de error si la config de la tienda esta incompleta", async () => {
    const { service, postouchClientService, otFacturacionRepository } = createService({
      valores: { "facturacion_pos.url": null },
    });

    const result = await service.enviarOrdenTerminada(otBase);

    expect(result).toEqual({
      exito: false,
      error: "La tienda 3 tiene facturacionPosHabilitada pero no tiene facturacionPosUrl configurada",
    });
    expect(postouchClientService.enviarOrden).not.toHaveBeenCalled();
    expect(otFacturacionRepository.save).not.toHaveBeenCalled();
  });

  it("retorna exito:false con el mensaje de error si POSTouch rechaza la orden", async () => {
    const { service, otFacturacionRepository } = createService({
      postouchClientService: {
        enviarOrden: jest.fn(async () => {
          throw new Error("POSTouch rechazo la orden: No existe el PLU[2205].");
        }),
      },
    });

    const result = await service.enviarOrdenTerminada(otBase);

    expect(result).toEqual({
      exito: false,
      error: "POSTouch rechazo la orden: No existe el PLU[2205].",
    });
    expect(otFacturacionRepository.save).not.toHaveBeenCalled();
  });
});
```

- [ ] **Step 3: Correr las pruebas para verificar que fallan**

Run: `cd backend && npx jest src/facturacion-pos/facturacion-pos.service.spec.ts`
Expected: FAIL — el constructor real todavía espera `credentialsCrypto` como
segundo parámetro y lee de `Tienda`, no de `TallerParametrosService`.

- [ ] **Step 4: Reescribir `facturacion-pos.service.ts`**

Reemplazar imports, constructor, el getter `tiendaRepository` y
`getConfigForTienda` por:

```ts
import { BadRequestException, Injectable, Logger } from "@nestjs/common";
import { Repository } from "typeorm";
import { TenantRepositoryAccessor } from "../tenancy/tenant-repository.accessor";
import { MaepluMirrorService } from "../productos/maeplu-mirror.service";
import { TallerParametrosService } from "../taller-parametros/taller-parametros.service";
import { FacturacionPosConfig, PostouchClientService } from "./postouch-client.service";
import { OtFacturacion } from "./entities/ot-facturacion.entity";
```

(quita el import de `Tienda` — ya no se usa en este archivo)

```ts
  constructor(
    private readonly tenantAccessor: TenantRepositoryAccessor,
    private readonly maepluMirrorService: MaepluMirrorService,
    private readonly postouchClientService: PostouchClientService,
    private readonly tallerParametros: TallerParametrosService,
  ) {}

  private get otFacturacionRepository(): Repository<OtFacturacion> {
    return this.tenantAccessor.repositoryFor(OtFacturacion);
  }
```

(quita el getter `tiendaRepository` completo)

```ts
  // Por TIENDA, no por tenant -- ver comentario en TallerParametro entity /
  // spec §3.3 sobre por que facturacion_pos vive con ambito='tienda'. Ya no
  // lee columnas de maetie directo: taller_parametros es la fuente de verdad
  // desde este cambio (Task 1 migro los datos existentes).
  private async getConfigForTienda(tiendaId: number): Promise<FacturacionPosConfig | null> {
    const habilitada = await this.tallerParametros.getParaTienda(tiendaId, "facturacion_pos.habilitada");
    if (!habilitada) {
      return null;
    }

    const url = await this.tallerParametros.getParaTienda(tiendaId, "facturacion_pos.url");
    const username = await this.tallerParametros.getParaTienda(tiendaId, "facturacion_pos.username");
    const password = await this.tallerParametros.getParaTienda(tiendaId, "facturacion_pos.password");
    const computadora = await this.tallerParametros.getParaTienda(tiendaId, "facturacion_pos.computadora");
    const precioDecimales = await this.tallerParametros.getParaTienda(tiendaId, "facturacion_pos.precio_decimales");

    if (!url) {
      throw new BadRequestException(
        `La tienda ${tiendaId} tiene facturacionPosHabilitada pero no tiene facturacionPosUrl configurada`,
      );
    }
    if (!username) {
      throw new BadRequestException(
        `La tienda ${tiendaId} tiene facturacionPosHabilitada pero no tiene facturacionPosUsername configurado`,
      );
    }
    if (!password) {
      throw new BadRequestException(
        `La tienda ${tiendaId} tiene facturacionPosHabilitada pero no tiene contraseña configurada`,
      );
    }
    if (!computadora) {
      throw new BadRequestException(
        `La tienda ${tiendaId} tiene facturacionPosHabilitada pero no tiene facturacionPosComputadora configurada`,
      );
    }
    if (!precioDecimales && precioDecimales !== 0) {
      throw new BadRequestException(
        `La tienda ${tiendaId} tiene facturacionPosHabilitada pero no tiene facturacionPosPrecioDecimales configurado`,
      );
    }

    return { habilitada: true, url, username, password, computadora, precioDecimales };
  }
```

Y al principio de `enviarOrdenTerminada`, dentro del `try`, antes del chequeo
de `ot.tiendaId === null`:

```ts
  async enviarOrdenTerminada(ot: OtParaFacturar): Promise<ResultadoEnvioPos> {
    try {
      const usaFacturacion = await this.tallerParametros.getBooleanConFallback("facturacion.usa_facturacion");
      if (!usaFacturacion) {
        return {
          exito: false,
          error: "Facturación automática desactivada para este tenant -- Configuración → Facturación.",
        };
      }

      if (ot.tiendaId === null) {
        return { exito: false, error: "OT sin tienda asignada, no se puede facturar por POS" };
      }

      // ... resto del método sin cambios ...
```

- [ ] **Step 5: Correr las pruebas**

Run: `cd backend && npx jest src/facturacion-pos/facturacion-pos.service.spec.ts`
Expected: PASS, 7 pruebas.

- [ ] **Step 6: Build completo**

Run: `cd backend && npm run build`
Expected: sin errores — confirma que ningún otro archivo seguía importando
`Tienda` desde `facturacion-pos.service.ts` ni instanciando el servicio con
el orden viejo de constructor.

- [ ] **Step 7: Commit**

```bash
git add backend/src/facturacion-pos/facturacion-pos.module.ts backend/src/facturacion-pos/facturacion-pos.service.ts backend/src/facturacion-pos/facturacion-pos.service.spec.ts
git commit -m "feat(facturacion-pos): gate real de usa_facturacion + config POS desde taller_parametros"
```

---

## Task 13: `PUT /auth/tiendas/:id/facturacion-pos` escribe solo en `taller_parametros`

Deja de tocar `maetie` por completo. Ver spec §9.1. Reusa el
`tallerParametros` inyectado en Task 10 (mismo constructor, un método más en
el mock de las pruebas).

**Files:**
- Modify: `backend/src/auth/auth.controller.ts`
- Modify: `backend/src/auth/auth.service.ts`
- Modify: `backend/src/auth/auth.service.spec.ts`

**Interfaces:**
- Consumes: `TallerParametrosService.setGrupoParaTiendaExplicit(tenantId, tiendaId, valores, usuarioId, destino?): Promise<void>` (Task 5).
- Produces: `AuthService.updateTiendaFacturacionPos(tenantId, id, dto, usuarioId)` — firma con un cuarto parámetro nuevo.

- [ ] **Step 1: Actualizar la prueba existente**

En `auth.service.spec.ts`, en el `createService` que Task 10 ya extendió con
`tallerParametros`, agregar `setGrupoParaTiendaExplicit` al mock por
defecto:

```ts
  const tallerParametros = {
    getBooleanConFallbackExplicit: jest.fn(async () => true),
    setGrupoParaTiendaExplicit: jest.fn(async () => undefined),
    ...overrides.tallerParametros,
  };
```

Reemplazar el `describe("AuthService.updateTiendaFacturacionPos", ...)`
completo (las 3 pruebas existentes) por:

```ts
describe("AuthService.updateTiendaFacturacionPos", () => {
  it("delega la escritura completa a TallerParametrosService.setGrupoParaTiendaExplicit", async () => {
    const tallerParametros = { setGrupoParaTiendaExplicit: jest.fn(async () => undefined) };
    const { service } = createService({ tallerParametros });

    const result = await service.updateTiendaFacturacionPos(
      42,
      3,
      {
        habilitada: true,
        url: "http://postouch/create_ordenes_edngt",
        username: "22",
        password: "clave-nueva",
        computadora: "5.5",
        precioDecimales: 2,
      },
      99,
    );

    expect(tallerParametros.setGrupoParaTiendaExplicit).toHaveBeenCalledWith(
      42,
      3,
      {
        "facturacion_pos.habilitada": true,
        "facturacion_pos.url": "http://postouch/create_ordenes_edngt",
        "facturacion_pos.username": "22",
        "facturacion_pos.password": "clave-nueva",
        "facturacion_pos.computadora": "5.5",
        "facturacion_pos.precio_decimales": 2,
      },
      99,
    );
    expect(result).toEqual({ tiendaId: 3, habilitada: true });
  });

  it("lanza NotFoundException si la tienda no existe", async () => {
    const { service } = createService({
      tiendaRepo: { findOne: jest.fn(async () => null) },
    });

    await expect(
      service.updateTiendaFacturacionPos(42, 999, { habilitada: false }, 99),
    ).rejects.toThrow(NotFoundException);
  });

  it("no incluye facturacion_pos.password en el grupo si no se manda una contraseña nueva", async () => {
    const tallerParametros = { setGrupoParaTiendaExplicit: jest.fn(async () => undefined) };
    const { service } = createService({ tallerParametros });

    await service.updateTiendaFacturacionPos(42, 3, { habilitada: false }, 99);

    expect(tallerParametros.setGrupoParaTiendaExplicit).toHaveBeenCalledWith(
      42,
      3,
      { "facturacion_pos.habilitada": false },
      99,
    );
  });
});
```

- [ ] **Step 2: Correr las pruebas para verificar que fallan**

Run: `cd backend && npx jest src/auth/auth.service.spec.ts -t updateTiendaFacturacionPos`
Expected: FAIL — la implementación real todavía escribe en `tiendaRepo.update`
y tiene solo 3 parámetros.

- [ ] **Step 3: Reescribir `updateTiendaFacturacionPos`**

En `auth.service.ts`:

```ts
  async updateTiendaFacturacionPos(
    tenantId: number,
    id: number,
    dto: UpdateTiendaFacturacionPosDto,
    usuarioId: number,
  ): Promise<{ tiendaId: number; habilitada: boolean }> {
    await this.withTenantRepo(tenantId, Tienda, async (tiendaRepo) => {
      const tienda = await tiendaRepo.findOne({ where: { id } });
      if (!tienda) {
        throw new NotFoundException(`Tienda ${id} no encontrada`);
      }
    });

    const valores: Partial<Record<TallerParametroClave, unknown>> = {
      "facturacion_pos.habilitada": dto.habilitada,
    };
    if (dto.url !== undefined) valores["facturacion_pos.url"] = dto.url;
    if (dto.username !== undefined) valores["facturacion_pos.username"] = dto.username;
    if (dto.password) valores["facturacion_pos.password"] = dto.password;
    if (dto.computadora !== undefined) valores["facturacion_pos.computadora"] = dto.computadora;
    if (dto.precioDecimales !== undefined) valores["facturacion_pos.precio_decimales"] = dto.precioDecimales;

    await this.tallerParametros.setGrupoParaTiendaExplicit(tenantId, id, valores, usuarioId);

    return { tiendaId: id, habilitada: dto.habilitada };
  }
```

Agregar el import de `TallerParametroClave` junto al de `TallerParametrosService`:

```ts
import { TallerParametroClave } from "../taller-parametros/taller-parametros.catalogo";
```

`CredentialsCryptoService` puede quedar sin uso en este archivo si
`updateTiendaFacturacionPos` era su único consumidor dentro de `auth.service.ts`
— verificar con `grep -n "credentialsCrypto" auth/auth.service.ts` antes de
tocar el constructor: si sigue usándose en otro método (ej. contraseñas de
usuario), dejarlo tal cual; si no, no hace falta quitarlo en esta tarea (no es
parte del alcance de este cambio, y tocar el constructor sin necesidad
arriesga romper otras pruebas).

- [ ] **Step 4: Actualizar el controlador para pasar el usuario actor**

En `auth.controller.ts`, método `updateTiendaFacturacionPos`:

```ts
  @Put("tiendas/:id/facturacion-pos")
  @UseGuards(JwtAuthGuard)
  @ApiBearerAuth()
  @ApiOperation({ summary: "Configurar (o desactivar) el envio automatico de OTs a POSTouch para esta tienda" })
  async updateTiendaFacturacionPos(
    @Param("id", ParseIntPipe) id: number,
    @Body() dto: UpdateTiendaFacturacionPosDto,
    @Request() req: any,
  ) {
    this.ensureCanManageUsers(req);
    return this.authService.updateTiendaFacturacionPos(req.user.tenantId, id, dto, req.user.usuarioLocalId);
  }
```

- [ ] **Step 5: Correr las pruebas y build**

Run: `cd backend && npx jest src/auth/auth.service.spec.ts && npm run build`
Expected: PASS, sin errores.

- [ ] **Step 6: Commit**

```bash
git add backend/src/auth/auth.controller.ts backend/src/auth/auth.service.ts backend/src/auth/auth.service.spec.ts
git commit -m "refactor(auth): PUT /auth/tiendas/:id/facturacion-pos escribe solo en taller_parametros"
```

---

## Task 14: WhatsApp deja de leer variables de entorno — 100% por tenant

`ConfigService` se queda SOLO para `PUBLIC_APP_URL`/`JWT_SECRET` (no son de
WhatsApp). Cada `WHATSAPP_*` se reemplaza por su clave del catálogo. Si
`notificaciones.usa_whatsapp` está apagado, se rechaza ANTES de cualquier
`fetch`. No existe `whatsapp.service.spec.ts` hoy — esta tarea lo crea. Ver
spec §8.

**Files:**
- Modify: `backend/src/whatsapp/whatsapp.module.ts`
- Modify: `backend/src/whatsapp/whatsapp.service.ts`
- Create: `backend/src/whatsapp/whatsapp.service.spec.ts`

**Interfaces:**
- Consumes: `TallerParametrosService.get(clave): Promise<ValorDe<K>>` (Task 3).
- Produces: `WhatsappService` con constructor `(configService, cotizacionesService, otsService, tenantAccessor, tallerParametros)`. `sendConfiguredMessage`/`sendTemplate`/`sendGraphMessage`/`resolveRecipient`/`normalizePhone` pasan a ser `async` (ya lo eran las primeras dos; `resolveRecipient`/`normalizePhone` cambian de sync a async).

- [ ] **Step 1: Importar `TallerParametrosModule` en `whatsapp.module.ts`**

Agregar `TallerParametrosModule` a los `imports` de `WhatsappModule` (hoy:
`TenancyModule, CotizacionesModule, OtsModule`), junto al import
correspondiente.

- [ ] **Step 2: Escribir las pruebas nuevas**

```ts
// backend/src/whatsapp/whatsapp.service.spec.ts
import { WhatsappService } from "./whatsapp.service";
import { EstadoOT } from "../ots/entities/orden-trabajo.entity";

function createService(overrides: Record<string, any> = {}) {
  const clienteRepository = {
    findOne: jest.fn(async () => ({ nombre: "Juan Perez", celular: "12345678", telefono: null })),
    ...overrides.clienteRepository,
  };

  const tenantAccessor = {
    repositoryFor: jest.fn(() => clienteRepository),
    getCurrentTenantId: jest.fn(() => 42),
    ...overrides.tenantAccessor,
  };

  const otsService = {
    findById: jest.fn(async () => ({
      id: 7,
      numeroOt: "OT-1234",
      estado: EstadoOT.EN_PROCESO,
      clienteTarjeta: "2031",
      vehiculo: { placa: "P123ABC" },
    })),
    logEvento: jest.fn(async () => undefined),
    ...overrides.otsService,
  };

  const cotizacionesService = { ...overrides.cotizacionesService };

  const configService = {
    get: jest.fn((_key: string, def?: any) => def),
    ...overrides.configService,
  };

  const valores: Record<string, any> = {
    "notificaciones.usa_whatsapp": true,
    "notificaciones.whatsapp_access_token": "token-real",
    "notificaciones.whatsapp_phone_number_id": "1234567890",
    "notificaciones.whatsapp_api_version": "v25.0",
    "notificaciones.whatsapp_default_country_code": "502",
    "notificaciones.whatsapp_template_estado": "",
    "notificaciones.whatsapp_template_cotizacion": "",
    "notificaciones.whatsapp_template_language": "es_GT",
    ...overrides.valores,
  };
  const tallerParametros = {
    get: jest.fn(async (clave: string) => valores[clave]),
    ...overrides.tallerParametros,
  };

  const service = new WhatsappService(
    configService as any,
    cotizacionesService as any,
    otsService as any,
    tenantAccessor as any,
    tallerParametros as any,
  );

  return { service, clienteRepository, tenantAccessor, otsService, configService, tallerParametros };
}

describe("WhatsappService.sendEstadoOt — configuración por tenant, sin fallback a env", () => {
  it("lanza un error claro si notificaciones.usa_whatsapp está apagado, sin llamar a fetch", async () => {
    const fetchMock = jest.fn();
    (global as any).fetch = fetchMock;
    const { service } = createService({ valores: { "notificaciones.usa_whatsapp": false } });

    await expect(service.sendEstadoOt(7, { id: 1 })).rejects.toThrow(/WhatsApp no está activado/);
    expect(fetchMock).not.toHaveBeenCalled();
  });

  it("lanza un error claro si falta access token o phone number id, no un error de 'undefined'", async () => {
    const fetchMock = jest.fn();
    (global as any).fetch = fetchMock;
    const { service } = createService({ valores: { "notificaciones.whatsapp_access_token": "" } });

    await expect(service.sendEstadoOt(7, { id: 1 })).rejects.toThrow(/WhatsApp no está configurado/);
    expect(fetchMock).not.toHaveBeenCalled();
  });

  it("usa el código de país del TENANT (no una constante fija) para números locales de 8 dígitos", async () => {
    const fetchMock = jest.fn(async () => ({
      ok: true,
      text: async () => JSON.stringify({ messages: [{ id: "wamid.1" }] }),
    }));
    (global as any).fetch = fetchMock;
    const { service } = createService({ valores: { "notificaciones.whatsapp_default_country_code": "1" } });

    await service.sendEstadoOt(7, { id: 1 });

    const [, requestInit] = fetchMock.mock.calls[0];
    const body = JSON.parse((requestInit as any).body);
    expect(body.to).toBe("112345678");
  });

  it("envía con éxito cuando la configuración del tenant está completa", async () => {
    const fetchMock = jest.fn(async () => ({
      ok: true,
      text: async () => JSON.stringify({ messages: [{ id: "wamid.1" }] }),
    }));
    (global as any).fetch = fetchMock;
    const { service } = createService();

    const result = await service.sendEstadoOt(7, { id: 1 });

    expect(result.ok).toBe(true);
    expect(fetchMock).toHaveBeenCalledWith(
      "https://graph.facebook.com/v25.0/1234567890/messages",
      expect.objectContaining({ headers: expect.objectContaining({ Authorization: "Bearer token-real" }) }),
    );
  });
});
```

- [ ] **Step 3: Correr las pruebas para verificar que fallan**

Run: `cd backend && npx jest src/whatsapp/whatsapp.service.spec.ts`
Expected: FAIL — `WhatsappService` no tiene un quinto parámetro todavía.

- [ ] **Step 4: Reescribir `whatsapp.service.ts`**

Agregar el import y el quinto parámetro del constructor:

```ts
import { TallerParametrosService } from "../taller-parametros/taller-parametros.service";
```

```ts
  constructor(
    private readonly configService: ConfigService,
    private readonly cotizacionesService: CotizacionesService,
    private readonly otsService: OtsService,
    private readonly tenantAccessor: TenantRepositoryAccessor,
    private readonly tallerParametros: TallerParametrosService,
  ) {}
```

Reemplazar `sendCotizacion` (solo las líneas de `recipient`/`templateName`
cambian, el resto del método sigue igual):

```ts
    const recipient = await this.resolveRecipient(cliente);
    const publicUrl = this.buildPublicUrl(link.publicPath);
    const total = Number(cotizacion.total || 0).toFixed(2);
    const placa = ot.vehiculo?.placa || "sin placa";
    const mecanico = ot.mecanico?.nombre || "pendiente de asignar";
    const vehiculoInfo = `${placa} | Mecanico: ${mecanico}`;
    const body = [
      `Hola ${recipient.nombre}.`,
      `Tu cotizacion ${cotizacion.numeroCotizacion || cotizacion.id} para el vehiculo ${placa} esta lista.`,
      `Mecanico asignado: ${mecanico}.`,
      `Total: Q ${total}.`,
      `Puedes revisarla, aceptar o rechazar aqui: ${publicUrl}`,
    ].join("\n");

    const response = await this.sendConfiguredMessage({
      to: recipient.phone,
      text: body,
      templateClave: "notificaciones.whatsapp_template_cotizacion",
      parameters: [
        recipient.nombre,
        cotizacion.numeroCotizacion || String(cotizacion.id),
        vehiculoInfo,
        `Q ${total}`,
        publicUrl,
      ],
    });
```

Reemplazar `sendEstadoOt` (mismo criterio):

```ts
    const recipient = await this.resolveRecipient(cliente);
    const statusPath = this.createStatusPath(ot.id);
    const publicUrl = this.buildPublicUrl(statusPath);
    const placa = ot.vehiculo?.placa || "sin placa";
    const estado = this.getEstadoLabel(ot.estado);
    const body = [
      `Hola ${recipient.nombre}.`,
      `Tu vehiculo ${placa} esta en estado: ${estado}.`,
      `Puedes revisar el seguimiento actualizado aqui: ${publicUrl}`,
    ].join("\n");

    const response = await this.sendConfiguredMessage({
      to: recipient.phone,
      text: body,
      templateClave: "notificaciones.whatsapp_template_estado",
      parameters: [recipient.nombre, ot.numeroOt, placa, estado, publicUrl],
    });
```

Reemplazar `sendConfiguredMessage`, `sendTemplate`, `sendGraphMessage`,
`resolveRecipient` y `normalizePhone` (borrar las versiones viejas de estos
cinco métodos):

```ts
  private async sendConfiguredMessage({
    to,
    text,
    templateClave,
    parameters,
  }: {
    to: string;
    text: string;
    templateClave: "notificaciones.whatsapp_template_cotizacion" | "notificaciones.whatsapp_template_estado";
    parameters: string[];
  }) {
    const usaWhatsapp = await this.tallerParametros.get("notificaciones.usa_whatsapp");
    if (!usaWhatsapp) {
      throw new BadRequestException(
        "WhatsApp no está activado para este tenant -- activalo en Configuración → Notificaciones.",
      );
    }

    const templateName = await this.tallerParametros.get(templateClave);
    if (templateName?.trim()) {
      return this.sendTemplate(to, templateName.trim(), parameters);
    }

    return this.sendText(to, text);
  }

  private async sendTemplate(to: string, templateName: string, parameters: string[]) {
    const language = await this.tallerParametros.get("notificaciones.whatsapp_template_language");
    const template: any = {
      name: templateName,
      language: { code: language },
    };

    if (templateName !== "hello_world") {
      template.components = [
        {
          type: "body",
          parameters: parameters.map((value) => ({
            type: "text",
            text: String(value || "-").slice(0, 1024),
          })),
        },
      ];
    }

    return this.sendGraphMessage({
      messaging_product: "whatsapp",
      to,
      type: "template",
      template,
    });
  }

  private async sendGraphMessage(payload: Record<string, any>) {
    const accessToken = await this.tallerParametros.get("notificaciones.whatsapp_access_token");
    const phoneNumberId = await this.tallerParametros.get("notificaciones.whatsapp_phone_number_id");
    const version = await this.tallerParametros.get("notificaciones.whatsapp_api_version");

    if (!accessToken || !phoneNumberId) {
      throw new BadRequestException(
        "WhatsApp no está configurado para este tenant -- completá Phone Number ID y Access Token en Configuración → Notificaciones.",
      );
    }

    const response = await fetch(`https://graph.facebook.com/${version}/${phoneNumberId}/messages`, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${accessToken}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify(payload),
    });

    const text = await response.text();
    let data: any = {};
    try {
      data = text ? JSON.parse(text) : {};
    } catch {
      data = {};
    }

    if (!response.ok) {
      throw new BadRequestException(this.getGraphErrorMessage(data?.error));
    }

    return data;
  }

  private async resolveRecipient(cliente: Cliente | null): Promise<WhatsappRecipient> {
    const nombre = cliente?.nombre?.trim() || "cliente";
    const phone = await this.normalizePhone(cliente?.celular || cliente?.telefono);

    if (!phone) {
      throw new BadRequestException("El cliente no tiene celular o telefono valido para WhatsApp");
    }

    return { nombre, phone };
  }

  private async normalizePhone(value?: string | null): Promise<string> {
    const digits = String(value || "").replace(/\D/g, "");
    if (!digits) return "";
    if (digits.length === 8) {
      const countryCode = await this.tallerParametros.get("notificaciones.whatsapp_default_country_code");
      return `${countryCode}${digits}`;
    }
    if (digits.startsWith("00")) {
      return digits.slice(2);
    }
    return digits;
  }
```

`sendText` no cambia (sigue llamando a `sendGraphMessage`, que ahora es
`async` — su propio `return this.sendGraphMessage(...)` ya funciona igual con
una función async). `getGraphErrorMessage`, `getPublicOtStatus`,
`createStatusPath`, `verifyStatusToken`, `sign`, `getSigningSecret`,
`safeCompare`, `base64UrlEncode`, `maskPhone`, `getEstadoLabel`,
`buildPublicUrl`, `findCliente` no cambian.

- [ ] **Step 5: Correr las pruebas**

Run: `cd backend && npx jest src/whatsapp/whatsapp.service.spec.ts`
Expected: PASS, 4 pruebas.

- [ ] **Step 6: Build completo**

Run: `cd backend && npm run build`
Expected: sin errores — confirma que no queda ningún caller síncrono de
`resolveRecipient`/`normalizePhone` sin `await`.

- [ ] **Step 7: Commit**

```bash
git add backend/src/whatsapp/whatsapp.module.ts backend/src/whatsapp/whatsapp.service.ts backend/src/whatsapp/whatsapp.service.spec.ts
git commit -m "feat(whatsapp): config 100% por tenant, sin fallback a variables de entorno"
```

---

## Task 15: Script de siembra `seed-whatsapp-desde-env.ts`

Mismo patrón que `backend/scripts/repair-tenant-maetie-facturacion-pos.ts`,
simplificado: no recibe `tenantId` ni pasa por el plano de control, porque
`TENANT_CLI_DB_*` ya apunta al único tenant de este sitio (mismo supuesto que
`typeorm.config.ts`, documentado explícitamente en el script). Ver spec §6.4.

**Nota sobre pruebas automatizadas**: ningún script existente en
`backend/scripts/` tiene `.spec.ts` — son herramientas operativas verificadas
a mano contra una base real, no unidades con mocks. Esta tarea sigue esa
misma convención a propósito en vez de inventar una nueva forma de probar
solo para este script; el Step 3 deja la verificación manual documentada de
forma concreta, no como "probarlo en algún momento".

**Files:**
- Create: `backend/scripts/seed-whatsapp-desde-env.ts`

**Interfaces:**
- Consumes: `TallerParametro` (Task 1), `CredentialsCryptoService` (`../src/control-plane/credentials-crypto.service`).
- Produces: ejecutable standalone `npx ts-node scripts/seed-whatsapp-desde-env.ts` (sin argumentos). Usado por Task 16 (`deploy.sh`).

- [ ] **Step 1: Escribir el script**

```ts
// backend/scripts/seed-whatsapp-desde-env.ts
import * as fs from "fs";
import * as path from "path";
import { DataSource } from "typeorm";
import { CredentialsCryptoService } from "../src/control-plane/credentials-crypto.service";
import { TallerParametro } from "../src/taller-parametros/entities/taller-parametro.entity";

// Siembra la config de WhatsApp de ESTE sitio (TENANT_CLI_DB_* -- el mismo
// tenant que migration:run:prod usa, ver typeorm.config.ts) desde las
// variables WHATSAPP_* actuales, una sola vez. Se corre en CADA despliegue
// (ver deploy.sh) porque es idempotente: no-op si ya existe la fila de
// whatsapp_phone_number_id (no pisa un valor ya editado desde la pantalla
// nueva) o si el sitio no tiene WHATSAPP_* configuradas.
//
// SUPUESTO, no garantia permanente: "un tenant por sitio" -- cierto hoy
// (typeorm.config.ts: "CLI config para migraciones de UN tenant a la vez"),
// pero RUNBOOK-DEPLOY.md:463-468 ya advierte que ese modelo no escala mas
// alla de un punado de tenants. El dia que un sitio sirva 2+ tenants
// activos, esto solo alcanza al que apunte TENANT_CLI_DB_DATABASE -- por eso
// loguea host+base destino en los tres casos (no-op, siembra, error), nunca
// credenciales.
function loadRootEnv(): void {
  const envPath = path.join(__dirname, "..", "..", ".env");
  if (!fs.existsSync(envPath)) return;
  for (const line of fs.readFileSync(envPath, "utf8").split(/\r?\n/)) {
    const m = line.match(/^([A-Z0-9_]+)=(.*)$/);
    if (m && !process.env[m[1]]) process.env[m[1]] = m[2];
  }
}

function buildCryptoService(): CredentialsCryptoService {
  const fakeConfigService = { get: (key: string) => process.env[key] };
  const crypto = new CredentialsCryptoService(fakeConfigService as any);
  crypto.onModuleInit();
  return crypto;
}

function buildTenantDataSource(): DataSource {
  return new DataSource({
    type: "mysql",
    host: process.env.TENANT_CLI_DB_HOST,
    port: parseInt(process.env.TENANT_CLI_DB_PORT || "3306", 10),
    username: process.env.TENANT_CLI_DB_USERNAME,
    password: process.env.TENANT_CLI_DB_PASSWORD,
    database: process.env.TENANT_CLI_DB_DATABASE,
    entities: [TallerParametro],
    synchronize: false,
  });
}

const CAMPOS: Array<{ clave: string; env: string; sensible: boolean; default?: string }> = [
  { clave: "notificaciones.whatsapp_phone_number_id", env: "WHATSAPP_PHONE_NUMBER_ID", sensible: false },
  { clave: "notificaciones.whatsapp_access_token", env: "WHATSAPP_ACCESS_TOKEN", sensible: true },
  { clave: "notificaciones.whatsapp_api_version", env: "WHATSAPP_API_VERSION", sensible: false, default: "v25.0" },
  {
    clave: "notificaciones.whatsapp_default_country_code",
    env: "WHATSAPP_DEFAULT_COUNTRY_CODE",
    sensible: false,
    default: "502",
  },
  { clave: "notificaciones.whatsapp_template_cotizacion", env: "WHATSAPP_TEMPLATE_COTIZACION", sensible: false },
  { clave: "notificaciones.whatsapp_template_estado", env: "WHATSAPP_TEMPLATE_ESTADO", sensible: false },
  {
    clave: "notificaciones.whatsapp_template_language",
    env: "WHATSAPP_TEMPLATE_LANGUAGE",
    sensible: false,
    default: "es_GT",
  },
];

async function seed(): Promise<void> {
  loadRootEnv();
  const host = process.env.TENANT_CLI_DB_HOST;
  const database = process.env.TENANT_CLI_DB_DATABASE;

  if (!process.env.WHATSAPP_ACCESS_TOKEN || !process.env.WHATSAPP_PHONE_NUMBER_ID) {
    console.log(`[seed-whatsapp-desde-env] ${host}/${database}: sin WHATSAPP_* en el entorno, no-op.`);
    return;
  }

  const crypto = buildCryptoService();
  const ds = buildTenantDataSource();
  await ds.initialize();

  try {
    const repo = ds.getRepository(TallerParametro);
    const yaExiste = await repo.findOne({
      where: { clave: "notificaciones.whatsapp_phone_number_id", tiendaId: 0, destino: "" },
    });
    if (yaExiste) {
      console.log(`[seed-whatsapp-desde-env] ${host}/${database}: ya tiene config de WhatsApp, no-op.`);
      return;
    }

    for (const campo of CAMPOS) {
      const valor = process.env[campo.env] ?? campo.default ?? "";
      if (!valor) continue;
      await repo.save(
        repo.create({
          clave: campo.clave,
          ambito: "tenant",
          tiendaId: 0,
          destino: "",
          tipoDato: "string",
          valor: campo.sensible ? null : valor,
          valorCifrado: campo.sensible ? crypto.encrypt(valor) : null,
        }),
      );
    }

    // El toggle en si: si este sitio tenia WHATSAPP_* funcionando, el
    // comportamiento equivalente post-migracion es usa_whatsapp=true --
    // "los clientes que ya operan se comportan igual" (spec, criterio de
    // listo).
    await repo.save(
      repo.create({
        clave: "notificaciones.usa_whatsapp",
        ambito: "tenant",
        tiendaId: 0,
        destino: "",
        tipoDato: "boolean",
        valor: "true",
        valorCifrado: null,
      }),
    );

    console.log(`[seed-whatsapp-desde-env] ${host}/${database}: sembrado desde variables de entorno.`);
  } finally {
    await ds.destroy();
  }
}

seed().catch((e) => {
  console.error(`[seed-whatsapp-desde-env] error: ${e?.message ?? e}`);
  process.exit(1);
});
```

- [ ] **Step 2: Build**

Run: `cd backend && npm run build`
Expected: sin errores de compilación (el script vive fuera de `src/`, pero
`tsc` vía `ts-node` en tiempo de ejecución es lo que realmente importa —
confirmar igual que no rompe el build de Nest, que no debería tocar
`scripts/`).

- [ ] **Step 3: Verificación manual documentada (no hay corredor de pruebas para `scripts/`)**

Contra una base de tenant de prueba con `TENANT_CLI_DB_*` apuntando a ella:

```bash
cd backend
WHATSAPP_ACCESS_TOKEN=token-de-prueba WHATSAPP_PHONE_NUMBER_ID=123456 \
  npx ts-node scripts/seed-whatsapp-desde-env.ts
```

Confirmar en el log la línea `sembrado desde variables de entorno`, y que
`SELECT * FROM taller_parametros WHERE clave LIKE 'notificaciones.%'` en esa
base tiene las filas esperadas (`whatsapp_access_token` con `valor_cifrado`
poblado y `valor` en `NULL`). Correr el mismo comando una segunda vez y
confirmar que el log dice `ya tiene config de WhatsApp, no-op` y que no se
duplicaron filas.

- [ ] **Step 4: Commit**

```bash
git add backend/scripts/seed-whatsapp-desde-env.ts
git commit -m "feat(scripts): sembrar config de WhatsApp desde variables de entorno, una sola vez por sitio"
```

---

## Task 16: `deploy.sh` corre la siembra + `.env.example` sin `WHATSAPP_*`

Ver spec §6.4 — el paso nuevo va DESPUÉS de las migraciones (la tabla recién
existe ahí) y ANTES de `docker compose up -d` (el código nuevo, sin fallback
a `ConfigService`, no debe arrancar a servir tráfico antes de que la siembra
haya corrido).

**Files:**
- Modify: `deploy.sh` (raíz del repo)
- Modify: `.env.example` (raíz del repo)

**Interfaces:** Ninguna — cambios de script/documentación, no de código TypeScript.

- [ ] **Step 1: Agregar el paso a `deploy.sh`**

Insertar entre el bloque de migraciones (líneas 33-35 actuales) y el de
`docker compose up -d` (líneas 37-38 actuales):

```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"]` — se invoca el
script directo, sin `--entrypoint sh`, igual que el `Dockerfile` documenta
para "cualquier script nuevo que se agregue a `scripts/`".)

- [ ] **Step 2: Quitar el bloque `WHATSAPP_*` de `.env.example`**

Borrar las líneas 73-82 actuales (`# WhatsApp Cloud API` y las 7 variables
`WHATSAPP_*` con sus comentarios), sin dejar un bloque vacío ni un comentario
huérfano.

- [ ] **Step 3: Revisión visual del diff**

Run: `git diff deploy.sh .env.example`
Expected: el paso nuevo aparece exactamente entre migraciones y
`docker compose up -d`; `.env.example` no menciona `WHATSAPP_` en ningún
lado.

- [ ] **Step 4: Commit**

```bash
git add deploy.sh .env.example
git commit -m "chore(deploy): sembrar WhatsApp automáticamente antes de levantar contenedores, quitar WHATSAPP_* del .env.example"
```

---

## Task 17: `PostouchClientService.probarConexion` — sonda segura por construcción

Independiente de `enviarOrden`, nunca cae a él. `ordenes: []` verificado
contra el código real de POSTouch (spec §9.2): ejerce autenticación real sin
crear ninguna orden. Nunca loguea la contraseña. Ver spec §9.3.

**Files:**
- Modify: `backend/src/facturacion-pos/postouch-client.service.ts`
- Modify: `backend/src/facturacion-pos/postouch-client.service.spec.ts`

**Interfaces:**
- Produces: `CredencialesPosParaProbar` (`Pick<FacturacionPosConfig, "url" | "username" | "password" | "computadora">`), `ResultadoProbarConexion` (`{ ok: true } | { ok: false; detalle: string }`), `PostouchClientService.probarConexion(config: CredencialesPosParaProbar): Promise<ResultadoProbarConexion>`. Usado por Task 20 (endpoint de la sonda).

- [ ] **Step 1: Escribir las pruebas**

Agregar al final de `postouch-client.service.spec.ts` (reusa el `config` ya
definido al principio del archivo):

```ts
describe("PostouchClientService.probarConexion", () => {
  let originalFetch: typeof fetch;

  beforeEach(() => {
    originalFetch = global.fetch;
  });

  afterEach(() => {
    global.fetch = originalFetch;
  });

  it("nunca llama a enviarOrden bajo ninguna rama -- el payload siempre lleva ordenes:[]", async () => {
    const fetchMock = jest.fn(async () => ({
      ok: true,
      text: async () => JSON.stringify({ procesadas: 0, facturas: [] }),
    }));
    global.fetch = fetchMock as any;
    const service = new PostouchClientService();

    await service.probarConexion({ url: config.url, username: "22", password: "5551", computadora: "5.5" });

    const [, init] = fetchMock.mock.calls[0] as any[];
    const body = JSON.parse(init.body);
    expect(body.ordenes).toEqual([]);
  });

  it("ok:true solo cuando la respuesta es exactamente {procesadas:0, facturas:[]}", async () => {
    global.fetch = jest.fn(async () => ({
      ok: true,
      text: async () => JSON.stringify({ procesadas: 0, facturas: [] }),
    })) as any;
    const service = new PostouchClientService();

    await expect(
      service.probarConexion({ url: config.url, username: "22", password: "5551", computadora: "5.5" }),
    ).resolves.toEqual({ ok: true });
  });

  it("ok:false si la respuesta HTTP no es exitosa", async () => {
    global.fetch = jest.fn(async () => ({ ok: false, status: 500, text: async () => "Internal Server Error" })) as any;
    const service = new PostouchClientService();

    const resultado = await service.probarConexion({
      url: config.url,
      username: "22",
      password: "5551",
      computadora: "5.5",
    });

    expect(resultado.ok).toBe(false);
  });

  it("ok:false si la respuesta trae data.error", async () => {
    global.fetch = jest.fn(async () => ({
      ok: true,
      text: async () => JSON.stringify({ error: "Usuario o contraseña incorrectos" }),
    })) as any;
    const service = new PostouchClientService();

    const resultado = await service.probarConexion({
      url: config.url,
      username: "22",
      password: "clave-mala",
      computadora: "5.5",
    });

    expect(resultado).toEqual({ ok: false, detalle: expect.stringContaining("Usuario o contraseña incorrectos") });
  });

  it("ok:false ante una forma de respuesta inesperada -- nunca asume éxito por omisión", async () => {
    global.fetch = jest.fn(async () => ({ ok: true, text: async () => JSON.stringify({ algo: "distinto" }) })) as any;
    const service = new PostouchClientService();

    const resultado = await service.probarConexion({
      url: config.url,
      username: "22",
      password: "5551",
      computadora: "5.5",
    });

    expect(resultado.ok).toBe(false);
  });

  it("ok:false si la respuesta no es JSON válido", async () => {
    global.fetch = jest.fn(async () => ({ ok: true, text: async () => "<html>error</html>" })) as any;
    const service = new PostouchClientService();

    const resultado = await service.probarConexion({
      url: config.url,
      username: "22",
      password: "5551",
      computadora: "5.5",
    });

    expect(resultado.ok).toBe(false);
  });

  it("nunca loguea la contraseña real, en ningún resultado", async () => {
    global.fetch = jest.fn(async () => ({
      ok: true,
      text: async () => JSON.stringify({ procesadas: 0, facturas: [] }),
    })) as any;
    const service = new PostouchClientService();
    const debugSpy = jest.spyOn((service as any).logger, "debug");

    await service.probarConexion({
      url: config.url,
      username: "22",
      password: "secreta-super-unica",
      computadora: "5.5",
    });

    for (const call of debugSpy.mock.calls) {
      expect(JSON.stringify(call)).not.toContain("secreta-super-unica");
    }
  });
});
```

- [ ] **Step 2: Correr las pruebas para verificar que fallan**

Run: `cd backend && npx jest src/facturacion-pos/postouch-client.service.spec.ts -t probarConexion`
Expected: FAIL — `service.probarConexion is not a function`.

- [ ] **Step 3: Implementar `probarConexion`**

Agregar a `postouch-client.service.ts`, junto a `enviarOrden` (import de
`Logger` nuevo, resto de imports sin cambios):

```ts
import { BadRequestException, Injectable, Logger } from "@nestjs/common";
```

```ts
export type CredencialesPosParaProbar = Pick<FacturacionPosConfig, "url" | "username" | "password" | "computadora">;

export type ResultadoProbarConexion = { ok: true } | { ok: false; detalle: string };
```

Dentro de la clase `PostouchClientService`, agregar el logger y el método
(nunca comparte rama de código con `enviarOrden`, nunca lo llama):

```ts
  private readonly logger = new Logger(PostouchClientService.name);

  // Sonda segura por construccion: ordenes:[] verificado contra el codigo
  // real de POSTouch (DomicilioController::createOrdenesEdngtAction llama
  // login() ANTES de tocar `ordenes`; con `ordenes:[]` el foreach de
  // Domicilio::createOrdenesEdngt no corre ni una vez -- cero PLUs, cero
  // clientes, cero facturas). Independiente de enviarOrden: nunca cae a el
  // bajo ninguna rama de error, nunca asume exito ante una respuesta que no
  // coincide exactamente con la esperada.
  async probarConexion(config: CredencialesPosParaProbar): Promise<ResultadoProbarConexion> {
    const payload = {
      _username: config.username,
      _password: config.password,
      computadora: config.computadora,
      ordenes: [] as unknown[],
    };

    let response: Response;
    try {
      response = await fetch(config.url, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(payload),
      });
    } catch (error: any) {
      this.logDiagnosticoProbarConexion(payload, null, `Error de red: ${error?.message ?? error}`);
      return { ok: false, detalle: `No se pudo contactar POSTouch: ${error?.message ?? "error de red"}` };
    }

    const text = await response.text();
    let data: any;
    try {
      data = text ? JSON.parse(text) : {};
    } catch {
      this.logDiagnosticoProbarConexion(payload, text, "Respuesta no es JSON valido");
      return { ok: false, detalle: "No se pudo verificar la respuesta de POSTouch" };
    }

    if (!response.ok) {
      this.logDiagnosticoProbarConexion(payload, text, `HTTP ${response.status}`);
      return { ok: false, detalle: `POSTouch respondió con error: ${text.slice(0, 300)}` };
    }

    if (data?.error) {
      this.logDiagnosticoProbarConexion(payload, text, "data.error presente");
      return { ok: false, detalle: `POSTouch rechazó la conexión: ${data.error}` };
    }

    const esRespuestaEsperada =
      data && typeof data === "object" && data.procesadas === 0 && Array.isArray(data.facturas) && data.facturas.length === 0;

    if (!esRespuestaEsperada) {
      this.logDiagnosticoProbarConexion(payload, text, "Forma de respuesta inesperada");
      return { ok: false, detalle: "No se pudo verificar la respuesta de POSTouch" };
    }

    this.logDiagnosticoProbarConexion(payload, text, "ok");
    return { ok: true };
  }

  private logDiagnosticoProbarConexion(
    payload: Record<string, unknown>,
    respuesta: string | null,
    resultado: string,
  ): void {
    const payloadSeguro = { ...payload, _password: "***" };
    this.logger.debug(
      `[probarConexion] payload=${JSON.stringify(payloadSeguro)} respuesta=${(respuesta ?? "").slice(0, 300)} resultado=${resultado}`,
    );
  }
```

- [ ] **Step 4: Correr las pruebas**

Run: `cd backend && npx jest src/facturacion-pos/postouch-client.service.spec.ts`
Expected: PASS, todas (las de `enviarOrden`/`redondear` ya existentes + las 7 nuevas).

- [ ] **Step 5: Commit**

```bash
git add backend/src/facturacion-pos/postouch-client.service.ts backend/src/facturacion-pos/postouch-client.service.spec.ts
git commit -m "feat(facturacion-pos): sonda de conexión segura contra POSTouch (probarConexion)"
```

---

## Task 18: Módulo `configuracion` en `PermissionGuard`

`write` para `SUPER_ADMIN`/`ADMIN` (ya tienen ese nivel de acceso hoy sobre
las credenciales de POSTouch vía `ensureCanManageUsers` — ver decisión
explícita en el spec §12), `none` para el resto. No existe
`permission.guard.spec.ts` hoy — esta tarea lo crea.

**Files:**
- Modify: `backend/src/auth/guards/permission.guard.ts`
- Create: `backend/src/auth/guards/permission.guard.spec.ts`

**Interfaces:** Ninguna nueva — agrega una entrada a un mapa existente.

- [ ] **Step 1: Escribir la prueba**

```ts
// backend/src/auth/guards/permission.guard.spec.ts
import "reflect-metadata";
import { ForbiddenException } from "@nestjs/common";
import { PERMISSION_METADATA_KEY } from "../decorators/require-permission.decorator";
import { UserRole } from "../entities/usuario.entity";
import { PermissionGuard } from "./permission.guard";

function createContext(user: any, requirement?: { module: string; level: "read" | "write" }) {
  function handler() {}
  if (requirement) {
    Reflect.defineMetadata(PERMISSION_METADATA_KEY, requirement, handler);
  }
  class Controller {}
  return {
    getHandler: () => handler,
    getClass: () => Controller,
    switchToHttp: () => ({ getRequest: () => ({ user }) }),
  } as any;
}

describe("PermissionGuard — módulo configuracion", () => {
  const guard = new PermissionGuard();

  it("SUPER_ADMIN siempre pasa (bypass existente, sin cambios)", () => {
    const ctx = createContext({ rol: UserRole.SUPER_ADMIN }, { module: "configuracion", level: "write" });
    expect(guard.canActivate(ctx)).toBe(true);
  });

  it("ADMIN tiene configuracion:write por default", () => {
    const ctx = createContext({ rol: UserRole.ADMIN }, { module: "configuracion", level: "write" });
    expect(guard.canActivate(ctx)).toBe(true);
  });

  it("OPERATIVO no tiene acceso a configuracion", () => {
    const ctx = createContext({ rol: UserRole.OPERATIVO }, { module: "configuracion", level: "read" });
    expect(() => guard.canActivate(ctx)).toThrow(ForbiddenException);
  });

  it("RECEPCION no tiene acceso a configuracion", () => {
    const ctx = createContext({ rol: UserRole.RECEPCION }, { module: "configuracion", level: "read" });
    expect(() => guard.canActivate(ctx)).toThrow(ForbiddenException);
  });

  it("MECANICO no tiene acceso a configuracion", () => {
    const ctx = createContext({ rol: UserRole.MECANICO }, { module: "configuracion", level: "read" });
    expect(() => guard.canActivate(ctx)).toThrow(ForbiddenException);
  });
});
```

- [ ] **Step 2: Correr la prueba para verificar que falla**

Run: `cd backend && npx jest src/auth/guards/permission.guard.spec.ts`
Expected: FAIL — hoy `configuracion` no existe en ningún mapa, así que
`currentLevel` cae a `"none"` para todos los roles, incluido `ADMIN`.

- [ ] **Step 3: Agregar `configuracion` a `DEFAULT_PERMISSIONS_BY_ROLE`**

En `permission.guard.ts`, agregar `configuracion: "write"` al mapa de
`UserRole.SUPER_ADMIN` y al de `UserRole.ADMIN`, y `configuracion: "none"` a
los mapas de `UserRole.OPERATIVO`, `UserRole.RECEPCION` y `UserRole.MECANICO`
(una línea por rol, junto a `usuarios:` en cada uno).

- [ ] **Step 4: Correr la prueba**

Run: `cd backend && npx jest src/auth/guards/permission.guard.spec.ts`
Expected: PASS, 5 pruebas.

- [ ] **Step 5: Commit**

```bash
git add backend/src/auth/guards/permission.guard.ts backend/src/auth/guards/permission.guard.spec.ts
git commit -m "feat(auth): módulo configuracion en PermissionGuard (write para SUPER_ADMIN/ADMIN)"
```

---

## Task 19: DTOs y mapeo clave↔campo para el controlador de configuración

Los DTOs validan SOLO tipo/forma (`@IsBoolean`/`@IsString`), nunca "requerido
si..." — esa regla vive una sola vez, en `TallerParametrosService` (Task 5),
derivada del catálogo. Duplicarla acá sería exactamente lo que el tiquete
pide evitar ("derivando las reglas del catálogo en vez de repetirlas").

**Files:**
- Create: `backend/src/taller-parametros/dto/actualizar-parametros-tenant.dto.ts`
- Create: `backend/src/taller-parametros/dto/probar-conexion-pos.dto.ts`
- Create: `backend/src/taller-parametros/taller-parametros.mapeo.ts`
- Create: `backend/src/taller-parametros/taller-parametros.mapeo.spec.ts`

**Interfaces:**
- Produces: `ActualizarParametrosTenantDto`, `ProbarConexionPosDto`, `MAPEO_TENANT`, `dtoAValoresTenant(dto): Partial<Record<TallerParametroClave, unknown>>`. Usados por Task 20.

- [ ] **Step 1: Crear los DTOs**

```ts
// backend/src/taller-parametros/dto/actualizar-parametros-tenant.dto.ts
import { ApiPropertyOptional } from "@nestjs/swagger";
import { IsBoolean, IsOptional, IsString } from "class-validator";

export class ActualizarParametrosTenantDto {
  @ApiPropertyOptional({ description: "Usar facturación automática" })
  @IsOptional()
  @IsBoolean()
  usaFacturacion?: boolean;

  @ApiPropertyOptional({ description: "Validar existencias antes de facturar (reservado, sin consumidor hoy)" })
  @IsOptional()
  @IsBoolean()
  usaValidacionInventario?: boolean;

  @ApiPropertyOptional({ description: "Permitir crear productos desde el taller" })
  @IsOptional()
  @IsBoolean()
  permiteCrearProductos?: boolean;

  @ApiPropertyOptional({ description: "Permitir crear tiendas desde el taller" })
  @IsOptional()
  @IsBoolean()
  permiteCrearTiendas?: boolean;

  @ApiPropertyOptional({ description: "Enviar notificaciones por WhatsApp" })
  @IsOptional()
  @IsBoolean()
  usaWhatsapp?: boolean;

  @ApiPropertyOptional()
  @IsOptional()
  @IsString()
  whatsappPhoneNumberId?: string;

  @ApiPropertyOptional({ description: "Vacío = no cambiar el token ya guardado" })
  @IsOptional()
  @IsString()
  whatsappAccessToken?: string;

  @ApiPropertyOptional()
  @IsOptional()
  @IsString()
  whatsappApiVersion?: string;

  @ApiPropertyOptional()
  @IsOptional()
  @IsString()
  whatsappTemplateCotizacion?: string;

  @ApiPropertyOptional()
  @IsOptional()
  @IsString()
  whatsappTemplateEstado?: string;

  @ApiPropertyOptional()
  @IsOptional()
  @IsString()
  whatsappTemplateLanguage?: string;

  @ApiPropertyOptional()
  @IsOptional()
  @IsString()
  whatsappDefaultCountryCode?: string;
}
```

```ts
// backend/src/taller-parametros/dto/probar-conexion-pos.dto.ts
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsNotEmpty, IsOptional, IsString } from "class-validator";

export class ProbarConexionPosDto {
  @ApiProperty({ description: "URL de create_ordenes_edngt en POSTouch para esta tienda" })
  @IsString()
  @IsNotEmpty()
  url: string;

  @ApiProperty({ description: "_username de la sesión de servicio en POSTouch" })
  @IsString()
  @IsNotEmpty()
  username: string;

  @ApiPropertyOptional({ description: "Vacío = usa la contraseña ya guardada para esta tienda" })
  @IsOptional()
  @IsString()
  password?: string;

  @ApiProperty({ description: "Identificador de computadora requerido por POSTouch" })
  @IsString()
  @IsNotEmpty()
  computadora: string;
}
```

- [ ] **Step 2: Escribir la prueba del mapeo**

```ts
// backend/src/taller-parametros/taller-parametros.mapeo.spec.ts
import { MAPEO_TENANT, dtoAValoresTenant } from "./taller-parametros.mapeo";

describe("dtoAValoresTenant", () => {
  it("solo incluye los campos definidos en el DTO, mapeados a su clave del catálogo", () => {
    const resultado = dtoAValoresTenant({ usaFacturacion: false, whatsappPhoneNumberId: "123" });
    expect(resultado).toEqual({
      "facturacion.usa_facturacion": false,
      "notificaciones.whatsapp_phone_number_id": "123",
    });
  });

  it("un DTO vacío produce un objeto vacío", () => {
    expect(dtoAValoresTenant({})).toEqual({});
  });

  it("MAPEO_TENANT cubre las 12 claves tenant-scope relevantes al formulario", () => {
    expect(Object.values(MAPEO_TENANT).sort()).toEqual(
      [
        "facturacion.usa_facturacion",
        "inventario.usa_validacion",
        "productos.permite_crear",
        "tiendas.permite_crear",
        "notificaciones.usa_whatsapp",
        "notificaciones.whatsapp_phone_number_id",
        "notificaciones.whatsapp_access_token",
        "notificaciones.whatsapp_api_version",
        "notificaciones.whatsapp_template_cotizacion",
        "notificaciones.whatsapp_template_estado",
        "notificaciones.whatsapp_template_language",
        "notificaciones.whatsapp_default_country_code",
      ].sort(),
    );
  });
});
```

- [ ] **Step 3: Correr la prueba para verificar que falla**

Run: `cd backend && npx jest src/taller-parametros/taller-parametros.mapeo.spec.ts`
Expected: FAIL — `Cannot find module './taller-parametros.mapeo'`.

- [ ] **Step 4: Implementar el mapeo**

```ts
// backend/src/taller-parametros/taller-parametros.mapeo.ts
import { ActualizarParametrosTenantDto } from "./dto/actualizar-parametros-tenant.dto";
import { TallerParametroClave } from "./taller-parametros.catalogo";

export const MAPEO_TENANT: Record<keyof ActualizarParametrosTenantDto, TallerParametroClave> = {
  usaFacturacion: "facturacion.usa_facturacion",
  usaValidacionInventario: "inventario.usa_validacion",
  permiteCrearProductos: "productos.permite_crear",
  permiteCrearTiendas: "tiendas.permite_crear",
  usaWhatsapp: "notificaciones.usa_whatsapp",
  whatsappPhoneNumberId: "notificaciones.whatsapp_phone_number_id",
  whatsappAccessToken: "notificaciones.whatsapp_access_token",
  whatsappApiVersion: "notificaciones.whatsapp_api_version",
  whatsappTemplateCotizacion: "notificaciones.whatsapp_template_cotizacion",
  whatsappTemplateEstado: "notificaciones.whatsapp_template_estado",
  whatsappTemplateLanguage: "notificaciones.whatsapp_template_language",
  whatsappDefaultCountryCode: "notificaciones.whatsapp_default_country_code",
};

export function dtoAValoresTenant(
  dto: ActualizarParametrosTenantDto,
): Partial<Record<TallerParametroClave, unknown>> {
  const valores: Partial<Record<TallerParametroClave, unknown>> = {};
  for (const [campo, clave] of Object.entries(MAPEO_TENANT) as Array<
    [keyof ActualizarParametrosTenantDto, TallerParametroClave]
  >) {
    const valor = dto[campo];
    if (valor !== undefined) {
      valores[clave] = valor;
    }
  }
  return valores;
}
```

- [ ] **Step 5: Correr la prueba**

Run: `cd backend && npx jest src/taller-parametros/taller-parametros.mapeo.spec.ts`
Expected: PASS, 3 pruebas.

- [ ] **Step 6: Commit**

```bash
git add backend/src/taller-parametros/dto backend/src/taller-parametros/taller-parametros.mapeo.ts backend/src/taller-parametros/taller-parametros.mapeo.spec.ts
git commit -m "feat(taller-parametros): DTOs y mapeo clave↔campo para el controlador de configuración"
```

---

## Task 20: `getResumenTenant()` + `TallerParametrosController`

Última pieza de backend. `TallerParametrosModule` y `FacturacionPosModule` se
necesitan mutuamente (la sonda vive en el controlador nuevo pero usa
`PostouchClientService`, que vive en `facturacion-pos/`) — se resuelve con
`forwardRef()`, patrón estándar de Nest para este caso exacto, documentado
inline. `GET /taller-parametros/flags` es la única ruta sin
`@RequirePermission` — cualquier usuario autenticado del tenant puede
consultarla (la usa el frontend para ocultar acciones, no solo
administradores).

**Files:**
- Modify: `backend/src/taller-parametros/taller-parametros.service.ts`
- Modify: `backend/src/taller-parametros/taller-parametros.service.spec.ts`
- Modify: `backend/src/taller-parametros/taller-parametros.module.ts`
- Modify: `backend/src/facturacion-pos/facturacion-pos.module.ts`
- Create: `backend/src/taller-parametros/taller-parametros.controller.ts`
- Create: `backend/src/taller-parametros/taller-parametros.controller.spec.ts`

**Interfaces:**
- Produces: `TallerParametrosService.getResumenTenant(): Promise<ResumenConfiguracionTenant>` (tipo `{ tenant: Partial<Record<TallerParametroClave, ValorParaUI>>; tiendas: Array<{ tiendaId: number; nombre: string; parametros: Partial<Record<TallerParametroClave, ValorParaUI>> }> }`, `ValorParaUI = boolean | string | number | { configurado: boolean }`). Rutas: `GET /taller-parametros/catalogo`, `GET /taller-parametros`, `GET /taller-parametros/flags`, `PUT /taller-parametros/tenant`, `POST /taller-parametros/tiendas/:id/facturacion-pos/probar`. Consumidas por Tasks 21-24 (frontend).

- [ ] **Step 1: Escribir la prueba de `getResumenTenant()`**

Agregar al final de `taller-parametros.service.spec.ts`:

```ts
describe("TallerParametrosService.getResumenTenant", () => {
  it("enmascara claves sensibles como {configurado:boolean}, nunca el valor real", async () => {
    const { service, repo, tenantAccessor } = createService();
    const tiendaRepo = {
      find: jest.fn(async () => [{ id: 3, nombre: "Tienda 3" }]),
    };
    tenantAccessor.repositoryFor.mockImplementation((entity: any) =>
      entity?.name === "Tienda" ? tiendaRepo : entity?.name === "TallerParametroHistorial" ? { create: jest.fn(), save: jest.fn() } : repo,
    );

    await service.setParaTienda(3, "facturacion_pos.password", "secreta", 1);

    const resumen = await service.getResumenTenant();

    expect(resumen.tiendas).toEqual([
      expect.objectContaining({
        tiendaId: 3,
        nombre: "Tienda 3",
        parametros: expect.objectContaining({ "facturacion_pos.password": { configurado: true } }),
      }),
    ]);
  });

  it("incluye todas las claves tenant-scope con su valor (o default) tipado", async () => {
    const { service, tenantAccessor } = createService();
    // Captura la implementacion ORIGINAL antes de reemplazarla -- llamar
    // getMockImplementation() DENTRO de la nueva implementacion se
    // autorreferencia (devuelve la funcion que se esta definiendo, no la de
    // createService()) y recursiona infinito para cualquier entidad que no
    // sea Tienda.
    const original = tenantAccessor.repositoryFor.getMockImplementation()!;
    tenantAccessor.repositoryFor.mockImplementation((entity: any) =>
      entity?.name === "Tienda" ? { find: jest.fn(async () => []) } : original(entity),
    );

    const resumen = await service.getResumenTenant();

    expect(resumen.tenant["facturacion.usa_facturacion"]).toBe(true);
    expect(resumen.tenant["notificaciones.usa_whatsapp"]).toBe(false);
  });
});
```

- [ ] **Step 2: Correr la prueba para verificar que falla**

Run: `cd backend && npx jest src/taller-parametros/taller-parametros.service.spec.ts -t getResumenTenant`
Expected: FAIL — `service.getResumenTenant is not a function`.

- [ ] **Step 3: Implementar `getResumenTenant`**

Agregar a `taller-parametros.service.ts`:

```ts
  // Todas las tiendas del tenant, sin filtrar por activa/transito -- a
  // proposito: filtrar por ese criterio es logica de AuthService.listTiendas
  // (query builder con status/alta/esTransito), y llamarla desde aca
  // agregaria un tercer modulo a la dependencia circular ya existente con
  // FacturacionPosModule (ver TallerParametrosModule). Para la pantalla de
  // configuracion, ver TODAS las tiendas (incluidas inactivas) es aceptable
  // -- un administrador configurando POS quiere ver el panorama completo.
  async getResumenTenant(): Promise<ResumenConfiguracionTenant> {
    const tenant: Partial<Record<TallerParametroClave, ValorParaUI>> = {};
    for (const clave of clavesPorAmbito("tenant")) {
      tenant[clave] = await this.valorParaUI(clave, () => this.get(clave));
    }

    const tiendaClaves = clavesPorAmbito("tienda");
    const tiendaRepo = this.tenantAccessor.repositoryFor(Tienda);
    const tiendasCrudas = await tiendaRepo.find({ order: { id: "ASC" } });

    const tiendas: ResumenConfiguracionTenant["tiendas"] = [];
    for (const tienda of tiendasCrudas) {
      const parametros: Partial<Record<TallerParametroClave, ValorParaUI>> = {};
      for (const clave of tiendaClaves) {
        parametros[clave] = await this.valorParaUI(clave, () => this.getParaTienda(tienda.id, clave));
      }
      tiendas.push({ tiendaId: tienda.id, nombre: tienda.nombre ?? `Tienda ${tienda.id}`, parametros });
    }

    return { tenant, tiendas };
  }

  private async valorParaUI(clave: TallerParametroClave, leer: () => Promise<unknown>): Promise<ValorParaUI> {
    const valor = await leer();
    if (entradaDe(clave).sensible) {
      return { configurado: Boolean(valor) };
    }
    return valor as ValorParaUI;
  }
```

Agregar los tipos e imports que hagan falta:

```ts
import { Tienda } from "../auth/entities/tienda.entity";
import { clavesPorAmbito } from "./taller-parametros.catalogo";
```

```ts
export type ValorParaUI = boolean | string | number | { configurado: boolean };

export type ResumenConfiguracionTenant = {
  tenant: Partial<Record<TallerParametroClave, ValorParaUI>>;
  tiendas: Array<{ tiendaId: number; nombre: string; parametros: Partial<Record<TallerParametroClave, ValorParaUI>> }>;
};
```

- [ ] **Step 4: Correr todas las pruebas del servicio**

Run: `cd backend && npx jest src/taller-parametros/taller-parametros.service.spec.ts`
Expected: PASS, todas.

- [ ] **Step 5: Escribir la prueba del controlador**

```ts
// backend/src/taller-parametros/taller-parametros.controller.spec.ts
import { BadRequestException } from "@nestjs/common";
import { TallerParametrosController } from "./taller-parametros.controller";

function createController(overrides: Record<string, any> = {}) {
  const tallerParametrosService = {
    getResumenTenant: jest.fn(async () => ({ tenant: {}, tiendas: [] })),
    getFlags: jest.fn(async () => ({
      usaFacturacion: true,
      permiteTiendas: true,
      permiteProductosPropios: true,
      usaWhatsapp: false,
    })),
    setGrupo: jest.fn(async () => undefined),
    getParaTienda: jest.fn(async () => "clave-guardada"),
    ...overrides.tallerParametrosService,
  };
  const postouchClientService = {
    probarConexion: jest.fn(async () => ({ ok: true })),
    ...overrides.postouchClientService,
  };

  const controller = new TallerParametrosController(tallerParametrosService as any, postouchClientService as any);
  return { controller, tallerParametrosService, postouchClientService };
}

describe("TallerParametrosController", () => {
  it("getCatalogo() devuelve el catálogo completo, sin llamar al servicio", () => {
    const { controller, tallerParametrosService } = createController();
    const catalogo = controller.getCatalogo();
    expect(catalogo["facturacion.usa_facturacion"]).toBeDefined();
    expect(tallerParametrosService.getResumenTenant).not.toHaveBeenCalled();
  });

  it("actualizarTenant() mapea el DTO y llama a setGrupo con el usuario actor del request", async () => {
    const { controller, tallerParametrosService } = createController();
    const req = { user: { usuarioLocalId: 42 } };

    await controller.actualizarTenant({ usaFacturacion: false }, req);

    expect(tallerParametrosService.setGrupo).toHaveBeenCalledWith({ "facturacion.usa_facturacion": false }, 42);
  });

  it("probarConexionPos() usa la contraseña del request si viene", async () => {
    const { controller, postouchClientService, tallerParametrosService } = createController();

    await controller.probarConexionPos(3, {
      url: "http://postouch",
      username: "22",
      password: "clave-del-form",
      computadora: "5.5",
    });

    expect(postouchClientService.probarConexion).toHaveBeenCalledWith({
      url: "http://postouch",
      username: "22",
      password: "clave-del-form",
      computadora: "5.5",
    });
    expect(tallerParametrosService.getParaTienda).not.toHaveBeenCalled();
  });

  it("probarConexionPos() usa la contraseña guardada si el request no manda una", async () => {
    const { controller, postouchClientService, tallerParametrosService } = createController();

    await controller.probarConexionPos(3, { url: "http://postouch", username: "22", computadora: "5.5" });

    expect(tallerParametrosService.getParaTienda).toHaveBeenCalledWith(3, "facturacion_pos.password");
    expect(postouchClientService.probarConexion).toHaveBeenCalledWith(
      expect.objectContaining({ password: "clave-guardada" }),
    );
  });

  it("probarConexionPos() rechaza si no hay contraseña ni en el request ni guardada", async () => {
    const { controller } = createController({
      tallerParametrosService: { getParaTienda: jest.fn(async () => "") },
    });

    await expect(
      controller.probarConexionPos(3, { url: "http://postouch", username: "22", computadora: "5.5" }),
    ).rejects.toThrow(BadRequestException);
  });
});
```

- [ ] **Step 6: Correr la prueba para verificar que falla**

Run: `cd backend && npx jest src/taller-parametros/taller-parametros.controller.spec.ts`
Expected: FAIL — `Cannot find module './taller-parametros.controller'`.

- [ ] **Step 7: Implementar el controlador**

```ts
// backend/src/taller-parametros/taller-parametros.controller.ts
import { BadRequestException, Body, Controller, Get, Param, ParseIntPipe, Post, Put, Request, UseGuards, UseInterceptors } from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import { RequirePermission } from "../auth/decorators/require-permission.decorator";
import { JwtAuthGuard } from "../auth/guards/jwt-auth.guard";
import { PermissionGuard } from "../auth/guards/permission.guard";
import { PostouchClientService } from "../facturacion-pos/postouch-client.service";
import { TenantGuard } from "../tenancy/tenant.guard";
import { TenantReleaseInterceptor } from "../tenancy/tenant-release.interceptor";
import { ActualizarParametrosTenantDto } from "./dto/actualizar-parametros-tenant.dto";
import { ProbarConexionPosDto } from "./dto/probar-conexion-pos.dto";
import { TALLER_PARAMETROS_CATALOGO } from "./taller-parametros.catalogo";
import { dtoAValoresTenant } from "./taller-parametros.mapeo";
import { TallerParametrosService } from "./taller-parametros.service";

@ApiTags("taller-parametros")
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, TenantGuard, PermissionGuard)
@UseInterceptors(TenantReleaseInterceptor)
@Controller("taller-parametros")
export class TallerParametrosController {
  constructor(
    private readonly tallerParametrosService: TallerParametrosService,
    private readonly postouchClientService: PostouchClientService,
  ) {}

  @Get("catalogo")
  @RequirePermission("configuracion", "read")
  @ApiOperation({ summary: "Metadata del catálogo de parámetros (sin valores), para renderizar el formulario" })
  getCatalogo() {
    return TALLER_PARAMETROS_CATALOGO;
  }

  @Get()
  @RequirePermission("configuracion", "read")
  @ApiOperation({ summary: "Resumen de configuración del tenant (valores actuales, sensibles enmascarados)" })
  async getResumen() {
    return this.tallerParametrosService.getResumenTenant();
  }

  // Sin @RequirePermission a propósito: cualquier usuario autenticado del
  // tenant necesita saber si debe ocultar "Facturar"/"Nueva tienda"/"Nuevo
  // producto" -- no solo quien administra la configuración.
  @Get("flags")
  @ApiOperation({ summary: "Flags booleanos no sensibles para que el frontend oculte acciones" })
  async getFlags() {
    return this.tallerParametrosService.getFlags();
  }

  @Put("tenant")
  @RequirePermission("configuracion", "write")
  @ApiOperation({ summary: "Actualizar parámetros de ámbito tenant (facturación, inventario, productos, tiendas, WhatsApp)" })
  async actualizarTenant(@Body() dto: ActualizarParametrosTenantDto, @Request() req: any) {
    const valores = dtoAValoresTenant(dto);
    await this.tallerParametrosService.setGrupo(valores, req.user.usuarioLocalId);
    return { ok: true };
  }

  @Post("tiendas/:id/facturacion-pos/probar")
  @RequirePermission("configuracion", "write")
  @ApiOperation({ summary: "Probar credenciales de facturación POS contra POSTouch, sin crear ninguna orden" })
  async probarConexionPos(@Param("id", ParseIntPipe) id: number, @Body() dto: ProbarConexionPosDto) {
    let password = dto.password;
    if (!password) {
      password = await this.tallerParametrosService.getParaTienda(id, "facturacion_pos.password");
      if (!password) {
        throw new BadRequestException(
          "No hay contraseña guardada para esta tienda -- ingresá una para poder probar.",
        );
      }
    }

    return this.postouchClientService.probarConexion({
      url: dto.url,
      username: dto.username,
      password,
      computadora: dto.computadora,
    });
  }
}
```

- [ ] **Step 8: Resolver la dependencia circular con `forwardRef`**

En `taller-parametros.module.ts`:

```ts
// backend/src/taller-parametros/taller-parametros.module.ts
import { Module, forwardRef } from "@nestjs/common";
import { FacturacionPosModule } from "../facturacion-pos/facturacion-pos.module";
import { TenancyModule } from "../tenancy/tenancy.module";
import { TallerParametrosController } from "./taller-parametros.controller";
import { TallerParametrosService } from "./taller-parametros.service";

// Ciclo real, no accidental: TallerParametrosController necesita
// PostouchClientService (la sonda de conexion vive aca por diseño -- ver
// spec §9.4), y FacturacionPosModule necesita TallerParametrosService (Task
// 12, gate de usa_facturacion + config POS). forwardRef() es el mecanismo
// estandar de Nest para este caso exacto -- ambos lados lo declaran.
@Module({
  imports: [TenancyModule, forwardRef(() => FacturacionPosModule)],
  controllers: [TallerParametrosController],
  providers: [TallerParametrosService],
  exports: [TallerParametrosService],
})
export class TallerParametrosModule {}
```

En `facturacion-pos.module.ts`, agregar el `forwardRef` recíproco y exportar
también `PostouchClientService` (hoy solo exporta `FacturacionPosService`):

```ts
import { Module, forwardRef } from "@nestjs/common";
// ... resto de imports sin cambios ...
import { TallerParametrosModule } from "../taller-parametros/taller-parametros.module";

@Module({
  imports: [ControlPlaneModule, ProductosModule, TenancyModule, forwardRef(() => TallerParametrosModule)],
  providers: [FacturacionPosService, PostouchClientService],
  exports: [FacturacionPosService, PostouchClientService],
})
export class FacturacionPosModule {}
```

- [ ] **Step 9: Correr las pruebas del controlador**

Run: `cd backend && npx jest src/taller-parametros/taller-parametros.controller.spec.ts`
Expected: PASS, 5 pruebas.

- [ ] **Step 10: Build completo de todo el backend**

Run: `cd backend && npm run build`
Expected: sin errores — confirma que `forwardRef()` resuelve el ciclo
correctamente y que ningún import quedó roto por los cambios de constructor
de Tasks 10-14.

- [ ] **Step 11: Commit**

```bash
git add backend/src/taller-parametros backend/src/facturacion-pos/facturacion-pos.module.ts
git commit -m "feat(taller-parametros): controlador de configuración (resumen, flags, catálogo, sonda POS)"
```

---

## Task 21: `configuracionApi` en `frontend/src/lib/api.ts`

Backend terminado. Empieza el frontend. Mismo patrón que `authApi`
(`apiFetch<T>(endpoint, options)`, ya maneja token, refresh, y parsea el
mensaje de error del backend hacia `error.message` — ningún manejo de error
nuevo hace falta acá, los `onError` de las mutaciones ya lo consumen así en
todo el resto de la app).

**Files:**
- Modify: `frontend/src/lib/api.ts`

**Interfaces:**
- Produces: `configuracionApi.catalogo()`, `.resumen()`, `.flags()`, `.actualizarTenant(data)`, `.probarConexionPos(tiendaId, data)`, y los tipos `CatalogoParametroEntrada`, `ValorParaUI`, `ResumenConfiguracion`, `FlagsConfiguracion`. Usados por Tasks 22-24.

- [ ] **Step 1: Agregar los tipos y el objeto `configuracionApi`**

Agregar al final de `api.ts` (después de `authApi` y el resto de los `*Api`
existentes):

```ts
export type CatalogoParametroEntrada = {
  ambito: "tenant" | "tienda";
  tipo: "boolean" | "string" | "number" | "json";
  grupo: string;
  etiqueta: string;
  descripcion: string;
  sensible: boolean;
  default?: boolean | string | number;
  requeridoSi?: string;
  comportamientoAnteFallo?: "bloquear" | "permitir";
};

export type ValorParaUI = boolean | string | number | { configurado: boolean };

export type ResumenConfiguracion = {
  tenant: Record<string, ValorParaUI>;
  tiendas: Array<{ tiendaId: number; nombre: string; parametros: Record<string, ValorParaUI> }>;
};

export type FlagsConfiguracion = {
  usaFacturacion: boolean;
  permiteTiendas: boolean;
  permiteProductosPropios: boolean;
  usaWhatsapp: boolean;
};

export const configuracionApi = {
  catalogo: () => apiFetch<Record<string, CatalogoParametroEntrada>>("/taller-parametros/catalogo"),

  resumen: () => apiFetch<ResumenConfiguracion>("/taller-parametros"),

  flags: () => apiFetch<FlagsConfiguracion>("/taller-parametros/flags"),

  actualizarTenant: (data: Record<string, boolean | string | undefined>) =>
    apiFetch<{ ok: true }>("/taller-parametros/tenant", {
      method: "PUT",
      body: JSON.stringify(data),
    }),

  probarConexionPos: (
    tiendaId: number,
    data: { url: string; username: string; password?: string; computadora: string },
  ) =>
    apiFetch<{ ok: boolean; detalle?: string }>(
      `/taller-parametros/tiendas/${tiendaId}/facturacion-pos/probar`,
      { method: "POST", body: JSON.stringify(data) },
    ),
};
```

- [ ] **Step 2: Verificación de tipos**

Run: `cd frontend && npm run typecheck`
Expected: sin errores.

- [ ] **Step 3: Commit**

```bash
git add frontend/src/lib/api.ts
git commit -m "feat(frontend): configuracionApi para la pantalla de configuración"
```

---

## Task 22: Componente compartido `FacturacionPosConfigForm`

Extraído de la sección "Facturación POS" de `tiendas/page.tsx`
(líneas 423-522, ya leídas completas). Cambio de fondo respecto del
original, no solo extracción: el formulario ya NO se inicializa desde los
campos `facturacionPos*` del objeto `Tienda` (`GET /auth/tiendas`) — esos
quedaron congelados desde que Task 13 dejó de escribirlos. Se inicializa
desde `parametros` (la forma que devuelve `GET /taller-parametros`, claves
del catálogo), que sí es la fuente de verdad después de la migración. Agrega
el botón "Probar conexión" que el original no tenía.

**Files:**
- Create: `frontend/src/components/facturacion-pos-config-form.tsx`

**Interfaces:**
- Consumes: `authApi.updateTiendaFacturacionPos` (ya existente), `configuracionApi.probarConexionPos` (Task 21).
- Produces: componente `FacturacionPosConfigForm({ tiendaId, tiendaNombre, parametros, onSaved? })`. Usado por Tasks 23 y 24.

- [ ] **Step 1: Crear el componente**

```tsx
// frontend/src/components/facturacion-pos-config-form.tsx
"use client";

import { useEffect, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useToast } from "@/hooks/use-toast";
import { authApi, configuracionApi, type ValorParaUI } from "@/lib/api";
import { Loader2, Receipt, Wifi } from "lucide-react";

export type FacturacionPosParametros = Record<string, ValorParaUI>;

type FacturacionPosFormState = {
  habilitada: boolean;
  url: string;
  username: string;
  password: string;
  computadora: string;
  precioDecimales: string;
};

function aFormState(parametros: FacturacionPosParametros): FacturacionPosFormState {
  const precioDecimales = parametros["facturacion_pos.precio_decimales"];
  return {
    habilitada: Boolean(parametros["facturacion_pos.habilitada"]),
    url: typeof parametros["facturacion_pos.url"] === "string" ? (parametros["facturacion_pos.url"] as string) : "",
    username:
      typeof parametros["facturacion_pos.username"] === "string"
        ? (parametros["facturacion_pos.username"] as string)
        : "",
    password: "",
    computadora:
      typeof parametros["facturacion_pos.computadora"] === "string"
        ? (parametros["facturacion_pos.computadora"] as string)
        : "",
    precioDecimales: typeof precioDecimales === "number" && precioDecimales > 0 ? String(precioDecimales) : "2",
  };
}

function passwordYaConfigurada(parametros: FacturacionPosParametros): boolean {
  const valor = parametros["facturacion_pos.password"];
  return typeof valor === "object" && valor !== null && Boolean((valor as { configurado?: boolean }).configurado);
}

export function FacturacionPosConfigForm({
  tiendaId,
  tiendaNombre,
  parametros,
  onSaved,
}: {
  tiendaId: number;
  tiendaNombre: string;
  parametros: FacturacionPosParametros;
  onSaved?: () => void;
}) {
  const { toast } = useToast();
  const queryClient = useQueryClient();
  const [form, setForm] = useState<FacturacionPosFormState>(() => aFormState(parametros));
  const [probando, setProbando] = useState(false);
  const [resultadoPrueba, setResultadoPrueba] = useState<{ ok: boolean; detalle?: string } | null>(null);

  useEffect(() => {
    setForm(aFormState(parametros));
    setResultadoPrueba(null);
  }, [tiendaId, parametros]);

  const guardarMutation = useMutation({
    mutationFn: async () => {
      const precioDecimales = Number(form.precioDecimales);
      if (form.habilitada && (!Number.isFinite(precioDecimales) || precioDecimales < 0)) {
        throw new Error("Los decimales de redondeo deben ser un número válido.");
      }
      if (form.habilitada && (!form.url.trim() || !form.username.trim() || !form.computadora.trim())) {
        throw new Error("URL, usuario y computadora son obligatorios mientras la facturación POS esté activada.");
      }

      return authApi.updateTiendaFacturacionPos(tiendaId, {
        habilitada: form.habilitada,
        url: form.url || undefined,
        username: form.username || undefined,
        password: form.password || undefined,
        computadora: form.computadora || undefined,
        precioDecimales: form.habilitada ? precioDecimales : undefined,
      });
    },
    onSuccess: async () => {
      await queryClient.invalidateQueries({ queryKey: ["tiendas"] });
      await queryClient.invalidateQueries({ queryKey: ["configuracion"] });
      setForm((current) => ({ ...current, password: "" }));
      setResultadoPrueba(null);
      onSaved?.();
      toast({
        variant: "success",
        title: "Facturación POS actualizada",
        description: form.habilitada
          ? "Las OTs terminadas de esta tienda se enviarán automáticamente a POSTouch."
          : "El envío automático a POSTouch quedó desactivado para esta tienda.",
      });
    },
    onError: (error: any) =>
      toast({
        variant: "destructive",
        title: "No se pudo actualizar",
        description: error.message || "Revisa los datos e intenta de nuevo.",
      }),
  });

  const probarConexion = async () => {
    if (!form.url.trim() || !form.username.trim() || !form.computadora.trim()) {
      toast({
        variant: "destructive",
        title: "Faltan datos",
        description: "Completá URL, usuario y computadora antes de probar.",
      });
      return;
    }
    setProbando(true);
    setResultadoPrueba(null);
    try {
      const resultado = await configuracionApi.probarConexionPos(tiendaId, {
        url: form.url,
        username: form.username,
        password: form.password || undefined,
        computadora: form.computadora,
      });
      setResultadoPrueba(resultado);
    } catch (error: any) {
      setResultadoPrueba({ ok: false, detalle: error.message || "No se pudo probar la conexión." });
    } finally {
      setProbando(false);
    }
  };

  return (
    <div className="max-w-xl space-y-4">
      <label className="flex items-center gap-3 rounded-xl border border-border bg-muted p-4">
        <input
          type="checkbox"
          checked={form.habilitada}
          onChange={(event) => setForm({ ...form, habilitada: event.target.checked })}
          className="h-5 w-5 rounded border-border-strong"
        />
        <span className="text-sm font-bold text-foreground">
          Activar envío automático a POSTouch para {tiendaNombre}
        </span>
      </label>

      {form.habilitada ? (
        <div className="space-y-4 rounded-xl border border-border bg-surface p-4">
          <label className="space-y-2 block">
            <span className="text-sm font-bold text-foreground">URL de create_ordenes_edngt</span>
            <Input
              value={form.url}
              onChange={(event) => setForm({ ...form, url: event.target.value })}
              className="h-11 rounded-xl"
              placeholder="http://host:puerto/create_ordenes_edngt"
            />
          </label>
          <div className="grid gap-4 md:grid-cols-2">
            <label className="space-y-2">
              <span className="text-sm font-bold text-foreground">Usuario de servicio</span>
              <Input
                value={form.username}
                onChange={(event) => setForm({ ...form, username: event.target.value })}
                className="h-11 rounded-xl"
              />
            </label>
            <label className="space-y-2">
              <span className="text-sm font-bold text-foreground">Contraseña</span>
              <Input
                type="password"
                value={form.password}
                onChange={(event) => setForm({ ...form, password: event.target.value })}
                className="h-11 rounded-xl"
                placeholder={passwordYaConfigurada(parametros) ? "Dejar en blanco para no cambiarla" : "Contraseña"}
              />
            </label>
          </div>
          <div className="grid gap-4 md:grid-cols-2">
            <label className="space-y-2">
              <span className="text-sm font-bold text-foreground">Computadora (POSTouch)</span>
              <Input
                value={form.computadora}
                onChange={(event) => setForm({ ...form, computadora: event.target.value })}
                className="h-11 rounded-xl"
                placeholder="5.5"
              />
            </label>
            <label className="space-y-2">
              <span className="text-sm font-bold text-foreground">Decimales de redondeo</span>
              <Input
                type="number"
                min={0}
                value={form.precioDecimales}
                onChange={(event) => setForm({ ...form, precioDecimales: event.target.value })}
                className="h-11 rounded-xl"
              />
            </label>
          </div>
        </div>
      ) : null}

      {resultadoPrueba ? (
        <div
          className={`rounded-xl border p-3 text-sm font-medium ${
            resultadoPrueba.ok
              ? "border-success/40 bg-success-soft text-success"
              : "border-destructive/40 bg-destructive-soft text-destructive"
          }`}
        >
          {resultadoPrueba.ok
            ? "Conexión verificada: POSTouch respondió correctamente."
            : resultadoPrueba.detalle || "No se pudo verificar la conexión."}
        </div>
      ) : null}

      <div className="flex flex-wrap gap-2">
        <Button className="rounded-xl" disabled={guardarMutation.isPending} onClick={() => guardarMutation.mutate()}>
          {guardarMutation.isPending ? (
            <Loader2 className="mr-2 h-4 w-4 animate-spin" />
          ) : (
            <Receipt className="mr-2 h-4 w-4" />
          )}
          Guardar facturación POS
        </Button>
        <Button
          variant="outline"
          className="rounded-xl"
          disabled={probando || !form.habilitada}
          onClick={probarConexion}
        >
          {probando ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Wifi className="mr-2 h-4 w-4" />}
          Probar conexión
        </Button>
      </div>
    </div>
  );
}
```

- [ ] **Step 2: Verificación de tipos**

Run: `cd frontend && npm run typecheck`
Expected: sin errores.

- [ ] **Step 3: Commit**

```bash
git add frontend/src/components/facturacion-pos-config-form.tsx
git commit -m "feat(frontend): componente compartido FacturacionPosConfigForm, con botón probar conexión"
```

---

## Task 23: `tiendas/page.tsx` usa el componente compartido

`GET /auth/tiendas` ya no refleja la config POS real (Task 13 dejó de
escribir esas columnas) — esta página necesita `configuracionApi.resumen()`
además de `authApi.tiendas()` para mostrar el estado correcto. Basado en la
lectura completa del archivo (626 líneas, ya citada arriba).

**Files:**
- Modify: `frontend/src/app/dashboard/tiendas/page.tsx`

**Interfaces:** Ninguna nueva — consume `FacturacionPosConfigForm` (Task 22) y `configuracionApi.resumen()` (Task 21).

- [ ] **Step 1: Quitar el tipo, el conversor y el estado que ya no hacen falta**

Borrar por completo (ya no se usan — el componente compartido los reemplaza):
- El tipo `FacturacionPosForm` (líneas 43-50 del archivo actual).
- La función `toFacturacionPosForm` (líneas 61-73).
- El import de `Receipt` en la línea de `lucide-react` (queda dentro del
  componente compartido) — revisar que `Building2, ImageIcon, Loader2,
  MapPin, Phone, Plus, Save, Upload` sigan importados, esos sí se usan en el
  resto del archivo.
- La línea `const [facturacionPosForm, setFacturacionPosForm] =
  useState<FacturacionPosForm>(toFacturacionPosForm(null));` (línea 90).
- El bloque completo `const facturacionPosMutation = useMutation({...})`
  (líneas 131-168).
- Dentro de `selectTienda`, la línea
  `setFacturacionPosForm(toFacturacionPosForm(tienda));` (línea 195) — el
  resto de `selectTienda` queda igual.

- [ ] **Step 2: Agregar los imports nuevos y la consulta de configuración**

Agregar a los imports del principio del archivo:

```ts
import { FacturacionPosConfigForm } from "@/components/facturacion-pos-config-form";
import { authApi, configuracionApi, uploadFileWithProgress } from "@/lib/api";
```

(reemplaza la línea de import de `@/lib/api` existente, que hoy solo trae
`authApi, uploadFileWithProgress`)

Agregar junto al `useQuery` de `tiendas` existente (después de su cierre):

```ts
  const { data: resumenConfiguracion } = useQuery({
    queryKey: ["configuracion"],
    queryFn: configuracionApi.resumen,
    enabled: canManage,
  });

  const parametrosTiendaSeleccionada = useMemo(() => {
    if (!selectedTienda) return {};
    const tienda = resumenConfiguracion?.tiendas.find(
      (t) => Number(t.tiendaId) === Number(selectedTienda.id),
    );
    return tienda?.parametros ?? {};
  }, [resumenConfiguracion, selectedTienda]);
```

- [ ] **Step 3: Reemplazar la sección de Facturación POS**

Reemplazar el bloque completo `{selectedTienda ? (<DashboardSection
title="Facturación POS (POSTouch)" ...>...</DashboardSection>) : null}`
(líneas 423-522 del archivo actual) por:

```tsx
        {selectedTienda ? (
          <DashboardSection
            title="Facturación POS (POSTouch)"
            description="Cuando esta tienda termina una OT, se envía automáticamente a POSTouch para facturarla. Cada tienda tiene su propia conexión — activarla acá no afecta a las demás."
          >
            <FacturacionPosConfigForm
              tiendaId={Number(selectedTienda.id)}
              tiendaNombre={selectedTienda.nombre}
              parametros={parametrosTiendaSeleccionada}
            />
          </DashboardSection>
        ) : null}
```

- [ ] **Step 4: Verificación de tipos y build**

Run: `cd frontend && npm run typecheck && npm run build`
Expected: sin errores.

- [ ] **Step 5: Verificación manual en navegador**

Levantar el backend y el frontend en desarrollo, entrar a
`/dashboard/tiendas` con un usuario `admin`/`super_admin`, seleccionar una
tienda, confirmar que la sección "Facturación POS" muestra los valores
actuales (los que trae `GET /taller-parametros`, no los viejos de `maetie`),
que "Probar conexión" hace la llamada esperada (verificar en la pestaña Red
del navegador que pega a `POST /taller-parametros/tiendas/:id/facturacion-pos/probar`),
y que "Guardar facturación POS" sigue funcionando igual que antes.

- [ ] **Step 6: Commit**

```bash
git add frontend/src/app/dashboard/tiendas/page.tsx
git commit -m "refactor(frontend): tiendas/page.tsx usa FacturacionPosConfigForm y lee de taller_parametros"
```

---

## Task 24: Pantalla `/dashboard/configuracion`

Agrupada por `grupo` del catálogo, banner de lo que falta, grupo de
WhatsApp oculto por completo (no deshabilitado) si el toggle está apagado,
contraseñas nunca precargadas. Usa `canRead`/`canWrite("configuracion")` —
NO el patrón `user?.rol === "admin"` que `tiendas/page.tsx` usa hoy (spec
§11, decisión explícita para no heredar la ambigüedad entre los dos sistemas
de permisos ya documentada en ese mismo archivo). `app/dashboard/layout.tsx`
ya envuelve toda página bajo `dashboard/` en `DashboardShell` — esta página
no la importa ni la renderiza ella misma, mismo patrón que `tiendas/page.tsx`.

**Files:**
- Create: `frontend/src/app/dashboard/configuracion/page.tsx`

**Interfaces:** Consume `configuracionApi` (Task 21), `FacturacionPosConfigForm` (Task 22).

- [ ] **Step 1: Crear la página**

```tsx
// frontend/src/app/dashboard/configuracion/page.tsx
"use client";

import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { DashboardEmptyState, DashboardPageIntro, DashboardSection } from "@/components/dashboard-page";
import { FacturacionPosConfigForm } from "@/components/facturacion-pos-config-form";
import { useAuth } from "@/components/providers";
import { Button } from "@/components/ui/button";
import { ErrorState } from "@/components/ui/error-state";
import { Input } from "@/components/ui/input";
import { useToast } from "@/hooks/use-toast";
import { configuracionApi, type CatalogoParametroEntrada, type ValorParaUI } from "@/lib/api";
import { AlertTriangle, Loader2, Save, Settings } from "lucide-react";

type FormularioTenant = Record<string, string | boolean>;

const ORDEN_GRUPOS = ["facturacion", "inventario", "productos", "tiendas", "notificaciones"] as const;

const ETIQUETAS_GRUPO: Record<string, string> = {
  facturacion: "Facturación",
  inventario: "Inventario",
  productos: "Productos",
  tiendas: "Tiendas",
  notificaciones: "Notificaciones (WhatsApp)",
};

// Espejo del MAPEO_TENANT del backend (taller-parametros.mapeo.ts) -- clave
// del catálogo -> campo camelCase que espera ActualizarParametrosTenantDto.
// Se duplica a propósito (frontend y backend no comparten paquete de tipos
// hoy): mismo criterio que ya usan authApi/UpdateTiendaFacturacionPosDto.
const CLAVE_A_CAMPO_API: Record<string, string> = {
  "facturacion.usa_facturacion": "usaFacturacion",
  "inventario.usa_validacion": "usaValidacionInventario",
  "productos.permite_crear": "permiteCrearProductos",
  "tiendas.permite_crear": "permiteCrearTiendas",
  "notificaciones.usa_whatsapp": "usaWhatsapp",
  "notificaciones.whatsapp_phone_number_id": "whatsappPhoneNumberId",
  "notificaciones.whatsapp_access_token": "whatsappAccessToken",
  "notificaciones.whatsapp_api_version": "whatsappApiVersion",
  "notificaciones.whatsapp_template_cotizacion": "whatsappTemplateCotizacion",
  "notificaciones.whatsapp_template_estado": "whatsappTemplateEstado",
  "notificaciones.whatsapp_template_language": "whatsappTemplateLanguage",
  "notificaciones.whatsapp_default_country_code": "whatsappDefaultCountryCode",
};

function valorInicial(entrada: CatalogoParametroEntrada, valorActual: ValorParaUI | undefined): string | boolean {
  if (entrada.sensible) return "";
  if (entrada.tipo === "boolean") return Boolean(valorActual ?? entrada.default ?? false);
  if (valorActual === undefined || valorActual === null) {
    return entrada.default !== undefined ? String(entrada.default) : "";
  }
  return String(valorActual);
}

// Estado ausente ("falta esto") calculado con el mismo criterio que el
// backend usa para requeridoSi -- valor efectivo (form actual) vacío
// mientras su gate está en true. Sin llamar al backend: el catálogo y el
// resumen ya están en memoria.
function calcularFaltantes(
  catalogo: Record<string, CatalogoParametroEntrada>,
  form: FormularioTenant,
  resumenTiendas: Array<{ tiendaId: number; nombre: string; parametros: Record<string, ValorParaUI> }>,
): string[] {
  const faltantes: string[] = [];

  for (const [clave, entrada] of Object.entries(catalogo)) {
    if (entrada.ambito !== "tenant" || !entrada.requeridoSi) continue;
    const gateEncendido = Boolean(form[entrada.requeridoSi]);
    if (!gateEncendido) continue;
    const valor = form[clave];
    if (valor === "" || valor === undefined) {
      faltantes.push(`${entrada.etiqueta} (${catalogo[entrada.requeridoSi]?.etiqueta ?? entrada.requeridoSi})`);
    }
  }

  for (const tienda of resumenTiendas) {
    const habilitada = Boolean(tienda.parametros["facturacion_pos.habilitada"]);
    if (!habilitada) continue;
    for (const clave of ["facturacion_pos.url", "facturacion_pos.username", "facturacion_pos.computadora"]) {
      const valor = tienda.parametros[clave];
      if (valor === "" || valor === undefined) {
        faltantes.push(`${catalogo[clave]?.etiqueta ?? clave} — tienda ${tienda.nombre}`);
      }
    }
    const password = tienda.parametros["facturacion_pos.password"];
    const passwordConfigurada = typeof password === "object" && password !== null && Boolean((password as any).configurado);
    if (!passwordConfigurada) {
      faltantes.push(`Contraseña de servicio — tienda ${tienda.nombre}`);
    }
  }

  return faltantes;
}

export default function ConfiguracionPage() {
  const router = useRouter();
  const { canRead, canWrite } = useAuth();
  const { toast } = useToast();
  const queryClient = useQueryClient();
  const puedeVer = canRead("configuracion");
  const puedeEditar = canWrite("configuracion");

  const catalogoQuery = useQuery({
    queryKey: ["configuracion", "catalogo"],
    queryFn: configuracionApi.catalogo,
    enabled: puedeVer,
  });
  const resumenQuery = useQuery({
    queryKey: ["configuracion"],
    queryFn: configuracionApi.resumen,
    enabled: puedeVer,
  });

  const clavesTenant = useMemo(
    () => Object.entries(catalogoQuery.data ?? {}).filter(([, entrada]) => entrada.ambito === "tenant"),
    [catalogoQuery.data],
  );

  const [form, setForm] = useState<FormularioTenant>({});

  useEffect(() => {
    if (!catalogoQuery.data || !resumenQuery.data) return;
    const inicial: FormularioTenant = {};
    for (const [clave, entrada] of clavesTenant) {
      inicial[clave] = valorInicial(entrada, resumenQuery.data.tenant[clave]);
    }
    setForm(inicial);
  }, [catalogoQuery.data, resumenQuery.data, clavesTenant]);

  const guardarMutation = useMutation({
    mutationFn: async () => {
      const cuerpo: Record<string, boolean | string | undefined> = {};
      for (const [clave, entrada] of clavesTenant) {
        const campoApi = CLAVE_A_CAMPO_API[clave];
        if (!campoApi) continue;
        const valor = form[clave];
        cuerpo[campoApi] = entrada.sensible && valor === "" ? undefined : valor;
      }
      return configuracionApi.actualizarTenant(cuerpo);
    },
    onSuccess: async () => {
      await queryClient.invalidateQueries({ queryKey: ["configuracion"] });
      setForm((current) => {
        const limpio = { ...current };
        for (const [clave, entrada] of clavesTenant) {
          if (entrada.sensible) limpio[clave] = "";
        }
        return limpio;
      });
      toast({ variant: "success", title: "Configuración guardada" });
    },
    onError: (error: any) =>
      toast({
        variant: "destructive",
        title: "No se pudo guardar",
        description: error.message || "Revisá los datos e intentá de nuevo.",
      }),
  });

  if (!puedeVer) {
    return (
      <main className="mx-auto max-w-4xl p-6">
        <DashboardEmptyState
          title="Sin acceso"
          description="Solo administrador o super admin pueden ver la configuración del taller."
          icon={Settings}
        />
      </main>
    );
  }

  if (catalogoQuery.isLoading || resumenQuery.isLoading) {
    return (
      <main className="mx-auto max-w-5xl p-6">
        <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
      </main>
    );
  }

  if (catalogoQuery.isError || resumenQuery.isError || !catalogoQuery.data || !resumenQuery.data) {
    return (
      <main className="mx-auto max-w-5xl p-6">
        <ErrorState
          onRetry={() => {
            catalogoQuery.refetch();
            resumenQuery.refetch();
          }}
          description="No se pudo cargar la configuración del taller."
        />
      </main>
    );
  }

  const faltantes = calcularFaltantes(catalogoQuery.data, form, resumenQuery.data.tiendas);
  const usaWhatsapp = Boolean(form["notificaciones.usa_whatsapp"]);

  return (
    <div className="min-h-full pb-16 md:pb-10">
      <main className="mx-auto flex max-w-5xl flex-col gap-6 p-4 md:p-6">
        <DashboardPageIntro
          title="Configuración"
          description="Toda la configuración del módulo de talleres: facturación, inventario, productos, tiendas y notificaciones."
          icon={Settings}
          tone="slate"
          onBack={() => router.push("/dashboard")}
        />

        {faltantes.length > 0 ? (
          <div className="flex items-start gap-3 rounded-xl border border-warning/40 bg-warning-soft p-4">
            <AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-warning" />
            <div>
              <p className="text-sm font-bold text-foreground">Falta completar {faltantes.length} campo(s)</p>
              <ul className="mt-1 list-inside list-disc text-sm text-muted-foreground">
                {faltantes.map((item) => (
                  <li key={item}>{item}</li>
                ))}
              </ul>
            </div>
          </div>
        ) : null}

        {ORDEN_GRUPOS.filter((grupo) => grupo !== "notificaciones" || usaWhatsapp || puedeEditar).map((grupo) => {
          const clavesDelGrupo = clavesTenant.filter(([, entrada]) => entrada.grupo === grupo);
          if (clavesDelGrupo.length === 0) return null;

          return (
            <DashboardSection key={grupo} title={ETIQUETAS_GRUPO[grupo] ?? grupo}>
              <div className="space-y-4">
                {clavesDelGrupo.map(([clave, entrada]) => {
                  // El grupo completo de WhatsApp desaparece si el toggle
                  // está apagado -- nunca se muestra deshabilitado. Ver spec
                  // §11 ("nada de mostrar ocho campos grises que no hacen
                  // nada"). La excepción es el toggle mismo, que siempre se
                  // muestra (si no, nadie podría prenderlo).
                  if (grupo === "notificaciones" && clave !== "notificaciones.usa_whatsapp" && !usaWhatsapp) {
                    return null;
                  }

                  if (entrada.tipo === "boolean") {
                    return (
                      <label key={clave} className="flex items-center gap-3 rounded-xl border border-border bg-muted p-4">
                        <input
                          type="checkbox"
                          checked={Boolean(form[clave])}
                          disabled={!puedeEditar}
                          onChange={(event) => setForm({ ...form, [clave]: event.target.checked })}
                          className="h-5 w-5 rounded border-border-strong"
                        />
                        <span>
                          <span className="block text-sm font-bold text-foreground">{entrada.etiqueta}</span>
                          <span className="block text-xs text-muted-foreground">{entrada.descripcion}</span>
                        </span>
                      </label>
                    );
                  }

                  return (
                    <label key={clave} className="space-y-2 block">
                      <span className="text-sm font-bold text-foreground">{entrada.etiqueta}</span>
                      <span className="block text-xs text-muted-foreground">{entrada.descripcion}</span>
                      <Input
                        type={entrada.sensible ? "password" : entrada.tipo === "number" ? "number" : "text"}
                        value={String(form[clave] ?? "")}
                        disabled={!puedeEditar}
                        onChange={(event) => setForm({ ...form, [clave]: event.target.value })}
                        className="h-11 rounded-xl"
                        placeholder={entrada.sensible ? "Dejar en blanco para no cambiar" : undefined}
                      />
                    </label>
                  );
                })}
              </div>
            </DashboardSection>
          );
        })}

        {puedeEditar ? (
          <div>
            <Button
              className="rounded-xl"
              disabled={guardarMutation.isPending}
              onClick={() => guardarMutation.mutate()}
            >
              {guardarMutation.isPending ? (
                <Loader2 className="mr-2 h-4 w-4 animate-spin" />
              ) : (
                <Save className="mr-2 h-4 w-4" />
              )}
              Guardar configuración
            </Button>
          </div>
        ) : null}

        <DashboardSection
          title="Facturación POS por tienda"
          description="Cada tienda tiene su propia conexión a POSTouch."
        >
          {resumenQuery.data.tiendas.length === 0 ? (
            <DashboardEmptyState
              title="No hay tiendas"
              description="Creá una tienda primero en Tiendas para poder configurar su facturación POS."
              icon={Settings}
            />
          ) : (
            <div className="space-y-8">
              {resumenQuery.data.tiendas.map((tienda) => (
                <div key={tienda.tiendaId} className="border-t border-border pt-6 first:border-none first:pt-0">
                  <p className="mb-3 text-sm font-bold text-foreground">{tienda.nombre}</p>
                  <FacturacionPosConfigForm
                    tiendaId={tienda.tiendaId}
                    tiendaNombre={tienda.nombre}
                    parametros={tienda.parametros}
                  />
                </div>
              ))}
            </div>
          )}
        </DashboardSection>
      </main>
    </div>
  );
}
```

- [ ] **Step 2: Verificación de tipos y build**

Run: `cd frontend && npm run typecheck && npm run build`
Expected: sin errores.

- [ ] **Step 3: Verificación manual en navegador**

Con backend y frontend levantados: entrar a `/dashboard/configuracion` como
`admin`/`super_admin`, confirmar que:
- Los grupos se ven separados por tema, cada control con su descripción.
- Apagar "Enviar notificaciones por WhatsApp" hace desaparecer los demás
  campos de ese grupo (no los deja grises).
- El banner de "falta completar" aparece si `facturacion_pos.habilitada` está
  en `true` en alguna tienda sin URL/usuario/computadora/contraseña.
- Guardar con un campo de WhatsApp obligatorio vacío (toggle encendido)
  muestra el mensaje de error del backend, no uno genérico.
- Con un usuario `operativo`/`recepcion`/`mecanico`, la pantalla muestra
  "Sin acceso" (confirma que `canRead("configuracion")` está funcionando de
  punta a punta con Task 18).

- [ ] **Step 4: Commit**

```bash
git add frontend/src/app/dashboard/configuracion/page.tsx
git commit -m "feat(frontend): pantalla /dashboard/configuracion"
```

---

## Task 25: Entrada de navegación en `dashboard-shell.tsx`

Última tarea del plan. `Settings` ya está en uso para "Catálogos" — se usa un
ícono distinto (`SlidersHorizontal`) para que las dos entradas no se
confundan visualmente. `permission: "configuracion"` oculta la entrada del
nav para roles sin acceso (el guard real sigue siendo el backend, Task 18 y
la propia página, Task 24).

**Files:**
- Modify: `frontend/src/components/dashboard-shell.tsx`

**Interfaces:** Ninguna — solo agrega una entrada a un array existente.

- [ ] **Step 1: Agregar el import del ícono**

Agregar `SlidersHorizontal` a la lista de imports de `lucide-react` (línea
~10-27 del archivo, junto a `Settings, Shield, Wrench`).

- [ ] **Step 2: Agregar la entrada a `secondaryNavigation`**

Agregar, junto a la entrada de `"/dashboard/tiendas"` (línea ~69):

```ts
  {
    href: "/dashboard/configuracion",
    label: "Configuración",
    icon: SlidersHorizontal,
    permission: "configuracion",
  },
```

- [ ] **Step 3: Verificación de tipos y build**

Run: `cd frontend && npm run typecheck && npm run build`
Expected: sin errores.

- [ ] **Step 4: Verificación manual**

Con el frontend levantado, confirmar que "Configuración" aparece en el nav
para un usuario `admin`/`super_admin` y NO aparece para
`operativo`/`recepcion`/`mecanico`, y que el enlace lleva a
`/dashboard/configuracion`.

- [ ] **Step 5: Commit**

```bash
git add frontend/src/components/dashboard-shell.tsx
git commit -m "feat(frontend): entrada de navegación para /dashboard/configuracion"
```

---

## Verificación final del plan completo

Después de la última tarea, antes de dar el trabajo por terminado:

- [ ] **Backend completo**: `cd backend && npm run build && npm run lint && npm run test`
  Expected: build sin errores, lint sin errores nuevos, TODA la suite en
  verde (incluida `tenant-aware-access.spec.ts` — confirma que el conteo real
  de bypasses en `taller-parametros.service.ts` coincide exactamente con `1`,
  el valor aprobado en Task 4).
- [ ] **Frontend completo**: `cd frontend && npm run typecheck && npm run lint && npm run build`
  Expected: sin errores (no hay `npm test` en este proyecto).
- [ ] **Migración contra una base de tenant real de prueba**: correr
  `npm run migration:run` (no `:prod`, contra la base de desarrollo/prueba)
  dos veces seguidas — la segunda corrida debe ser un no-op limpio (confirma
  idempotencia de `CreateTallerParametros1780700000000`).
- [ ] **Revisar el criterio "Listo cuando" del spec** (§14,
  `docs/superpowers/specs/2026-09-23-taller-parametros-design.md`) uno por
  uno contra lo implementado.
