openapi: 3.1.0
info:
  title: Cupontli API — Developers
  version: 1.0.0
  description: >-
    Superficie de integración de Cupontli: los endpoints utilizables con una API key (`cup_live_...`/`cup_test_...`) más
    los públicos del ciclo de integración (enroll, micupon). Autenticación: `Authorization: Bearer <api key>`. La API
    del dashboard humano no es parte de este contrato.
servers:
  - url: https://1ocp4a5izl.execute-api.us-east-1.amazonaws.com/prod
    description: Production
tags:
  - name: apikeys
    description: Machine credentials (API keys)
  - name: campanas
    description: Campaign CRUD
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT or API key (cup_live_...)
      description: >-
        Send `Authorization: Bearer <token>`. The token can be a Cognito JWT (humans) or an API key starting with
        `cup_live_` (machines). The authorizer branches by prefix. Mixing both in one request is not supported.
  schemas:
    LookSnapshot:
      type: object
      properties:
        variant:
          type: string
          enum:
            - B1
            - B2
            - B4
          description: Estilo del cupón snapshoteado del look.
          example: B1
        colors:
          type: object
          properties:
            background:
              type: string
              pattern: ^#[0-9A-Fa-f]{6}$
              description: Color de fondo del cupón, hex de 6 dígitos.
              example: '#FBF5EA'
            accent:
              type: string
              pattern: ^#[0-9A-Fa-f]{6}$
              description: Color de acento (badges, borde del QR, bloque B2).
              example: '#C75D43'
            text:
              type: string
              pattern: ^#[0-9A-Fa-f]{6}$
              description: Color del texto principal sobre el fondo.
              example: '#2A211A'
          required:
            - background
            - accent
            - text
          additionalProperties: false
        logoUrl:
          type: string
          maxLength: 1024
          format: uri
          description: URL pública del logo (bucket de looks) al momento del snapshot.
        heroUrl:
          type: string
          maxLength: 1024
          format: uri
          description: URL pública del hero al momento del snapshot.
      required:
        - variant
        - colors
      description: >-
        Snapshot congelado del look al asignarse (server-derived; el cliente NO lo envía). Es lo que renderizan
        mint/micupon — editar el look después no muta campañas vivas.
    ErrorResponse:
      type: object
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              description: >-
                Código de error de Cupontli (e.g. `VALIDATION_ERROR`, `ATRIBUTO_FALTANTE`,
                `CAMPANA_BLOQUEADA_PARA_EDICION`). Catálogo completo en la guía de errores.
              example: VALIDATION_ERROR
            message:
              type: string
              description: Mensaje legible para el desarrollador en español. No customer-facing.
              example: Request inválido
          required:
            - code
            - message
          description: >-
            Cuerpo del error. Acepta campos adicionales según el tipo de error (p. ej. `issues` para validación Zod,
            `atributos` para política filtrex, `campos` para lock post-canje).
      required:
        - error
      description: >-
        Shape estándar de cualquier respuesta de error. El código HTTP transporta la categoría (400 validación, 401
        auth, 403 scope, 404, 409 conflicto de estado, 500 server).
    WebhookEventPayload:
      type: object
      properties:
        deliveryId:
          type: string
          pattern: ^[0-9A-HJKMNP-TV-Z]{26}$
          description: Id único de ESTA entrega. Úsalo como llave de idempotencia en tu receptor.
          example: 01JZK3NDEKTSV4RRFFQ69G5FAV
        eventId:
          type: string
          pattern: ^[0-9A-HJKMNP-TV-Z]{26}$
          description: Id del evento de negocio subyacente (estable si el evento se reenviara).
          example: 01JZK3ND8H2M4N5P6Q7R8S9T0V
        type:
          type: string
          description: Tipo del evento (`<recurso>.<acción>`).
          example: coupon.redeemed
        occurredAt:
          type: string
          description: Timestamp ISO-8601 UTC del evento.
          example: '2026-07-06T18:25:43.511Z'
        environment:
          type: string
          enum:
            - live
            - test
          description: Ambiente que emitió el evento.
        resource:
          anyOf:
            - type: object
              properties:
                type:
                  type: string
                  enum:
                    - coupon
                campaignId:
                  type: string
                  pattern: ^[0-9A-HJKMNP-TV-Z]{26}$
                serial:
                  type: integer
              required:
                - type
                - campaignId
                - serial
            - type: object
              properties:
                type:
                  type: string
                  enum:
                    - campaign
                campaignId:
                  type: string
                  pattern: ^[0-9A-HJKMNP-TV-Z]{26}$
              required:
                - type
                - campaignId
            - type: object
              properties:
                type:
                  type: string
                id:
                  type: string
              required:
                - type
                - id
          description: >-
            Referencia estructurada al recurso: `{type:'coupon', campaignId, serial}` (hidratar con POST
            /v1/coupons/lookup) o `{type:'campaign', campaignId}` (hidratar con GET /v1/campaigns/{id}). El token del
            cupón NUNCA viaja en webhooks.
        data:
          type: object
          additionalProperties: {}
          description: >-
            Datos específicos del tipo de evento (payload del evento de auditoría). Ausente si el evento no lleva
            extras.
        actor:
          type: object
          properties:
            kind:
              type: string
              example: apikey
            id:
              type: string
              example: 3a7bd3e2360a3d29…
          required:
            - kind
            - id
          description: Quién causó el evento (humano, apikey o sistema).
      required:
        - deliveryId
        - eventId
        - type
        - occurredAt
        - environment
        - resource
        - actor
    ProductoTemplate:
      type: object
      properties:
        name:
          type: string
          description: Nombre del producto.
          example: Café 250g
        price:
          type: number
          description: Precio unitario (2 decimales).
          example: 100
        moneda:
          type: string
          description: Moneda ISO 4217 (uppercase).
          example: MXN
        pais:
          type: string
          description: País ISO alpha-2 (uppercase).
          example: MX
        category:
          type: string
          description: Categoría (lowercase).
          example: bebidas
        pid:
          type: string
          description: SKU/ID de catálogo del producto. Presente solo si es un SKU real (el sintético `manual:<uuid>` se descarta).
          example: CAFE-250
      required:
        - name
        - price
        - moneda
        - pais
        - category
    Policy:
      type: object
      properties:
        policyId:
          type: string
          pattern: ^[0-9A-HJKMNP-TV-Z]{26}$
          description: Identificador ULID de la política (server-set).
          example: 01JWQ8ZK5M7N9P2R4T6V8X0YB6
        tenantId:
          type: string
          format: uuid
          description: Tenant dueño de la política (server-set del contexto).
          example: b2d4e1c7-3a5f-4e6c-8b9d-1a2c3d4e5f60
        name:
          type: string
          minLength: 1
          description: Nombre legible de la política, mostrado en el picker.
          example: Solo CDMX, monto mínimo
        description:
          type: string
          maxLength: 256
          description: Nota humana opcional sobre la política.
          example: Aplica a promociones de la zona metropolitana.
        rules:
          type: array
          items:
            type: object
            properties:
              descripcion:
                type: string
                minLength: 1
                description: Texto descriptivo de la regla que se reporta al POS cuando esta regla falla en la validación.
                example: Solo aplica en sucursales de la zona metropolitana
              expr:
                type: string
                minLength: 1
                description: >-
                  Expresión filtrex de UNA comparación simple (var op valor, o var in (lista)) que debe evaluar a true
                  para que la regla pase. Los strings literales van con comillas dobles, no simples. No se permiten
                  and/or ni expresiones compuestas: usa varias reglas (AND implícito entre ellas).
                example: ciudad in ("CDMX", "Monterrey", "Guadalajara")
            required:
              - descripcion
              - expr
            additionalProperties: false
          minItems: 1
          description: >-
            Reglas filtrex evaluadas en AND. Cada regla lleva su descripción (que el validador reporta al POS cuando esa
            regla falla).
        version:
          type: integer
          exclusiveMinimum: 0
          description: >-
            Versión de la política. Arranca en 1 y se incrementa en cada edición. La campaña snapshotea esta versión
            para detectar drift.
          example: 1
        active:
          type: boolean
          description: false = soft-deleted (oculta del listado por defecto).
          example: true
        createdAt:
          type: string
          description: Instante de creación, ISO 8601 UTC (server-set).
          example: '2026-06-02T18:30:42.123Z'
        createdBy:
          type: string
          description: Identidad del creador (email humano o apikey:<id>).
          example: admin@tenant.com
        updatedBy:
          type: string
          description: Identidad de la última edición.
          example: admin@tenant.com
        updatedAt:
          type: string
          description: Instante de la última edición, ISO 8601 UTC.
          example: '2026-06-03T10:00:00.000Z'
      required:
        - policyId
        - tenantId
        - name
        - rules
        - version
        - active
        - createdAt
        - createdBy
    Look:
      type: object
      properties:
        lookId:
          type: string
          pattern: ^[0-9A-HJKMNP-TV-Z]{26}$
          description: Identificador ULID del look (server-set).
          example: 01JWQ8ZK5M7N9P2R4T6V8X0YC7
        tenantId:
          type: string
          format: uuid
          description: Tenant dueño del look (server-set del contexto).
          example: b2d4e1c7-3a5f-4e6c-8b9d-1a2c3d4e5f60
        name:
          type: string
          minLength: 1
          maxLength: 64
          description: Nombre legible del look, mostrado en la biblioteca y el picker.
          example: Café Mañana
        variant:
          type: string
          enum:
            - B1
            - B2
            - B4
          description: 'Estilo del cupón (disposición base de los slots): B1 Soleado, B2 Bloque de color, B4 Cupón protagonista.'
          example: B1
        colors:
          type: object
          properties:
            background:
              type: string
              pattern: ^#[0-9A-Fa-f]{6}$
              description: Color de fondo del cupón, hex de 6 dígitos.
              example: '#FBF5EA'
            accent:
              type: string
              pattern: ^#[0-9A-Fa-f]{6}$
              description: Color de acento (badges, borde del QR, bloque B2).
              example: '#C75D43'
            text:
              type: string
              pattern: ^#[0-9A-Fa-f]{6}$
              description: Color del texto principal sobre el fondo.
              example: '#2A211A'
          required:
            - background
            - accent
            - text
          additionalProperties: false
          description: Los 3 colores de marca del tenant.
        logoUrl:
          type:
            - string
            - 'null'
          maxLength: 1024
          format: uri
          description: >-
            URL pública del logo en el bucket de looks. Debe apuntar al prefijo del tenant (el backend lo verifica).
            null/ausente = sin logo.
          example: >-
            https://cupontliv2-looks.s3.us-east-1.amazonaws.com/b2d4e1c7-3a5f-4e6c-8b9d-1a2c3d4e5f60/01JWQ8ZK5M7N9P2R4T6V8X0YC7/logo-a3f9c2d41b7e8f06.webp
        heroUrl:
          type:
            - string
            - 'null'
          maxLength: 1024
          format: uri
          description: >-
            URL pública de la imagen principal (hero) en el bucket de looks; mismo contrato que logoUrl. null/ausente =
            look solo-logo.
        version:
          type: integer
          exclusiveMinimum: 0
          description: >-
            Versión del look. Arranca en 1 y se incrementa en cada edición. La campaña snapshotea esta versión para
            detectar drift.
          example: 1
        active:
          type: boolean
          description: false = soft-deleted (oculto del listado por defecto).
          example: true
        createdAt:
          type: string
          description: Instante de creación, ISO 8601 UTC (server-set).
          example: '2026-06-11T18:30:42.123Z'
        createdBy:
          type: string
          description: Identidad del creador (email humano o apikey:<id>).
          example: admin@tenant.com
        updatedBy:
          type: string
          description: Identidad de la última edición.
          example: admin@tenant.com
        updatedAt:
          type: string
          description: Instante de la última edición, ISO 8601 UTC.
          example: '2026-06-12T10:00:00.000Z'
      required:
        - lookId
        - tenantId
        - name
        - variant
        - colors
        - version
        - active
        - createdAt
        - createdBy
    PresignLookAsset:
      type: object
      properties:
        uploadUrl:
          type: string
          format: uri
          description: >-
            Presigned PUT hacia el bucket de looks. Expira en ~5 minutos. Subir con header Content-Type: image/webp y el
            cuerpo exacto declarado.
          example: https://cupontli-looks.s3.amazonaws.com/t/7a3e8b12/logo-a3f9c2d41b7e8f06.webp?X-Amz-Signature=…
        publicUrl:
          type: string
          format: uri
          description: URL pública final del asset. Tras el 200 del PUT, este valor va en logoUrl/heroUrl del look (create/PATCH).
          example: https://cupontli-looks.s3.amazonaws.com/t/7a3e8b12/logo-a3f9c2d41b7e8f06.webp
      required:
        - uploadUrl
        - publicUrl
    DescargaMasivaResponseData:
      type: object
      properties:
        campaignId:
          type: string
          pattern: ^[0-9A-HJKMNP-TV-Z]{26}$
          example: 01J9ZX8K3M4N5P6Q7R8S9T0V1W
        name:
          type: string
          description: Nombre de la campaña.
        quantity:
          type: integer
          description: Total de cupones del lote (= maxCoupons de la campaña).
          example: 1000
        bulkMintedAt:
          type: string
          description: Cuándo se reclamó el lote (ISO 8601 UTC).
          example: '2026-07-03T15:00:00Z'
        bulkMintedBy:
          type: string
          description: 'Quién reclamó el lote: email del admin o `apikey:<keyHash>` de la integración.'
          example: admin@empresa.mx
        coupons:
          type: array
          items:
            type: object
            properties:
              serial:
                type: integer
                example: 0
              token:
                type: string
                description: Token del cupón (el mismo que emite el minteo normal).
              url:
                type: string
                description: Liga viva del cupón (`/m/c#token`). Para papel, este es el payload del QR.
            required:
              - serial
              - token
              - url
          description: TODOS los cupones de la campaña, serial 0..quantity-1.
      required:
        - campaignId
        - name
        - quantity
        - bulkMintedAt
        - bulkMintedBy
        - coupons
    CreateReporteResponseData:
      type: object
      properties:
        jobId:
          type: string
          description: ID del job (ULID). Usar en GET /v1/reports/{jobId} para poll y descarga.
          example: 01J9ZX8K3M4N5P6Q7R8S9T0V1W
        status:
          type: string
          enum:
            - pending
            - running
            - ready
            - failed
          description: Estado inicial del job (siempre 'pending').
          example: pending
      required:
        - jobId
        - status
    ReporteEstadoResponseData:
      type: object
      properties:
        status:
          type: string
          enum:
            - pending
            - running
            - ready
            - failed
          description: >-
            Estado del job. 'failed' puede ser DERIVADO por staleness (job pending/running por más de ~15 min → el
            worker murió, reintentable).
          example: ready
        url:
          type: string
          description: >-
            URL presigned de descarga del CSV (TTL corto, ~5 min). Presente solo si estado='ready'. Se re-firma en cada
            GET — no cachear.
        error:
          type: string
          description: Mensaje de error. Presente solo si estado='failed'.
      required:
        - status
    ReporteJobSummary:
      type: object
      properties:
        jobId:
          type: string
          example: 01J9ZX8K3M4N5P6Q7R8S9T0V1W
        type:
          type: string
          enum:
            - campaigns
            - inventory
            - leads
          description: Tipo de reporte.
          example: inventory
        params:
          description: Los params con los que se lanzó (rango, campaña, etc.).
          example:
            desde: '2026-06-01'
            hasta: '2026-06-30'
        status:
          type: string
          enum:
            - pending
            - running
            - ready
            - failed
          description: Estado del job (con staleness derivado).
          example: ready
        createdBy:
          type: string
          description: Email humano o `apikey:<keyHash>` de quien lo solicitó.
          example: apikey:3a7bd3e2360a3d29eea436fcfb7e44c735d117c42d1c1835420b6b9942dd4f1b
        createdAt:
          type: string
          description: ISO 8601 UTC.
          example: '2026-07-06T18:25:43.511Z'
        updatedAt:
          type: string
          description: >-
            ISO 8601 UTC de la última transición de estado. En un job `ready` ≈ el momento en que terminó de generarse →
            el cliente calcula la duración (updatedAt − createdAt). Ausente si el job sigue en `pending` (nunca se
            actualizó).
      required:
        - jobId
        - type
        - status
        - createdBy
        - createdAt
    ListReportesResponseData:
      type: object
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/ReporteJobSummary'
      required:
        - items
  parameters: {}
paths:
  /v1/apikeys/revoke-self:
    post:
      operationId: revokeSelfApiKey
      x-required-scope: coupon:redeem
      x-error-codes:
        '400':
          - BAD_REQUEST
        '401':
          - UNAUTHORIZED
        '403':
          - FORBIDDEN
        '404':
          - NOT_FOUND
        '500':
          - SERVER_ERROR
          - LOOKUP_FAILED
          - REVOKE_FAILED
      tags:
        - apikeys
      security:
        - bearerAuth: []
      summary: 'Revoca la api key del propio caller (idempotente) (scope: coupon:redeem)'
      description: >-
        No lleva body: revoca la key con la que el caller se autenticó (su `keyHash` sale del bearer). Pensado para que
        una POS revoque su credencial al des-enrolar. La autoridad es la posesión del bearer — no requiere
        `apikey:write`, y estructuralmente solo puede revocar la propia key, nunca otra. Idempotente: revocar dos veces
        devuelve el mismo recurso con `active: false`.
      responses:
        '200':
          description: Api key revocada o ya estaba revocada (idempotente).
          content:
            application/json:
              schema:
                type: object
                properties:
                  keyHash:
                    type: string
                    description: >-
                      Hash sha256 (hex, 64 chars) del token. Es el pk de la entity y sirve como identificador estable de
                      la apikey. NO es credencial — el plaintext es la credencial. Sirve para correlacionar con logs de
                      auditoria (enmascarados como `apikey:<first6>...<last6>`) y para revocar via DELETE
                      /v1/apikeys/{keyHash} cuando el plaintext se perdio.
                    example: 3a7bd3e2360a3d29eea436fcfb7e44c735d117c42d1c1835420b6b9942dd4f1b
                  tenantId:
                    type: string
                    format: uuid
                    description: >-
                      UUID del tenant dueño de la api key. Redundante para el caller autenticado pero se incluye por
                      simetría con el shape de campana.
                    example: 7a3e8b12-9c4d-4f5e-a6b7-c8d9e0f1a2b3
                  name:
                    type: string
                    description: Etiqueta humana de la api key.
                    example: POS sucursal Polanco
                  scopes:
                    type: array
                    items:
                      type: string
                    description: >-
                      Scopes asignados (posiblemente filtrados contra el catálogo, por lo que pueden ser un subconjunto
                      de los enviados al crear).
                    example:
                      - coupon:redeem
                      - coupon:read
                  active:
                    type: boolean
                    description: >-
                      `true` si la api key sigue válida. Una revocación lo pone en `false` (revocación lógica, el record
                      persiste para audit trail).
                    example: true
                  createdAt:
                    type: string
                    description: Instante de creación en ISO 8601 UTC.
                    example: '2026-05-16T14:30:00.000Z'
                  createdBy:
                    type: string
                    description: Identificador del humano (email) o api key (`apikey:<hash>`) que creó esta key.
                    example: admin@ejemplo.com
                  profile:
                    type: object
                    additionalProperties: {}
                    description: >-
                      Snapshot (copia congelada) de los atributos del perfil al crear la key. Optional — solo presente
                      si se creó con un perfil.
                    example:
                      pais: MX
                      canal: POS
                      idTienda: MX-001
                  profileId:
                    type: string
                    description: ULID del perfil del que se snapshoteó el profile (procedencia). Optional.
                    example: 01JWQ8ZK5M7N9P2R4T6V8X0YB6
                  environment:
                    type: string
                    enum:
                      - live
                      - test
                    description: >-
                      Ambiente al que pertenece la api key. Determinado por el caller que la creó (post Fase ApiKeys
                      Simetría).
                    example: live
                  tags:
                    type: array
                    items:
                      type: string
                    description: >-
                      Tags asignados a la api key. Mutados via PUT/DELETE /v1/apikeys/{keyHash}/tags/{tagName}. Cap 10
                      por api key.
                    example:
                      - pos-cdmx
                      - integracion-erp
                required:
                  - keyHash
                  - tenantId
                  - name
                  - scopes
                  - active
                  - createdAt
                  - createdBy
                  - environment
        '400':
          description: '`BAD_REQUEST` — Request malformado: falta el body o un parámetro de path requerido.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: '`UNAUTHORIZED` — Falta el header Authorization o la credencial es inválida/revocada.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: '`FORBIDDEN` — La credencial es válida pero no porta el scope que la operación exige.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: >-
            `NOT_FOUND` — El recurso no existe para tu tenant/ambiente. Anti-enumeración: identificadores de otro tenant
            o malformados colapsan a este mismo código.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: >-
            `SERVER_ERROR` — Error interno no controlado. Reintentable. `LOOKUP_FAILED` — Falla interna al resolver la
            credencial/invitación. Reintentable. `REVOKE_FAILED` — La revocación falló al escribir (falla interna).
            Reintentable.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
      x-audience: machine
      x-developer: true
  /v1/enroll:
    post:
      operationId: claimEnrollment
      x-audience: public
      x-developer: true
      x-error-codes:
        '400':
          - VALIDATION_ERROR
          - INVALID_JSON
          - BAD_REQUEST
        '404':
          - NOT_FOUND
        '500':
          - SERVER_ERROR
          - LOOKUP_FAILED
      tags:
        - apikeys
      security: []
      summary: Reclama una invitacion de enrolamiento (publico — el claimToken es la credencial)
      description: >-
        La POS canjea la invitacion: valida pending + no expirada + ambiente del prefijo del token, mintea la api key
        con los scopes preset + profile de la invitacion, marca la invitacion como `claimed` y quema el token (un solo
        uso, transaccional). La api key plaintext se devuelve UNA sola vez — nace dentro del device. Anti-enumeration:
        cualquier fallo de resolucion (id inexistente, token incorrecto, ya reclamada, revocada, expirada,
        tenant/ambiente no corresponden) colapsa al mismo 404 NOT_FOUND.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                tenantId:
                  type: string
                  format: uuid
                  description: UUID del tenant (del QR, query param `tenant`).
                  example: 7a3e8b12-9c4d-4f5e-a6b7-c8d9e0f1a2b3
                enrollmentId:
                  type: string
                  pattern: ^[0-9A-HJKMNP-TV-Z]{26}$
                  description: ULID de la invitacion (del QR, query param `id`).
                  example: 01ARZ3NDEKTSV4RRFFQ69G5FAV
                claimToken:
                  type: string
                  description: >-
                    Token de la invitacion (del QR, fragment `#token=`). El prefijo `enrl_live_`/`enrl_test_` determina
                    el ambiente. ES la credencial.
                  example: enrl_live_8f2a9b1c4d7e6f0a3b5c2d8e1f9a7b4c
              required:
                - tenantId
                - enrollmentId
                - claimToken
              additionalProperties: false
      responses:
        '200':
          description: Api key minteada — mismo shape que POST /v1/apikeys, `token` plaintext incluido (unica vez).
          content:
            application/json:
              schema:
                type: object
                properties:
                  keyHash:
                    type: string
                    description: >-
                      Hash sha256 (hex, 64 chars) del token. Es el pk de la entity y sirve como identificador estable de
                      la apikey. NO es credencial — el plaintext es la credencial. Sirve para correlacionar con logs de
                      auditoria (enmascarados como `apikey:<first6>...<last6>`) y para revocar via DELETE
                      /v1/apikeys/{keyHash} cuando el plaintext se perdio.
                    example: 3a7bd3e2360a3d29eea436fcfb7e44c735d117c42d1c1835420b6b9942dd4f1b
                  tenantId:
                    type: string
                    format: uuid
                    description: >-
                      UUID del tenant dueño de la api key. Redundante para el caller autenticado pero se incluye por
                      simetría con el shape de campana.
                    example: 7a3e8b12-9c4d-4f5e-a6b7-c8d9e0f1a2b3
                  name:
                    type: string
                    description: Etiqueta humana de la api key.
                    example: POS sucursal Polanco
                  scopes:
                    type: array
                    items:
                      type: string
                    description: >-
                      Scopes asignados (posiblemente filtrados contra el catálogo, por lo que pueden ser un subconjunto
                      de los enviados al crear).
                    example:
                      - coupon:redeem
                      - coupon:read
                  active:
                    type: boolean
                    description: >-
                      `true` si la api key sigue válida. Una revocación lo pone en `false` (revocación lógica, el record
                      persiste para audit trail).
                    example: true
                  createdAt:
                    type: string
                    description: Instante de creación en ISO 8601 UTC.
                    example: '2026-05-16T14:30:00.000Z'
                  createdBy:
                    type: string
                    description: Identificador del humano (email) o api key (`apikey:<hash>`) que creó esta key.
                    example: admin@ejemplo.com
                  profile:
                    type: object
                    additionalProperties: {}
                    description: >-
                      Snapshot (copia congelada) de los atributos del perfil al crear la key. Optional — solo presente
                      si se creó con un perfil.
                    example:
                      pais: MX
                      canal: POS
                      idTienda: MX-001
                  profileId:
                    type: string
                    description: ULID del perfil del que se snapshoteó el profile (procedencia). Optional.
                    example: 01JWQ8ZK5M7N9P2R4T6V8X0YB6
                  environment:
                    type: string
                    enum:
                      - live
                      - test
                    description: >-
                      Ambiente al que pertenece la api key. Determinado por el caller que la creó (post Fase ApiKeys
                      Simetría).
                    example: live
                  tags:
                    type: array
                    items:
                      type: string
                    description: >-
                      Tags asignados a la api key. Mutados via PUT/DELETE /v1/apikeys/{keyHash}/tags/{tagName}. Cap 10
                      por api key.
                    example:
                      - pos-cdmx
                      - integracion-erp
                  token:
                    type: string
                    description: >-
                      Token plaintext con prefijo `cup_live_` o `cup_test_` segun el ambiente del caller. Aparece solo
                      en esta respuesta — no se persiste de forma recuperable. Si se pierde, hay que revocar y recrear.
                    example: cup_live_8f2a9b1c4d7e6f0a3b5c2d8e1f9a7b4c
                required:
                  - keyHash
                  - tenantId
                  - name
                  - scopes
                  - active
                  - createdAt
                  - createdBy
                  - environment
                  - token
        '400':
          description: >-
            `VALIDATION_ERROR` — El body no cumple el shape del request (Zod). El detalle viene en `error.issues`.
            `INVALID_JSON` — El body no es JSON válido. `BAD_REQUEST` — Request malformado: falta el body o un parámetro
            de path requerido.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: >-
            `NOT_FOUND` — El recurso no existe para tu tenant/ambiente. Anti-enumeración: identificadores de otro tenant
            o malformados colapsan a este mismo código.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: >-
            `SERVER_ERROR` — Error interno no controlado. Reintentable. `LOOKUP_FAILED` — Falla interna al resolver la
            credencial/invitación. Reintentable.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /v1/cache/{kind}:
    get:
      operationId: getTenantCache
      x-required-scope: campaign:read
      x-error-codes:
        '400':
          - BAD_REQUEST
        '401':
          - UNAUTHORIZED
        '403':
          - FORBIDDEN
        '404':
          - NOT_FOUND
        '500':
          - SERVER_ERROR
      tags:
        - cache
      security:
        - bearerAuth: []
      summary: 'Lee el cache de captura del tenant (scope: campaign:read)'
      description: >-
        Devuelve valores usados antes por el tenant para autocompletar el form de creación de campaña: `kind=products`
        da templates de producto (nombre/precio/moneda/país/categoría) y `kind=clients` da nombres de cliente (strings).
        NO es el catálogo/CRM del negocio ni fuente de verdad. Tenant-scoped: cada caller ve solo su propio cache. Dedup
        de variantes tipográficas del nombre (mayúsculas/acentos); en productos las variantes de precio se conservan. El
        shape de la respuesta depende del `kind`.
      parameters:
        - schema:
            type: string
            enum:
              - products
              - customers
            description: 'Categoría del cache: `products` o `clients`.'
            example: products
          required: true
          name: kind
          in: path
      responses:
        '200':
          description: >-
            Cache del tenant para el `kind` pedido, ordenado alfabéticamente. `{ productos }` para `products`, `{
            clientes }` para `clients`.
          content:
            application/json:
              schema:
                anyOf:
                  - type: object
                    properties:
                      productos:
                        type: array
                        items:
                          $ref: '#/components/schemas/ProductoTemplate'
                    required:
                      - productos
                  - type: object
                    properties:
                      clientes:
                        type: array
                        items:
                          type: string
                          example: Café del Centro
                    required:
                      - clientes
        '400':
          description: '`BAD_REQUEST` — Request malformado: falta el body o un parámetro de path requerido.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: '`UNAUTHORIZED` — Falta el header Authorization o la credencial es inválida/revocada.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: '`FORBIDDEN` — La credencial es válida pero no porta el scope que la operación exige.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: >-
            `NOT_FOUND` — El recurso no existe para tu tenant/ambiente. Anti-enumeración: identificadores de otro tenant
            o malformados colapsan a este mismo código.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: '`SERVER_ERROR` — Error interno no controlado. Reintentable.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
      x-audience: machine
      x-developer: true
  /v1/cache/products:
    put:
      operationId: replaceTenantProductCache
      x-required-scope: campaign:write
      x-error-codes:
        '400':
          - VALIDATION_ERROR
          - INVALID_JSON
          - BAD_REQUEST
        '401':
          - UNAUTHORIZED
        '403':
          - FORBIDDEN
        '404':
          - NOT_FOUND
        '500':
          - SERVER_ERROR
      tags:
        - cache
      security:
        - bearerAuth: []
      summary: 'Reemplaza la lista de productos guardados del tenant (scope: campaign:write)'
      description: >-
        Reemplaza la lista COMPLETA de productos guardados del tenant (editor 'Administrar productos'). El cache es
        índice de conveniencia, no catálogo: se manda toda la lista curada y el server la pisa wholesale (sin merge, sin
        IDs por item). La identidad de dedup la deriva el server (el SKU si existe; si no, nombre+precio+moneda). `pais`
        lo deriva el cliente de la moneda; `pid` opcional = SKU real (el sintético `manual:<uuid>` se descarta).
        Tenant-scoped. Lista vacía → vacía el cache. Devuelve la lista resultante, ordenada alfabéticamente.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                products:
                  type: array
                  items:
                    type: object
                    properties:
                      name:
                        type: string
                        minLength: 1
                        maxLength: 120
                        example: Café especial 250g
                      price:
                        type: number
                        minimum: 0
                        example: 180
                      moneda:
                        type: string
                        minLength: 1
                        maxLength: 8
                        example: MXN
                      pais:
                        type: string
                        maxLength: 8
                        default: ''
                        example: MX
                      category:
                        type: string
                        maxLength: 60
                        default: ''
                        example: bebidas-cafe
                      pid:
                        type: string
                        maxLength: 120
                        example: CAFE-250
                    required:
                      - name
                      - price
                      - moneda
                    additionalProperties: false
                  maxItems: 200
              required:
                - products
              additionalProperties: false
      responses:
        '200':
          description: Lista de productos guardados tras el reemplazo.
          content:
            application/json:
              schema:
                type: object
                properties:
                  productos:
                    type: array
                    items:
                      $ref: '#/components/schemas/ProductoTemplate'
                required:
                  - productos
        '400':
          description: >-
            `VALIDATION_ERROR` — El body no cumple el shape del request (Zod). El detalle viene en `error.issues`.
            `INVALID_JSON` — El body no es JSON válido. `BAD_REQUEST` — Request malformado: falta el body o un parámetro
            de path requerido.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: '`UNAUTHORIZED` — Falta el header Authorization o la credencial es inválida/revocada.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: '`FORBIDDEN` — La credencial es válida pero no porta el scope que la operación exige.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: >-
            `NOT_FOUND` — El recurso no existe para tu tenant/ambiente. Anti-enumeración: identificadores de otro tenant
            o malformados colapsan a este mismo código.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: '`SERVER_ERROR` — Error interno no controlado. Reintentable.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
      x-audience: machine
      x-developer: true
  /v1/campaigns:
    get:
      operationId: listCampaigns
      x-required-scope: campaign:read
      x-error-codes:
        '400':
          - BAD_REQUEST
          - INVALID_QUERY
        '401':
          - UNAUTHORIZED
        '403':
          - FORBIDDEN
        '500':
          - SERVER_ERROR
      tags:
        - campanas
      security:
        - bearerAuth: []
      summary: 'Lista campanas (scope: campaign:read)'
      parameters:
        - schema:
            type: string
            enum:
              - archived
            description: >-
              Filtro de estado. Si se omite, el listado excluye campañas archivadas (default operativo). Si `archived`,
              devuelve solo las archivadas.
            example: archived
          required: false
          name: status
          in: query
        - schema:
            type: string
            minLength: 1
            maxLength: 32
            pattern: ^[a-z0-9-]+$
            description: >-
              Filtra por tag asignado. Lowercase, alfanumérico + guiones, 1-32 chars. Si se omite, no se filtra por tag.
              Combinable con `status` (ambos filtros aplican).
            example: promo-verano
          required: false
          name: tag
          in: query
        - schema:
            type: string
            description: >-
              Tamaño de página solicitado, entero entre 1 y 100. Default 50 si se omite. Valores fuera de rango
              devuelven `400 INVALID_QUERY`.
            example: '50'
          required: false
          name: pageSize
          in: query
        - schema:
            type: string
            description: >-
              Cursor opaco devuelto en `nextToken` del response anterior. Reenviar tal cual para pedir la siguiente
              página.
          required: false
          name: nextToken
          in: query
      responses:
        '200':
          description: Listado paginado.
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      type: object
                      properties:
                        campaignId:
                          type: string
                          pattern: ^[0-9A-HJKMNP-TV-Z]{26}$
                          description: Identificador ULID de la campaña. Server-set al crear (el cliente no lo envía).
                          example: 01ARZ3NDEKTSV4RRFFQ69G5FAV
                        tenantId:
                          type: string
                          format: uuid
                          description: Identificador UUID del tenant dueño de la campaña. Server-set desde el authorizer.
                          example: 7a3e8b12-9c4d-4f5e-a6b7-c8d9e0f1a2b3
                        customer:
                          type: string
                          description: >-
                            Marca/comercio que aparece en el cupón emitido (la UI lo etiqueta como 'Marca'; default =
                            nombre de la organización del tenant). Nombre de campo `cliente` por compatibilidad con v1.
                          example: Cafetería del Centro
                        description:
                          type: string
                          maxLength: 128
                          description: >-
                            Descripción interna del admin (máximo 128 caracteres). No es el mensaje al cliente final ni
                            la justificación del precio.
                          example: Push de junio para mover inventario de café especial
                        name:
                          type: string
                          minLength: 1
                          description: Nombre legible de la campaña, mostrado en consolas de admin.
                          example: Promo Día del Padre 2026
                        maxCoupons:
                          type: integer
                          exclusiveMinimum: 0
                          description: Total de cupones que la campaña puede emitir. Cap implícito de anti-abuso junto con maxUses.
                          example: 500
                        maxUses:
                          type: integer
                          exclusiveMinimum: 0
                          description: Cuántas veces cada cupón individual puede canjearse. Default 1 (un solo uso por cupón).
                          example: 1
                        createdAt:
                          type: string
                          description: Instante de creación en ISO 8601 UTC (sufijo `Z`). Server-set.
                          example: '2026-05-16T14:30:00.000Z'
                        startDate:
                          type: string
                          description: >-
                            Inicio del rango de validez. Wall-clock interpretado en la `timezone` de la campaña. Acepta
                            YYYY-MM-DD (se normaliza a T00:00:00) o YYYY-MM-DDTHH:MM:SS sin offset.
                          example: '2026-06-01T00:00:00'
                        endDate:
                          type: string
                          description: >-
                            Fin del rango de validez. Mismo formato que startDate; se normaliza a T23:59:59 si solo
                            viene la fecha.
                          example: '2026-06-30T23:59:59'
                        timezone:
                          type: string
                          minLength: 1
                          description: >-
                            IANA timezone de la campaña, por ejemplo `America/Mexico_City`. Es la única fuente de verdad
                            para resolver startDate/endDate a UTC. No acepta offsets (`-06:00`) ni abreviaciones
                            (`CST`).
                          example: America/Mexico_City
                        lastSerial:
                          type: integer
                          minimum: 0
                          description: 'Folio del último cupón emitido. Server-managed: incrementa con cada createCoupon.'
                          example: 42
                        status:
                          type: string
                          enum:
                            - draft
                            - active
                            - expired
                            - paused
                            - archived
                          description: >-
                            Estado de la campaña en su ciclo de vida. `activa` es el default al crear; el admin puede
                            mover a `suspendida` o `archivada` vía PATCH.
                          example: active
                        mintMechanism:
                          type: string
                          description: >-
                            Mecanismo de minteo de la campaña. `"autoservicio"` = minteo público self-service de
                            Cupontli (`POST /v1/mint`). Cualquier otro valor = `keyHash` de la api key de la integración
                            que mintea por la API autenticada. Ausencia = sin mecanismo declarado (minteo público
                            cerrado).
                          example: self-service
                        createdBy:
                          type: string
                          description: Identificador del usuario Cognito o api key que creó la campaña. Server-set.
                          example: admin@ejemplo.com
                        updatedBy:
                          type: string
                          description: >-
                            Identificador del principal que actualizó la campaña por última vez vía controller.
                            Server-set en cada PATCH.
                          example: admin@ejemplo.com
                        updatedAt:
                          type: string
                          description: >-
                            Instante de la última actualización vía controller, en ISO 8601 UTC. No se bumpea por
                            escrituras de sistema (totalRedemptions, lastSerial).
                          example: '2026-05-17T09:15:22.000Z'
                        products:
                          type: array
                          items:
                            type: object
                            properties:
                              pid:
                                type: string
                                minLength: 1
                                maxLength: 128
                                description: >-
                                  Identificador del producto en el catálogo del tenant (free-form: SKU, id interno,
                                  etc.). La UI manual genera `manual:<uuid>`.
                                example: CAFE-250G
                              name:
                                type: string
                                minLength: 1
                                description: Nombre legible del producto.
                                example: Café especial 250g
                              quantity:
                                type: integer
                                exclusiveMinimum: 0
                                description: Unidades de este producto incluidas en la promoción.
                                example: 3
                              price:
                                type: number
                                exclusiveMinimum: 0
                                description: Precio unitario del producto en su moneda.
                                example: 180
                              moneda:
                                type: string
                                minLength: 1
                                description: Código de moneda (ISO 4217 sugerido, por ejemplo `MXN`, `USD`, `COP`).
                                example: MXN
                              pais:
                                type: string
                                minLength: 1
                                description: Código de país donde aplica este producto (alfa-2 sugerido).
                                example: MX
                              category:
                                type: string
                                minLength: 1
                                description: Categoría libre del producto para clasificación interna.
                                example: bebidas-cafe
                            required:
                              - pid
                              - name
                              - quantity
                              - price
                              - moneda
                              - pais
                              - category
                            additionalProperties: false
                          description: >-
                            Listado de productos incluidos en la promoción. Cada uno con cantidad y precio. `listPrice`
                            se calcula como Σ productos[i].price × cantidad.
                        listPrice:
                          type: number
                          minimum: 0
                          description: >-
                            Precio de lista del paquete, calculado por el servidor como Σ productos[i].price × cantidad.
                            No se acepta del cliente.
                          example: 540
                        finalPrice:
                          type: number
                          minimum: 0
                          description: >-
                            Precio efectivo que paga el cliente final. Un solo número que colapsa las cuatro mecánicas
                            promocionales (porcentaje, monto fijo, precio fijo, NxM). Para regalo, usar `0`.
                          example: 360
                        finalPriceJustification:
                          type: string
                          minLength: 20
                          maxLength: 500
                          description: >-
                            Texto de auditoría que explica por qué este precio (audiencia: equipo financiero del
                            tenant). Entre 20 y 500 caracteres. Distinto a `couponMessage` que es customer-facing.
                          example: >-
                            Descuento promocional del 33% sobre el precio de lista para impulsar volumen durante junio.
                            Cierre presupuestal Q2.
                        couponMessage:
                          type: string
                          minLength: 1
                          maxLength: 80
                          description: >-
                            Texto promocional customer-facing que ve el cliente final (máximo 80 caracteres). Aquí vive
                            el `sabor` promocional (`3x2`, `20% off`, etc.).
                          example: 3x2 en cafés - hasta el 30 de junio
                        offerSubtitle:
                          type: string
                          maxLength: 60
                          description: >-
                            Subtítulo corto customer-facing bajo el mensaje del cupón (máximo 60 caracteres). Opcional;
                            cadena vacía lo elimina.
                          example: Solo en sucursal · presenta tu QR en caja
                        maxBudget:
                          type: number
                          minimum: 0
                          description: >-
                            Tope absoluto del costo agregado del incentivo. Si está definido, el validador rechaza
                            canjes que excederían `(totalRedemptions+1) × (listPrice − finalPrice)` con
                            `PRESUPUESTO_AGOTADO`.
                          example: 90000
                        totalRedemptions:
                          type: integer
                          minimum: 0
                          default: 0
                          description: >-
                            Contador atómico de canjes exitosos. Server-managed: se incrementa transaccionalmente en
                            `validateCoupon` junto con el patch del cupón.
                          example: 17
                        policyRules:
                          type: array
                          items:
                            type: object
                            properties:
                              descripcion:
                                type: string
                                minLength: 1
                                description: >-
                                  Texto descriptivo de la regla que se reporta al POS cuando esta regla falla en la
                                  validación.
                                example: Solo aplica en sucursales de la zona metropolitana
                              expr:
                                type: string
                                minLength: 1
                                description: >-
                                  Expresión filtrex de UNA comparación simple (var op valor, o var in (lista)) que debe
                                  evaluar a true para que la regla pase. Los strings literales van con comillas dobles,
                                  no simples. No se permiten and/or ni expresiones compuestas: usa varias reglas (AND
                                  implícito entre ellas).
                                example: ciudad in ("CDMX", "Monterrey", "Guadalajara")
                            required:
                              - descripcion
                              - expr
                            additionalProperties: false
                          description: >-
                            Reglas filtrex evaluadas al canjear el cupón. AND implícito entre reglas; cada una se evalúa
                            por separado para reportar fallas individuales en `error.details.failures[]`.
                            Server-derived: es el snapshot congelado de las reglas de la política referida por
                            `policyId` al momento de crear la campaña — el cliente NO lo envía.
                        policyId:
                          type: string
                          pattern: ^[0-9A-HJKMNP-TV-Z]{26}$
                          description: >-
                            ULID de la política referida. El server snapshotea sus reglas a `politica[]` y guarda su
                            versión en `policyVersion`. Opcional: una campaña puede no tener política.
                          example: 01JWQ8ZK5M7N9P2R4T6V8X0YB6
                        policyVersion:
                          type: integer
                          exclusiveMinimum: 0
                          description: >-
                            Versión de la política al momento del snapshot (server-set). Permite detectar drift contra
                            la política actual.
                          example: 1
                        lookId:
                          type:
                            - string
                            - 'null'
                          pattern: ^[0-9A-HJKMNP-TV-Z]{26}$
                          description: >-
                            ULID del look (diseño) referido. El server snapshotea variante/colores/URLs a `look` y su
                            versión a `lookVersion`. null en PATCH = quitar el diseño.
                          example: 01JWQ8ZK5M7N9P2R4T6V8X0YC7
                        look:
                          $ref: '#/components/schemas/LookSnapshot'
                        lookVersion:
                          type: integer
                          exclusiveMinimum: 0
                          description: >-
                            Versión del look al momento del snapshot (server-set). Permite detectar drift contra el look
                            actual.
                          example: 1
                        legalText:
                          type: string
                          description: Texto legal opcional anexado al cupón (términos, condiciones, restricciones).
                          example: >-
                            Promoción válida hasta agotar inventario. No acumulable con otras ofertas. Aplica solo en
                            sucursales participantes.
                        tags:
                          type: array
                          items:
                            type: string
                            minLength: 1
                            maxLength: 32
                            pattern: ^[a-z0-9-]+$
                          maxItems: 10
                          description: >-
                            Tags asignados a la campaña. Se mutan via PUT/DELETE
                            /v1/campaigns/{campaignId}/tags/{tagName}, no via PATCH del recurso. Máximo 10 por campaña.
                          example:
                            - promo-verano
                            - q2-2026
                        exhaustedNotifiedAt:
                          type: string
                          description: >-
                            Server-set: cuándo se anunció la exhaustión por presupuesto de la campaña (evento
                            campaign.exhausted). Solo lectura.
                          example: '2026-07-02T18:32:00Z'
                        bulkMintedAt:
                          type: string
                          description: >-
                            Server-set: cuándo la descarga masiva reclamó la campaña completa. Presente ⇔ la campaña se
                            agotó por descarga y su set de cupones puede volver a descargarse. Solo lectura.
                          example: '2026-07-03T15:00:00Z'
                        bulkMintedBy:
                          type: string
                          description: >-
                            Server-set: quién reclamó el lote (email del admin o `apikey:<keyHash>` de la integración).
                            Solo lectura.
                          example: admin@empresa.mx
                        termsAccepted:
                          type: boolean
                          description: >-
                            Confirmación explícita del admin de que aceptó los términos al crear la campaña. Debe ser
                            `true`. No se persiste; gate del request schema.
                          example: true
                      required:
                        - campaignId
                        - tenantId
                        - name
                        - maxCoupons
                        - createdAt
                        - startDate
                        - endDate
                        - timezone
                        - lastSerial
                        - status
                        - createdBy
                        - listPrice
                        - finalPrice
                        - finalPriceJustification
                        - couponMessage
                        - termsAccepted
                      additionalProperties: false
                    description: >-
                      Items de esta página. Puede contener menos que `pageSize` si es la última página o si el endpoint
                      aplica filtros post-query.
                  nextToken:
                    type: string
                    description: >-
                      Cursor opaco para pedir la siguiente página. Reenviar tal cual en `?nextToken=<valor>`. Ausente =
                      última página.
                    example: eyJwayI6IlQjMSIsInNrIjoiQyMxIn0=
                required:
                  - items
        '400':
          description: >-
            `BAD_REQUEST` — Request malformado: falta el body o un parámetro de path requerido. `INVALID_QUERY` — Query
            params inválidos (filtro desconocido, paginación malformada o rango de fechas ilegible).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: '`UNAUTHORIZED` — Falta el header Authorization o la credencial es inválida/revocada.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: '`FORBIDDEN` — La credencial es válida pero no porta el scope que la operación exige.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: '`SERVER_ERROR` — Error interno no controlado. Reintentable.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
      x-audience: machine
      x-developer: true
    post:
      operationId: createCampaign
      x-required-scope: campaign:write
      x-error-codes:
        '400':
          - VALIDATION_ERROR
          - INVALID_JSON
          - BAD_REQUEST
          - POLICY_INACTIVE
          - LOOK_INACTIVE
        '401':
          - UNAUTHORIZED
        '403':
          - FORBIDDEN
        '404':
          - POLICY_NOT_FOUND
          - LOOK_NOT_FOUND
        '500':
          - SERVER_ERROR
      tags:
        - campanas
      security:
        - bearerAuth: []
      summary: 'Crea campana (scope: campaign:write)'
      requestBody:
        content:
          application/json:
            schema:
              anyOf:
                - type: object
                  properties:
                    customer:
                      type: string
                      description: >-
                        Marca/comercio que aparece en el cupón emitido (la UI lo etiqueta como 'Marca'; default = nombre
                        de la organización del tenant). Nombre de campo `cliente` por compatibilidad con v1.
                      example: Cafetería del Centro
                    description:
                      type: string
                      maxLength: 128
                      description: >-
                        Descripción interna del admin (máximo 128 caracteres). No es el mensaje al cliente final ni la
                        justificación del precio.
                      example: Push de junio para mover inventario de café especial
                    name:
                      type: string
                      minLength: 1
                      description: Nombre legible de la campaña, mostrado en consolas de admin.
                      example: Promo Día del Padre 2026
                    maxCoupons:
                      type: integer
                      exclusiveMinimum: 0
                      description: Total de cupones que la campaña puede emitir. Cap implícito de anti-abuso junto con maxUses.
                      example: 500
                    maxUses:
                      type: integer
                      exclusiveMinimum: 0
                      description: Cuántas veces cada cupón individual puede canjearse. Default 1 (un solo uso por cupón).
                      example: 1
                    startDate:
                      type: string
                      description: >-
                        Inicio del rango de validez. Wall-clock interpretado en la `timezone` de la campaña. Acepta
                        YYYY-MM-DD (se normaliza a T00:00:00) o YYYY-MM-DDTHH:MM:SS sin offset.
                      example: '2026-06-01T00:00:00'
                    endDate:
                      type: string
                      description: >-
                        Fin del rango de validez. Mismo formato que startDate; se normaliza a T23:59:59 si solo viene la
                        fecha.
                      example: '2026-06-30T23:59:59'
                    timezone:
                      type: string
                      minLength: 1
                      description: >-
                        IANA timezone de la campaña, por ejemplo `America/Mexico_City`. Es la única fuente de verdad
                        para resolver startDate/endDate a UTC. No acepta offsets (`-06:00`) ni abreviaciones (`CST`).
                      example: America/Mexico_City
                    mintMechanism:
                      type: string
                      description: >-
                        Mecanismo de minteo de la campaña. `"autoservicio"` = minteo público self-service de Cupontli
                        (`POST /v1/mint`). Cualquier otro valor = `keyHash` de la api key de la integración que mintea
                        por la API autenticada. Ausencia = sin mecanismo declarado (minteo público cerrado).
                      example: self-service
                    products:
                      type: array
                      items:
                        type: object
                        properties:
                          pid:
                            type: string
                            minLength: 1
                            maxLength: 128
                            description: >-
                              Identificador del producto en el catálogo del tenant (free-form: SKU, id interno, etc.).
                              La UI manual genera `manual:<uuid>`.
                            example: CAFE-250G
                          name:
                            type: string
                            minLength: 1
                            description: Nombre legible del producto.
                            example: Café especial 250g
                          quantity:
                            type: integer
                            exclusiveMinimum: 0
                            description: Unidades de este producto incluidas en la promoción.
                            example: 3
                          price:
                            type: number
                            exclusiveMinimum: 0
                            description: Precio unitario del producto en su moneda.
                            example: 180
                          moneda:
                            type: string
                            minLength: 1
                            description: Código de moneda (ISO 4217 sugerido, por ejemplo `MXN`, `USD`, `COP`).
                            example: MXN
                          pais:
                            type: string
                            minLength: 1
                            description: Código de país donde aplica este producto (alfa-2 sugerido).
                            example: MX
                          category:
                            type: string
                            minLength: 1
                            description: Categoría libre del producto para clasificación interna.
                            example: bebidas-cafe
                        required:
                          - pid
                          - name
                          - quantity
                          - price
                          - moneda
                          - pais
                          - category
                        additionalProperties: false
                      description: >-
                        Listado de productos incluidos en la promoción. Cada uno con cantidad y precio. `listPrice` se
                        calcula como Σ productos[i].price × cantidad.
                    finalPrice:
                      type: number
                      minimum: 0
                      description: >-
                        Precio efectivo que paga el cliente final. Un solo número que colapsa las cuatro mecánicas
                        promocionales (porcentaje, monto fijo, precio fijo, NxM). Para regalo, usar `0`.
                      example: 360
                    finalPriceJustification:
                      type: string
                      minLength: 20
                      maxLength: 500
                      description: >-
                        Texto de auditoría que explica por qué este precio (audiencia: equipo financiero del tenant).
                        Entre 20 y 500 caracteres. Distinto a `couponMessage` que es customer-facing.
                      example: >-
                        Descuento promocional del 33% sobre el precio de lista para impulsar volumen durante junio.
                        Cierre presupuestal Q2.
                    couponMessage:
                      type: string
                      minLength: 1
                      maxLength: 80
                      description: >-
                        Texto promocional customer-facing que ve el cliente final (máximo 80 caracteres). Aquí vive el
                        `sabor` promocional (`3x2`, `20% off`, etc.).
                      example: 3x2 en cafés - hasta el 30 de junio
                    offerSubtitle:
                      type: string
                      maxLength: 60
                      description: >-
                        Subtítulo corto customer-facing bajo el mensaje del cupón (máximo 60 caracteres). Opcional;
                        cadena vacía lo elimina.
                      example: Solo en sucursal · presenta tu QR en caja
                    maxBudget:
                      type: number
                      minimum: 0
                      description: >-
                        Tope absoluto del costo agregado del incentivo. Si está definido, el validador rechaza canjes
                        que excederían `(totalRedemptions+1) × (listPrice − finalPrice)` con `PRESUPUESTO_AGOTADO`.
                      example: 90000
                    policyId:
                      type: string
                      pattern: ^[0-9A-HJKMNP-TV-Z]{26}$
                      description: >-
                        ULID de la política referida. El server snapshotea sus reglas a `politica[]` y guarda su versión
                        en `policyVersion`. Opcional: una campaña puede no tener política.
                      example: 01JWQ8ZK5M7N9P2R4T6V8X0YB6
                    lookId:
                      type:
                        - string
                        - 'null'
                      pattern: ^[0-9A-HJKMNP-TV-Z]{26}$
                      description: >-
                        ULID del look (diseño) referido. El server snapshotea variante/colores/URLs a `look` y su
                        versión a `lookVersion`. null en PATCH = quitar el diseño.
                      example: 01JWQ8ZK5M7N9P2R4T6V8X0YC7
                    legalText:
                      type: string
                      description: Texto legal opcional anexado al cupón (términos, condiciones, restricciones).
                      example: >-
                        Promoción válida hasta agotar inventario. No acumulable con otras ofertas. Aplica solo en
                        sucursales participantes.
                    termsAccepted:
                      type: boolean
                      description: >-
                        Confirmación explícita del admin de que aceptó los términos al crear la campaña. Debe ser
                        `true`. No se persiste; gate del request schema.
                      example: true
                  required:
                    - name
                    - maxCoupons
                    - startDate
                    - endDate
                    - timezone
                    - finalPrice
                    - finalPriceJustification
                    - couponMessage
                    - termsAccepted
                  additionalProperties: false
                - type: object
                  properties:
                    customer:
                      type: string
                      description: >-
                        Marca/comercio que aparece en el cupón emitido (la UI lo etiqueta como 'Marca'; default = nombre
                        de la organización del tenant). Nombre de campo `cliente` por compatibilidad con v1.
                      example: Cafetería del Centro
                    description:
                      type: string
                      maxLength: 128
                      description: >-
                        Descripción interna del admin (máximo 128 caracteres). No es el mensaje al cliente final ni la
                        justificación del precio.
                      example: Push de junio para mover inventario de café especial
                    name:
                      type: string
                      minLength: 1
                      description: Nombre legible de la campaña, mostrado en consolas de admin.
                      example: Promo Día del Padre 2026
                    maxCoupons:
                      type: integer
                      exclusiveMinimum: 0
                      description: Total de cupones que la campaña puede emitir. Cap implícito de anti-abuso junto con maxUses.
                      example: 500
                    maxUses:
                      type: integer
                      exclusiveMinimum: 0
                      description: Cuántas veces cada cupón individual puede canjearse. Default 1 (un solo uso por cupón).
                      example: 1
                    startDate:
                      type: string
                      description: >-
                        Inicio del rango de validez. Wall-clock interpretado en la `timezone` de la campaña. Acepta
                        YYYY-MM-DD (se normaliza a T00:00:00) o YYYY-MM-DDTHH:MM:SS sin offset.
                      example: '2026-06-01T00:00:00'
                    endDate:
                      type: string
                      description: >-
                        Fin del rango de validez. Mismo formato que startDate; se normaliza a T23:59:59 si solo viene la
                        fecha.
                      example: '2026-06-30T23:59:59'
                    timezone:
                      type: string
                      minLength: 1
                      description: >-
                        IANA timezone de la campaña, por ejemplo `America/Mexico_City`. Es la única fuente de verdad
                        para resolver startDate/endDate a UTC. No acepta offsets (`-06:00`) ni abreviaciones (`CST`).
                      example: America/Mexico_City
                    status:
                      type: string
                      enum:
                        - draft
                    mintMechanism:
                      type: string
                      description: >-
                        Mecanismo de minteo de la campaña. `"autoservicio"` = minteo público self-service de Cupontli
                        (`POST /v1/mint`). Cualquier otro valor = `keyHash` de la api key de la integración que mintea
                        por la API autenticada. Ausencia = sin mecanismo declarado (minteo público cerrado).
                      example: self-service
                    products:
                      type: array
                      items:
                        type: object
                        properties:
                          pid:
                            type: string
                            minLength: 1
                            maxLength: 128
                            description: >-
                              Identificador del producto en el catálogo del tenant (free-form: SKU, id interno, etc.).
                              La UI manual genera `manual:<uuid>`.
                            example: CAFE-250G
                          name:
                            type: string
                            minLength: 1
                            description: Nombre legible del producto.
                            example: Café especial 250g
                          quantity:
                            type: integer
                            exclusiveMinimum: 0
                            description: Unidades de este producto incluidas en la promoción.
                            example: 3
                          price:
                            type: number
                            exclusiveMinimum: 0
                            description: Precio unitario del producto en su moneda.
                            example: 180
                          moneda:
                            type: string
                            minLength: 1
                            description: Código de moneda (ISO 4217 sugerido, por ejemplo `MXN`, `USD`, `COP`).
                            example: MXN
                          pais:
                            type: string
                            minLength: 1
                            description: Código de país donde aplica este producto (alfa-2 sugerido).
                            example: MX
                          category:
                            type: string
                            minLength: 1
                            description: Categoría libre del producto para clasificación interna.
                            example: bebidas-cafe
                        required:
                          - pid
                          - name
                          - quantity
                          - price
                          - moneda
                          - pais
                          - category
                        additionalProperties: false
                      description: >-
                        Listado de productos incluidos en la promoción. Cada uno con cantidad y precio. `listPrice` se
                        calcula como Σ productos[i].price × cantidad.
                    finalPrice:
                      type: number
                      minimum: 0
                      description: >-
                        Precio efectivo que paga el cliente final. Un solo número que colapsa las cuatro mecánicas
                        promocionales (porcentaje, monto fijo, precio fijo, NxM). Para regalo, usar `0`.
                      example: 360
                    finalPriceJustification:
                      type: string
                      minLength: 20
                      maxLength: 500
                      description: >-
                        Texto de auditoría que explica por qué este precio (audiencia: equipo financiero del tenant).
                        Entre 20 y 500 caracteres. Distinto a `couponMessage` que es customer-facing.
                      example: >-
                        Descuento promocional del 33% sobre el precio de lista para impulsar volumen durante junio.
                        Cierre presupuestal Q2.
                    couponMessage:
                      type: string
                      minLength: 1
                      maxLength: 80
                      description: >-
                        Texto promocional customer-facing que ve el cliente final (máximo 80 caracteres). Aquí vive el
                        `sabor` promocional (`3x2`, `20% off`, etc.).
                      example: 3x2 en cafés - hasta el 30 de junio
                    offerSubtitle:
                      type: string
                      maxLength: 60
                      description: >-
                        Subtítulo corto customer-facing bajo el mensaje del cupón (máximo 60 caracteres). Opcional;
                        cadena vacía lo elimina.
                      example: Solo en sucursal · presenta tu QR en caja
                    maxBudget:
                      type: number
                      minimum: 0
                      description: >-
                        Tope absoluto del costo agregado del incentivo. Si está definido, el validador rechaza canjes
                        que excederían `(totalRedemptions+1) × (listPrice − finalPrice)` con `PRESUPUESTO_AGOTADO`.
                      example: 90000
                    policyId:
                      type: string
                      pattern: ^[0-9A-HJKMNP-TV-Z]{26}$
                      description: >-
                        ULID de la política referida. El server snapshotea sus reglas a `politica[]` y guarda su versión
                        en `policyVersion`. Opcional: una campaña puede no tener política.
                      example: 01JWQ8ZK5M7N9P2R4T6V8X0YB6
                    lookId:
                      type:
                        - string
                        - 'null'
                      pattern: ^[0-9A-HJKMNP-TV-Z]{26}$
                      description: >-
                        ULID del look (diseño) referido. El server snapshotea variante/colores/URLs a `look` y su
                        versión a `lookVersion`. null en PATCH = quitar el diseño.
                      example: 01JWQ8ZK5M7N9P2R4T6V8X0YC7
                    legalText:
                      type: string
                      description: Texto legal opcional anexado al cupón (términos, condiciones, restricciones).
                      example: >-
                        Promoción válida hasta agotar inventario. No acumulable con otras ofertas. Aplica solo en
                        sucursales participantes.
                    termsAccepted:
                      type: boolean
                      description: >-
                        Confirmación explícita del admin de que aceptó los términos al crear la campaña. Debe ser
                        `true`. No se persiste; gate del request schema.
                      example: true
                  required:
                    - name
                    - status
                  additionalProperties: false
              description: 'Campaña completa (status activa por defecto) o borrador (`status: "draft"`, solo `nombre` requerido).'
      responses:
        '200':
          description: Creado.
          content:
            application/json:
              schema:
                type: object
                properties:
                  campaignId:
                    type: string
                    pattern: ^[0-9A-HJKMNP-TV-Z]{26}$
                    description: Identificador ULID de la campaña. Server-set al crear (el cliente no lo envía).
                    example: 01ARZ3NDEKTSV4RRFFQ69G5FAV
                  tenantId:
                    type: string
                    format: uuid
                    description: Identificador UUID del tenant dueño de la campaña. Server-set desde el authorizer.
                    example: 7a3e8b12-9c4d-4f5e-a6b7-c8d9e0f1a2b3
                  customer:
                    type: string
                    description: >-
                      Marca/comercio que aparece en el cupón emitido (la UI lo etiqueta como 'Marca'; default = nombre
                      de la organización del tenant). Nombre de campo `cliente` por compatibilidad con v1.
                    example: Cafetería del Centro
                  description:
                    type: string
                    maxLength: 128
                    description: >-
                      Descripción interna del admin (máximo 128 caracteres). No es el mensaje al cliente final ni la
                      justificación del precio.
                    example: Push de junio para mover inventario de café especial
                  name:
                    type: string
                    minLength: 1
                    description: Nombre legible de la campaña, mostrado en consolas de admin.
                    example: Promo Día del Padre 2026
                  maxCoupons:
                    type: integer
                    exclusiveMinimum: 0
                    description: Total de cupones que la campaña puede emitir. Cap implícito de anti-abuso junto con maxUses.
                    example: 500
                  maxUses:
                    type: integer
                    exclusiveMinimum: 0
                    description: Cuántas veces cada cupón individual puede canjearse. Default 1 (un solo uso por cupón).
                    example: 1
                  createdAt:
                    type: string
                    description: Instante de creación en ISO 8601 UTC (sufijo `Z`). Server-set.
                    example: '2026-05-16T14:30:00.000Z'
                  startDate:
                    type: string
                    description: >-
                      Inicio del rango de validez. Wall-clock interpretado en la `timezone` de la campaña. Acepta
                      YYYY-MM-DD (se normaliza a T00:00:00) o YYYY-MM-DDTHH:MM:SS sin offset.
                    example: '2026-06-01T00:00:00'
                  endDate:
                    type: string
                    description: >-
                      Fin del rango de validez. Mismo formato que startDate; se normaliza a T23:59:59 si solo viene la
                      fecha.
                    example: '2026-06-30T23:59:59'
                  timezone:
                    type: string
                    minLength: 1
                    description: >-
                      IANA timezone de la campaña, por ejemplo `America/Mexico_City`. Es la única fuente de verdad para
                      resolver startDate/endDate a UTC. No acepta offsets (`-06:00`) ni abreviaciones (`CST`).
                    example: America/Mexico_City
                  lastSerial:
                    type: integer
                    minimum: 0
                    description: 'Folio del último cupón emitido. Server-managed: incrementa con cada createCoupon.'
                    example: 42
                  status:
                    type: string
                    enum:
                      - draft
                      - active
                      - expired
                      - paused
                      - archived
                    description: >-
                      Estado de la campaña en su ciclo de vida. `activa` es el default al crear; el admin puede mover a
                      `suspendida` o `archivada` vía PATCH.
                    example: active
                  mintMechanism:
                    type: string
                    description: >-
                      Mecanismo de minteo de la campaña. `"autoservicio"` = minteo público self-service de Cupontli
                      (`POST /v1/mint`). Cualquier otro valor = `keyHash` de la api key de la integración que mintea por
                      la API autenticada. Ausencia = sin mecanismo declarado (minteo público cerrado).
                    example: self-service
                  createdBy:
                    type: string
                    description: Identificador del usuario Cognito o api key que creó la campaña. Server-set.
                    example: admin@ejemplo.com
                  updatedBy:
                    type: string
                    description: >-
                      Identificador del principal que actualizó la campaña por última vez vía controller. Server-set en
                      cada PATCH.
                    example: admin@ejemplo.com
                  updatedAt:
                    type: string
                    description: >-
                      Instante de la última actualización vía controller, en ISO 8601 UTC. No se bumpea por escrituras
                      de sistema (totalRedemptions, lastSerial).
                    example: '2026-05-17T09:15:22.000Z'
                  products:
                    type: array
                    items:
                      type: object
                      properties:
                        pid:
                          type: string
                          minLength: 1
                          maxLength: 128
                          description: >-
                            Identificador del producto en el catálogo del tenant (free-form: SKU, id interno, etc.). La
                            UI manual genera `manual:<uuid>`.
                          example: CAFE-250G
                        name:
                          type: string
                          minLength: 1
                          description: Nombre legible del producto.
                          example: Café especial 250g
                        quantity:
                          type: integer
                          exclusiveMinimum: 0
                          description: Unidades de este producto incluidas en la promoción.
                          example: 3
                        price:
                          type: number
                          exclusiveMinimum: 0
                          description: Precio unitario del producto en su moneda.
                          example: 180
                        moneda:
                          type: string
                          minLength: 1
                          description: Código de moneda (ISO 4217 sugerido, por ejemplo `MXN`, `USD`, `COP`).
                          example: MXN
                        pais:
                          type: string
                          minLength: 1
                          description: Código de país donde aplica este producto (alfa-2 sugerido).
                          example: MX
                        category:
                          type: string
                          minLength: 1
                          description: Categoría libre del producto para clasificación interna.
                          example: bebidas-cafe
                      required:
                        - pid
                        - name
                        - quantity
                        - price
                        - moneda
                        - pais
                        - category
                      additionalProperties: false
                    description: >-
                      Listado de productos incluidos en la promoción. Cada uno con cantidad y precio. `listPrice` se
                      calcula como Σ productos[i].price × cantidad.
                  listPrice:
                    type: number
                    minimum: 0
                    description: >-
                      Precio de lista del paquete, calculado por el servidor como Σ productos[i].price × cantidad. No se
                      acepta del cliente.
                    example: 540
                  finalPrice:
                    type: number
                    minimum: 0
                    description: >-
                      Precio efectivo que paga el cliente final. Un solo número que colapsa las cuatro mecánicas
                      promocionales (porcentaje, monto fijo, precio fijo, NxM). Para regalo, usar `0`.
                    example: 360
                  finalPriceJustification:
                    type: string
                    minLength: 20
                    maxLength: 500
                    description: >-
                      Texto de auditoría que explica por qué este precio (audiencia: equipo financiero del tenant).
                      Entre 20 y 500 caracteres. Distinto a `couponMessage` que es customer-facing.
                    example: >-
                      Descuento promocional del 33% sobre el precio de lista para impulsar volumen durante junio. Cierre
                      presupuestal Q2.
                  couponMessage:
                    type: string
                    minLength: 1
                    maxLength: 80
                    description: >-
                      Texto promocional customer-facing que ve el cliente final (máximo 80 caracteres). Aquí vive el
                      `sabor` promocional (`3x2`, `20% off`, etc.).
                    example: 3x2 en cafés - hasta el 30 de junio
                  offerSubtitle:
                    type: string
                    maxLength: 60
                    description: >-
                      Subtítulo corto customer-facing bajo el mensaje del cupón (máximo 60 caracteres). Opcional; cadena
                      vacía lo elimina.
                    example: Solo en sucursal · presenta tu QR en caja
                  maxBudget:
                    type: number
                    minimum: 0
                    description: >-
                      Tope absoluto del costo agregado del incentivo. Si está definido, el validador rechaza canjes que
                      excederían `(totalRedemptions+1) × (listPrice − finalPrice)` con `PRESUPUESTO_AGOTADO`.
                    example: 90000
                  totalRedemptions:
                    type: integer
                    minimum: 0
                    default: 0
                    description: >-
                      Contador atómico de canjes exitosos. Server-managed: se incrementa transaccionalmente en
                      `validateCoupon` junto con el patch del cupón.
                    example: 17
                  policyRules:
                    type: array
                    items:
                      type: object
                      properties:
                        descripcion:
                          type: string
                          minLength: 1
                          description: >-
                            Texto descriptivo de la regla que se reporta al POS cuando esta regla falla en la
                            validación.
                          example: Solo aplica en sucursales de la zona metropolitana
                        expr:
                          type: string
                          minLength: 1
                          description: >-
                            Expresión filtrex de UNA comparación simple (var op valor, o var in (lista)) que debe
                            evaluar a true para que la regla pase. Los strings literales van con comillas dobles, no
                            simples. No se permiten and/or ni expresiones compuestas: usa varias reglas (AND implícito
                            entre ellas).
                          example: ciudad in ("CDMX", "Monterrey", "Guadalajara")
                      required:
                        - descripcion
                        - expr
                      additionalProperties: false
                    description: >-
                      Reglas filtrex evaluadas al canjear el cupón. AND implícito entre reglas; cada una se evalúa por
                      separado para reportar fallas individuales en `error.details.failures[]`. Server-derived: es el
                      snapshot congelado de las reglas de la política referida por `policyId` al momento de crear la
                      campaña — el cliente NO lo envía.
                  policyId:
                    type: string
                    pattern: ^[0-9A-HJKMNP-TV-Z]{26}$
                    description: >-
                      ULID de la política referida. El server snapshotea sus reglas a `politica[]` y guarda su versión
                      en `policyVersion`. Opcional: una campaña puede no tener política.
                    example: 01JWQ8ZK5M7N9P2R4T6V8X0YB6
                  policyVersion:
                    type: integer
                    exclusiveMinimum: 0
                    description: >-
                      Versión de la política al momento del snapshot (server-set). Permite detectar drift contra la
                      política actual.
                    example: 1
                  lookId:
                    type:
                      - string
                      - 'null'
                    pattern: ^[0-9A-HJKMNP-TV-Z]{26}$
                    description: >-
                      ULID del look (diseño) referido. El server snapshotea variante/colores/URLs a `look` y su versión
                      a `lookVersion`. null en PATCH = quitar el diseño.
                    example: 01JWQ8ZK5M7N9P2R4T6V8X0YC7
                  look:
                    $ref: '#/components/schemas/LookSnapshot'
                  lookVersion:
                    type: integer
                    exclusiveMinimum: 0
                    description: >-
                      Versión del look al momento del snapshot (server-set). Permite detectar drift contra el look
                      actual.
                    example: 1
                  legalText:
                    type: string
                    description: Texto legal opcional anexado al cupón (términos, condiciones, restricciones).
                    example: >-
                      Promoción válida hasta agotar inventario. No acumulable con otras ofertas. Aplica solo en
                      sucursales participantes.
                  tags:
                    type: array
                    items:
                      type: string
                      minLength: 1
                      maxLength: 32
                      pattern: ^[a-z0-9-]+$
                    maxItems: 10
                    description: >-
                      Tags asignados a la campaña. Se mutan via PUT/DELETE /v1/campaigns/{campaignId}/tags/{tagName}, no
                      via PATCH del recurso. Máximo 10 por campaña.
                    example:
                      - promo-verano
                      - q2-2026
                  exhaustedNotifiedAt:
                    type: string
                    description: >-
                      Server-set: cuándo se anunció la exhaustión por presupuesto de la campaña (evento
                      campaign.exhausted). Solo lectura.
                    example: '2026-07-02T18:32:00Z'
                  bulkMintedAt:
                    type: string
                    description: >-
                      Server-set: cuándo la descarga masiva reclamó la campaña completa. Presente ⇔ la campaña se agotó
                      por descarga y su set de cupones puede volver a descargarse. Solo lectura.
                    example: '2026-07-03T15:00:00Z'
                  bulkMintedBy:
                    type: string
                    description: >-
                      Server-set: quién reclamó el lote (email del admin o `apikey:<keyHash>` de la integración). Solo
                      lectura.
                    example: admin@empresa.mx
                  termsAccepted:
                    type: boolean
                    description: >-
                      Confirmación explícita del admin de que aceptó los términos al crear la campaña. Debe ser `true`.
                      No se persiste; gate del request schema.
                    example: true
                required:
                  - campaignId
                  - tenantId
                  - name
                  - maxCoupons
                  - createdAt
                  - startDate
                  - endDate
                  - timezone
                  - lastSerial
                  - status
                  - createdBy
                  - listPrice
                  - finalPrice
                  - finalPriceJustification
                  - couponMessage
                  - termsAccepted
                additionalProperties: false
        '400':
          description: >-
            `VALIDATION_ERROR` — El body no cumple el shape del request (Zod). El detalle viene en `error.issues`.
            `INVALID_JSON` — El body no es JSON válido. `BAD_REQUEST` — Request malformado: falta el body o un parámetro
            de path requerido. `POLICY_INACTIVE` — La política referenciada está archivada; solo políticas activas se
            pueden snapshotear a una campaña. `LOOK_INACTIVE` — El diseño (look) referenciado está archivado; solo
            diseños activos se pueden asignar.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: '`UNAUTHORIZED` — Falta el header Authorization o la credencial es inválida/revocada.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: '`FORBIDDEN` — La credencial es válida pero no porta el scope que la operación exige.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: >-
            `POLICY_NOT_FOUND` — El `policyId` referenciado no existe en tu tenant. `LOOK_NOT_FOUND` — El `lookId`
            referenciado no existe en tu tenant.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: '`SERVER_ERROR` — Error interno no controlado. Reintentable.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
      x-audience: machine
      x-developer: true
  /v1/campaigns/{id}:
    get:
      operationId: getCampaign
      x-required-scope: campaign:read
      x-error-codes:
        '400':
          - BAD_REQUEST
        '401':
          - UNAUTHORIZED
        '403':
          - FORBIDDEN
        '404':
          - NOT_FOUND
        '500':
          - SERVER_ERROR
      tags:
        - campanas
      security:
        - bearerAuth: []
      summary: 'Obtiene campana por id (scope: campaign:read)'
      parameters:
        - schema:
            type: string
            pattern: ^[0-9A-HJKMNP-TV-Z]{26}$
            description: ULID del recurso.
            example: 01ARZ3NDEKTSV4RRFFQ69G5FAV
          required: true
          name: id
          in: path
      responses:
        '200':
          description: Encontrado.
          content:
            application/json:
              schema:
                type: object
                properties:
                  campaignId:
                    type: string
                    pattern: ^[0-9A-HJKMNP-TV-Z]{26}$
                    description: Identificador ULID de la campaña. Server-set al crear (el cliente no lo envía).
                    example: 01ARZ3NDEKTSV4RRFFQ69G5FAV
                  tenantId:
                    type: string
                    format: uuid
                    description: Identificador UUID del tenant dueño de la campaña. Server-set desde el authorizer.
                    example: 7a3e8b12-9c4d-4f5e-a6b7-c8d9e0f1a2b3
                  customer:
                    type: string
                    description: >-
                      Marca/comercio que aparece en el cupón emitido (la UI lo etiqueta como 'Marca'; default = nombre
                      de la organización del tenant). Nombre de campo `cliente` por compatibilidad con v1.
                    example: Cafetería del Centro
                  description:
                    type: string
                    maxLength: 128
                    description: >-
                      Descripción interna del admin (máximo 128 caracteres). No es el mensaje al cliente final ni la
                      justificación del precio.
                    example: Push de junio para mover inventario de café especial
                  name:
                    type: string
                    minLength: 1
                    description: Nombre legible de la campaña, mostrado en consolas de admin.
                    example: Promo Día del Padre 2026
                  maxCoupons:
                    type: integer
                    exclusiveMinimum: 0
                    description: Total de cupones que la campaña puede emitir. Cap implícito de anti-abuso junto con maxUses.
                    example: 500
                  maxUses:
                    type: integer
                    exclusiveMinimum: 0
                    description: Cuántas veces cada cupón individual puede canjearse. Default 1 (un solo uso por cupón).
                    example: 1
                  createdAt:
                    type: string
                    description: Instante de creación en ISO 8601 UTC (sufijo `Z`). Server-set.
                    example: '2026-05-16T14:30:00.000Z'
                  startDate:
                    type: string
                    description: >-
                      Inicio del rango de validez. Wall-clock interpretado en la `timezone` de la campaña. Acepta
                      YYYY-MM-DD (se normaliza a T00:00:00) o YYYY-MM-DDTHH:MM:SS sin offset.
                    example: '2026-06-01T00:00:00'
                  endDate:
                    type: string
                    description: >-
                      Fin del rango de validez. Mismo formato que startDate; se normaliza a T23:59:59 si solo viene la
                      fecha.
                    example: '2026-06-30T23:59:59'
                  timezone:
                    type: string
                    minLength: 1
                    description: >-
                      IANA timezone de la campaña, por ejemplo `America/Mexico_City`. Es la única fuente de verdad para
                      resolver startDate/endDate a UTC. No acepta offsets (`-06:00`) ni abreviaciones (`CST`).
                    example: America/Mexico_City
                  lastSerial:
                    type: integer
                    minimum: 0
                    description: 'Folio del último cupón emitido. Server-managed: incrementa con cada createCoupon.'
                    example: 42
                  status:
                    type: string
                    enum:
                      - draft
                      - active
                      - expired
                      - paused
                      - archived
                    description: >-
                      Estado de la campaña en su ciclo de vida. `activa` es el default al crear; el admin puede mover a
                      `suspendida` o `archivada` vía PATCH.
                    example: active
                  mintMechanism:
                    type: string
                    description: >-
                      Mecanismo de minteo de la campaña. `"autoservicio"` = minteo público self-service de Cupontli
                      (`POST /v1/mint`). Cualquier otro valor = `keyHash` de la api key de la integración que mintea por
                      la API autenticada. Ausencia = sin mecanismo declarado (minteo público cerrado).
                    example: self-service
                  createdBy:
                    type: string
                    description: Identificador del usuario Cognito o api key que creó la campaña. Server-set.
                    example: admin@ejemplo.com
                  updatedBy:
                    type: string
                    description: >-
                      Identificador del principal que actualizó la campaña por última vez vía controller. Server-set en
                      cada PATCH.
                    example: admin@ejemplo.com
                  updatedAt:
                    type: string
                    description: >-
                      Instante de la última actualización vía controller, en ISO 8601 UTC. No se bumpea por escrituras
                      de sistema (totalRedemptions, lastSerial).
                    example: '2026-05-17T09:15:22.000Z'
                  products:
                    type: array
                    items:
                      type: object
                      properties:
                        pid:
                          type: string
                          minLength: 1
                          maxLength: 128
                          description: >-
                            Identificador del producto en el catálogo del tenant (free-form: SKU, id interno, etc.). La
                            UI manual genera `manual:<uuid>`.
                          example: CAFE-250G
                        name:
                          type: string
                          minLength: 1
                          description: Nombre legible del producto.
                          example: Café especial 250g
                        quantity:
                          type: integer
                          exclusiveMinimum: 0
                          description: Unidades de este producto incluidas en la promoción.
                          example: 3
                        price:
                          type: number
                          exclusiveMinimum: 0
                          description: Precio unitario del producto en su moneda.
                          example: 180
                        moneda:
                          type: string
                          minLength: 1
                          description: Código de moneda (ISO 4217 sugerido, por ejemplo `MXN`, `USD`, `COP`).
                          example: MXN
                        pais:
                          type: string
                          minLength: 1
                          description: Código de país donde aplica este producto (alfa-2 sugerido).
                          example: MX
                        category:
                          type: string
                          minLength: 1
                          description: Categoría libre del producto para clasificación interna.
                          example: bebidas-cafe
                      required:
                        - pid
                        - name
                        - quantity
                        - price
                        - moneda
                        - pais
                        - category
                      additionalProperties: false
                    description: >-
                      Listado de productos incluidos en la promoción. Cada uno con cantidad y precio. `listPrice` se
                      calcula como Σ productos[i].price × cantidad.
                  listPrice:
                    type: number
                    minimum: 0
                    description: >-
                      Precio de lista del paquete, calculado por el servidor como Σ productos[i].price × cantidad. No se
                      acepta del cliente.
                    example: 540
                  finalPrice:
                    type: number
                    minimum: 0
                    description: >-
                      Precio efectivo que paga el cliente final. Un solo número que colapsa las cuatro mecánicas
                      promocionales (porcentaje, monto fijo, precio fijo, NxM). Para regalo, usar `0`.
                    example: 360
                  finalPriceJustification:
                    type: string
                    minLength: 20
                    maxLength: 500
                    description: >-
                      Texto de auditoría que explica por qué este precio (audiencia: equipo financiero del tenant).
                      Entre 20 y 500 caracteres. Distinto a `couponMessage` que es customer-facing.
                    example: >-
                      Descuento promocional del 33% sobre el precio de lista para impulsar volumen durante junio. Cierre
                      presupuestal Q2.
                  couponMessage:
                    type: string
                    minLength: 1
                    maxLength: 80
                    description: >-
                      Texto promocional customer-facing que ve el cliente final (máximo 80 caracteres). Aquí vive el
                      `sabor` promocional (`3x2`, `20% off`, etc.).
                    example: 3x2 en cafés - hasta el 30 de junio
                  offerSubtitle:
                    type: string
                    maxLength: 60
                    description: >-
                      Subtítulo corto customer-facing bajo el mensaje del cupón (máximo 60 caracteres). Opcional; cadena
                      vacía lo elimina.
                    example: Solo en sucursal · presenta tu QR en caja
                  maxBudget:
                    type: number
                    minimum: 0
                    description: >-
                      Tope absoluto del costo agregado del incentivo. Si está definido, el validador rechaza canjes que
                      excederían `(totalRedemptions+1) × (listPrice − finalPrice)` con `PRESUPUESTO_AGOTADO`.
                    example: 90000
                  totalRedemptions:
                    type: integer
                    minimum: 0
                    default: 0
                    description: >-
                      Contador atómico de canjes exitosos. Server-managed: se incrementa transaccionalmente en
                      `validateCoupon` junto con el patch del cupón.
                    example: 17
                  policyRules:
                    type: array
                    items:
                      type: object
                      properties:
                        descripcion:
                          type: string
                          minLength: 1
                          description: >-
                            Texto descriptivo de la regla que se reporta al POS cuando esta regla falla en la
                            validación.
                          example: Solo aplica en sucursales de la zona metropolitana
                        expr:
                          type: string
                          minLength: 1
                          description: >-
                            Expresión filtrex de UNA comparación simple (var op valor, o var in (lista)) que debe
                            evaluar a true para que la regla pase. Los strings literales van con comillas dobles, no
                            simples. No se permiten and/or ni expresiones compuestas: usa varias reglas (AND implícito
                            entre ellas).
                          example: ciudad in ("CDMX", "Monterrey", "Guadalajara")
                      required:
                        - descripcion
                        - expr
                      additionalProperties: false
                    description: >-
                      Reglas filtrex evaluadas al canjear el cupón. AND implícito entre reglas; cada una se evalúa por
                      separado para reportar fallas individuales en `error.details.failures[]`. Server-derived: es el
                      snapshot congelado de las reglas de la política referida por `policyId` al momento de crear la
                      campaña — el cliente NO lo envía.
                  policyId:
                    type: string
                    pattern: ^[0-9A-HJKMNP-TV-Z]{26}$
                    description: >-
                      ULID de la política referida. El server snapshotea sus reglas a `politica[]` y guarda su versión
                      en `policyVersion`. Opcional: una campaña puede no tener política.
                    example: 01JWQ8ZK5M7N9P2R4T6V8X0YB6
                  policyVersion:
                    type: integer
                    exclusiveMinimum: 0
                    description: >-
                      Versión de la política al momento del snapshot (server-set). Permite detectar drift contra la
                      política actual.
                    example: 1
                  lookId:
                    type:
                      - string
                      - 'null'
                    pattern: ^[0-9A-HJKMNP-TV-Z]{26}$
                    description: >-
                      ULID del look (diseño) referido. El server snapshotea variante/colores/URLs a `look` y su versión
                      a `lookVersion`. null en PATCH = quitar el diseño.
                    example: 01JWQ8ZK5M7N9P2R4T6V8X0YC7
                  look:
                    $ref: '#/components/schemas/LookSnapshot'
                  lookVersion:
                    type: integer
                    exclusiveMinimum: 0
                    description: >-
                      Versión del look al momento del snapshot (server-set). Permite detectar drift contra el look
                      actual.
                    example: 1
                  legalText:
                    type: string
                    description: Texto legal opcional anexado al cupón (términos, condiciones, restricciones).
                    example: >-
                      Promoción válida hasta agotar inventario. No acumulable con otras ofertas. Aplica solo en
                      sucursales participantes.
                  tags:
                    type: array
                    items:
                      type: string
                      minLength: 1
                      maxLength: 32
                      pattern: ^[a-z0-9-]+$
                    maxItems: 10
                    description: >-
                      Tags asignados a la campaña. Se mutan via PUT/DELETE /v1/campaigns/{campaignId}/tags/{tagName}, no
                      via PATCH del recurso. Máximo 10 por campaña.
                    example:
                      - promo-verano
                      - q2-2026
                  exhaustedNotifiedAt:
                    type: string
                    description: >-
                      Server-set: cuándo se anunció la exhaustión por presupuesto de la campaña (evento
                      campaign.exhausted). Solo lectura.
                    example: '2026-07-02T18:32:00Z'
                  bulkMintedAt:
                    type: string
                    description: >-
                      Server-set: cuándo la descarga masiva reclamó la campaña completa. Presente ⇔ la campaña se agotó
                      por descarga y su set de cupones puede volver a descargarse. Solo lectura.
                    example: '2026-07-03T15:00:00Z'
                  bulkMintedBy:
                    type: string
                    description: >-
                      Server-set: quién reclamó el lote (email del admin o `apikey:<keyHash>` de la integración). Solo
                      lectura.
                    example: admin@empresa.mx
                  termsAccepted:
                    type: boolean
                    description: >-
                      Confirmación explícita del admin de que aceptó los términos al crear la campaña. Debe ser `true`.
                      No se persiste; gate del request schema.
                    example: true
                required:
                  - campaignId
                  - tenantId
                  - name
                  - maxCoupons
                  - createdAt
                  - startDate
                  - endDate
                  - timezone
                  - lastSerial
                  - status
                  - createdBy
                  - listPrice
                  - finalPrice
                  - finalPriceJustification
                  - couponMessage
                  - termsAccepted
                additionalProperties: false
        '400':
          description: '`BAD_REQUEST` — Request malformado: falta el body o un parámetro de path requerido.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: '`UNAUTHORIZED` — Falta el header Authorization o la credencial es inválida/revocada.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: '`FORBIDDEN` — La credencial es válida pero no porta el scope que la operación exige.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: >-
            `NOT_FOUND` — El recurso no existe para tu tenant/ambiente. Anti-enumeración: identificadores de otro tenant
            o malformados colapsan a este mismo código.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: '`SERVER_ERROR` — Error interno no controlado. Reintentable.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
      x-audience: machine
      x-developer: true
    patch:
      operationId: updateCampaign
      x-required-scope: campaign:write
      x-error-codes:
        '400':
          - VALIDATION_ERROR
          - INVALID_JSON
          - BAD_REQUEST
          - POLICY_INACTIVE
          - LOOK_INACTIVE
        '401':
          - UNAUTHORIZED
        '403':
          - FORBIDDEN
        '404':
          - NOT_FOUND
          - POLICY_NOT_FOUND
          - LOOK_NOT_FOUND
        '409':
          - CAMPAIGN_ARCHIVED
          - CANNOT_REVERT_TO_DRAFT
          - MINT_MECHANISM_LOCKED
          - CAMPAIGN_LOCKED
        '500':
          - SERVER_ERROR
      tags:
        - campanas
      security:
        - bearerAuth: []
      summary: 'Actualiza campana (scope: campaign:write)'
      parameters:
        - schema:
            type: string
            pattern: ^[0-9A-HJKMNP-TV-Z]{26}$
            description: ULID del recurso.
            example: 01ARZ3NDEKTSV4RRFFQ69G5FAV
          required: true
          name: id
          in: path
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                customer:
                  type: string
                  description: >-
                    Marca/comercio que aparece en el cupón emitido (la UI lo etiqueta como 'Marca'; default = nombre de
                    la organización del tenant). Nombre de campo `cliente` por compatibilidad con v1.
                  example: Cafetería del Centro
                description:
                  type: string
                  maxLength: 128
                  description: >-
                    Descripción interna del admin (máximo 128 caracteres). No es el mensaje al cliente final ni la
                    justificación del precio.
                  example: Push de junio para mover inventario de café especial
                name:
                  type: string
                  minLength: 1
                  description: Nombre legible de la campaña, mostrado en consolas de admin.
                  example: Promo Día del Padre 2026
                maxCoupons:
                  type: integer
                  exclusiveMinimum: 0
                  description: Total de cupones que la campaña puede emitir. Cap implícito de anti-abuso junto con maxUses.
                  example: 500
                maxUses:
                  type: integer
                  exclusiveMinimum: 0
                  description: Cuántas veces cada cupón individual puede canjearse. Default 1 (un solo uso por cupón).
                  example: 1
                startDate:
                  type: string
                  description: >-
                    Inicio del rango de validez. Wall-clock interpretado en la `timezone` de la campaña. Acepta
                    YYYY-MM-DD (se normaliza a T00:00:00) o YYYY-MM-DDTHH:MM:SS sin offset.
                  example: '2026-06-01T00:00:00'
                endDate:
                  type: string
                  description: >-
                    Fin del rango de validez. Mismo formato que startDate; se normaliza a T23:59:59 si solo viene la
                    fecha.
                  example: '2026-06-30T23:59:59'
                timezone:
                  type: string
                  minLength: 1
                  description: >-
                    IANA timezone de la campaña, por ejemplo `America/Mexico_City`. Es la única fuente de verdad para
                    resolver startDate/endDate a UTC. No acepta offsets (`-06:00`) ni abreviaciones (`CST`).
                  example: America/Mexico_City
                status:
                  type: string
                  enum:
                    - draft
                    - active
                    - expired
                    - paused
                    - archived
                  description: >-
                    Estado de la campaña en su ciclo de vida. `activa` es el default al crear; el admin puede mover a
                    `suspendida` o `archivada` vía PATCH.
                  example: active
                mintMechanism:
                  type: string
                  description: >-
                    Mecanismo de minteo de la campaña. `"autoservicio"` = minteo público self-service de Cupontli (`POST
                    /v1/mint`). Cualquier otro valor = `keyHash` de la api key de la integración que mintea por la API
                    autenticada. Ausencia = sin mecanismo declarado (minteo público cerrado).
                  example: self-service
                products:
                  type: array
                  items:
                    type: object
                    properties:
                      pid:
                        type: string
                        minLength: 1
                        maxLength: 128
                        description: >-
                          Identificador del producto en el catálogo del tenant (free-form: SKU, id interno, etc.). La UI
                          manual genera `manual:<uuid>`.
                        example: CAFE-250G
                      name:
                        type: string
                        minLength: 1
                        description: Nombre legible del producto.
                        example: Café especial 250g
                      quantity:
                        type: integer
                        exclusiveMinimum: 0
                        description: Unidades de este producto incluidas en la promoción.
                        example: 3
                      price:
                        type: number
                        exclusiveMinimum: 0
                        description: Precio unitario del producto en su moneda.
                        example: 180
                      moneda:
                        type: string
                        minLength: 1
                        description: Código de moneda (ISO 4217 sugerido, por ejemplo `MXN`, `USD`, `COP`).
                        example: MXN
                      pais:
                        type: string
                        minLength: 1
                        description: Código de país donde aplica este producto (alfa-2 sugerido).
                        example: MX
                      category:
                        type: string
                        minLength: 1
                        description: Categoría libre del producto para clasificación interna.
                        example: bebidas-cafe
                    required:
                      - pid
                      - name
                      - quantity
                      - price
                      - moneda
                      - pais
                      - category
                    additionalProperties: false
                  description: >-
                    Listado de productos incluidos en la promoción. Cada uno con cantidad y precio. `listPrice` se
                    calcula como Σ productos[i].price × cantidad.
                finalPrice:
                  type: number
                  minimum: 0
                  description: >-
                    Precio efectivo que paga el cliente final. Un solo número que colapsa las cuatro mecánicas
                    promocionales (porcentaje, monto fijo, precio fijo, NxM). Para regalo, usar `0`.
                  example: 360
                finalPriceJustification:
                  type: string
                  minLength: 20
                  maxLength: 500
                  description: >-
                    Texto de auditoría que explica por qué este precio (audiencia: equipo financiero del tenant). Entre
                    20 y 500 caracteres. Distinto a `couponMessage` que es customer-facing.
                  example: >-
                    Descuento promocional del 33% sobre el precio de lista para impulsar volumen durante junio. Cierre
                    presupuestal Q2.
                couponMessage:
                  type: string
                  minLength: 1
                  maxLength: 80
                  description: >-
                    Texto promocional customer-facing que ve el cliente final (máximo 80 caracteres). Aquí vive el
                    `sabor` promocional (`3x2`, `20% off`, etc.).
                  example: 3x2 en cafés - hasta el 30 de junio
                offerSubtitle:
                  type: string
                  maxLength: 60
                  description: >-
                    Subtítulo corto customer-facing bajo el mensaje del cupón (máximo 60 caracteres). Opcional; cadena
                    vacía lo elimina.
                  example: Solo en sucursal · presenta tu QR en caja
                maxBudget:
                  type: number
                  minimum: 0
                  description: >-
                    Tope absoluto del costo agregado del incentivo. Si está definido, el validador rechaza canjes que
                    excederían `(totalRedemptions+1) × (listPrice − finalPrice)` con `PRESUPUESTO_AGOTADO`.
                  example: 90000
                policyId:
                  type: string
                  pattern: ^[0-9A-HJKMNP-TV-Z]{26}$
                  description: >-
                    ULID de la política referida. El server snapshotea sus reglas a `politica[]` y guarda su versión en
                    `policyVersion`. Opcional: una campaña puede no tener política.
                  example: 01JWQ8ZK5M7N9P2R4T6V8X0YB6
                lookId:
                  type:
                    - string
                    - 'null'
                  pattern: ^[0-9A-HJKMNP-TV-Z]{26}$
                  description: >-
                    ULID del look (diseño) referido. El server snapshotea variante/colores/URLs a `look` y su versión a
                    `lookVersion`. null en PATCH = quitar el diseño.
                  example: 01JWQ8ZK5M7N9P2R4T6V8X0YC7
                legalText:
                  type: string
                  description: Texto legal opcional anexado al cupón (términos, condiciones, restricciones).
                  example: >-
                    Promoción válida hasta agotar inventario. No acumulable con otras ofertas. Aplica solo en sucursales
                    participantes.
                termsAccepted:
                  type: boolean
                  description: >-
                    Confirmación explícita del admin de que aceptó los términos al crear la campaña. Debe ser `true`. No
                    se persiste; gate del request schema.
                  example: true
              additionalProperties: false
      responses:
        '200':
          description: Actualizado.
          content:
            application/json:
              schema:
                type: object
                properties:
                  campaignId:
                    type: string
                    pattern: ^[0-9A-HJKMNP-TV-Z]{26}$
                    description: Identificador ULID de la campaña. Server-set al crear (el cliente no lo envía).
                    example: 01ARZ3NDEKTSV4RRFFQ69G5FAV
                  tenantId:
                    type: string
                    format: uuid
                    description: Identificador UUID del tenant dueño de la campaña. Server-set desde el authorizer.
                    example: 7a3e8b12-9c4d-4f5e-a6b7-c8d9e0f1a2b3
                  customer:
                    type: string
                    description: >-
                      Marca/comercio que aparece en el cupón emitido (la UI lo etiqueta como 'Marca'; default = nombre
                      de la organización del tenant). Nombre de campo `cliente` por compatibilidad con v1.
                    example: Cafetería del Centro
                  description:
                    type: string
                    maxLength: 128
                    description: >-
                      Descripción interna del admin (máximo 128 caracteres). No es el mensaje al cliente final ni la
                      justificación del precio.
                    example: Push de junio para mover inventario de café especial
                  name:
                    type: string
                    minLength: 1
                    description: Nombre legible de la campaña, mostrado en consolas de admin.
                    example: Promo Día del Padre 2026
                  maxCoupons:
                    type: integer
                    exclusiveMinimum: 0
                    description: Total de cupones que la campaña puede emitir. Cap implícito de anti-abuso junto con maxUses.
                    example: 500
                  maxUses:
                    type: integer
                    exclusiveMinimum: 0
                    description: Cuántas veces cada cupón individual puede canjearse. Default 1 (un solo uso por cupón).
                    example: 1
                  createdAt:
                    type: string
                    description: Instante de creación en ISO 8601 UTC (sufijo `Z`). Server-set.
                    example: '2026-05-16T14:30:00.000Z'
                  startDate:
                    type: string
                    description: >-
                      Inicio del rango de validez. Wall-clock interpretado en la `timezone` de la campaña. Acepta
                      YYYY-MM-DD (se normaliza a T00:00:00) o YYYY-MM-DDTHH:MM:SS sin offset.
                    example: '2026-06-01T00:00:00'
                  endDate:
                    type: string
                    description: >-
                      Fin del rango de validez. Mismo formato que startDate; se normaliza a T23:59:59 si solo viene la
                      fecha.
                    example: '2026-06-30T23:59:59'
                  timezone:
                    type: string
                    minLength: 1
                    description: >-
                      IANA timezone de la campaña, por ejemplo `America/Mexico_City`. Es la única fuente de verdad para
                      resolver startDate/endDate a UTC. No acepta offsets (`-06:00`) ni abreviaciones (`CST`).
                    example: America/Mexico_City
                  lastSerial:
                    type: integer
                    minimum: 0
                    description: 'Folio del último cupón emitido. Server-managed: incrementa con cada createCoupon.'
                    example: 42
                  status:
                    type: string
                    enum:
                      - draft
                      - active
                      - expired
                      - paused
                      - archived
                    description: >-
                      Estado de la campaña en su ciclo de vida. `activa` es el default al crear; el admin puede mover a
                      `suspendida` o `archivada` vía PATCH.
                    example: active
                  mintMechanism:
                    type: string
                    description: >-
                      Mecanismo de minteo de la campaña. `"autoservicio"` = minteo público self-service de Cupontli
                      (`POST /v1/mint`). Cualquier otro valor = `keyHash` de la api key de la integración que mintea por
                      la API autenticada. Ausencia = sin mecanismo declarado (minteo público cerrado).
                    example: self-service
                  createdBy:
                    type: string
                    description: Identificador del usuario Cognito o api key que creó la campaña. Server-set.
                    example: admin@ejemplo.com
                  updatedBy:
                    type: string
                    description: >-
                      Identificador del principal que actualizó la campaña por última vez vía controller. Server-set en
                      cada PATCH.
                    example: admin@ejemplo.com
                  updatedAt:
                    type: string
                    description: >-
                      Instante de la última actualización vía controller, en ISO 8601 UTC. No se bumpea por escrituras
                      de sistema (totalRedemptions, lastSerial).
                    example: '2026-05-17T09:15:22.000Z'
                  products:
                    type: array
                    items:
                      type: object
                      properties:
                        pid:
                          type: string
                          minLength: 1
                          maxLength: 128
                          description: >-
                            Identificador del producto en el catálogo del tenant (free-form: SKU, id interno, etc.). La
                            UI manual genera `manual:<uuid>`.
                          example: CAFE-250G
                        name:
                          type: string
                          minLength: 1
                          description: Nombre legible del producto.
                          example: Café especial 250g
                        quantity:
                          type: integer
                          exclusiveMinimum: 0
                          description: Unidades de este producto incluidas en la promoción.
                          example: 3
                        price:
                          type: number
                          exclusiveMinimum: 0
                          description: Precio unitario del producto en su moneda.
                          example: 180
                        moneda:
                          type: string
                          minLength: 1
                          description: Código de moneda (ISO 4217 sugerido, por ejemplo `MXN`, `USD`, `COP`).
                          example: MXN
                        pais:
                          type: string
                          minLength: 1
                          description: Código de país donde aplica este producto (alfa-2 sugerido).
                          example: MX
                        category:
                          type: string
                          minLength: 1
                          description: Categoría libre del producto para clasificación interna.
                          example: bebidas-cafe
                      required:
                        - pid
                        - name
                        - quantity
                        - price
                        - moneda
                        - pais
                        - category
                      additionalProperties: false
                    description: >-
                      Listado de productos incluidos en la promoción. Cada uno con cantidad y precio. `listPrice` se
                      calcula como Σ productos[i].price × cantidad.
                  listPrice:
                    type: number
                    minimum: 0
                    description: >-
                      Precio de lista del paquete, calculado por el servidor como Σ productos[i].price × cantidad. No se
                      acepta del cliente.
                    example: 540
                  finalPrice:
                    type: number
                    minimum: 0
                    description: >-
                      Precio efectivo que paga el cliente final. Un solo número que colapsa las cuatro mecánicas
                      promocionales (porcentaje, monto fijo, precio fijo, NxM). Para regalo, usar `0`.
                    example: 360
                  finalPriceJustification:
                    type: string
                    minLength: 20
                    maxLength: 500
                    description: >-
                      Texto de auditoría que explica por qué este precio (audiencia: equipo financiero del tenant).
                      Entre 20 y 500 caracteres. Distinto a `couponMessage` que es customer-facing.
                    example: >-
                      Descuento promocional del 33% sobre el precio de lista para impulsar volumen durante junio. Cierre
                      presupuestal Q2.
                  couponMessage:
                    type: string
                    minLength: 1
                    maxLength: 80
                    description: >-
                      Texto promocional customer-facing que ve el cliente final (máximo 80 caracteres). Aquí vive el
                      `sabor` promocional (`3x2`, `20% off`, etc.).
                    example: 3x2 en cafés - hasta el 30 de junio
                  offerSubtitle:
                    type: string
                    maxLength: 60
                    description: >-
                      Subtítulo corto customer-facing bajo el mensaje del cupón (máximo 60 caracteres). Opcional; cadena
                      vacía lo elimina.
                    example: Solo en sucursal · presenta tu QR en caja
                  maxBudget:
                    type: number
                    minimum: 0
                    description: >-
                      Tope absoluto del costo agregado del incentivo. Si está definido, el validador rechaza canjes que
                      excederían `(totalRedemptions+1) × (listPrice − finalPrice)` con `PRESUPUESTO_AGOTADO`.
                    example: 90000
                  totalRedemptions:
                    type: integer
                    minimum: 0
                    default: 0
                    description: >-
                      Contador atómico de canjes exitosos. Server-managed: se incrementa transaccionalmente en
                      `validateCoupon` junto con el patch del cupón.
                    example: 17
                  policyRules:
                    type: array
                    items:
                      type: object
                      properties:
                        descripcion:
                          type: string
                          minLength: 1
                          description: >-
                            Texto descriptivo de la regla que se reporta al POS cuando esta regla falla en la
                            validación.
                          example: Solo aplica en sucursales de la zona metropolitana
                        expr:
                          type: string
                          minLength: 1
                          description: >-
                            Expresión filtrex de UNA comparación simple (var op valor, o var in (lista)) que debe
                            evaluar a true para que la regla pase. Los strings literales van con comillas dobles, no
                            simples. No se permiten and/or ni expresiones compuestas: usa varias reglas (AND implícito
                            entre ellas).
                          example: ciudad in ("CDMX", "Monterrey", "Guadalajara")
                      required:
                        - descripcion
                        - expr
                      additionalProperties: false
                    description: >-
                      Reglas filtrex evaluadas al canjear el cupón. AND implícito entre reglas; cada una se evalúa por
                      separado para reportar fallas individuales en `error.details.failures[]`. Server-derived: es el
                      snapshot congelado de las reglas de la política referida por `policyId` al momento de crear la
                      campaña — el cliente NO lo envía.
                  policyId:
                    type: string
                    pattern: ^[0-9A-HJKMNP-TV-Z]{26}$
                    description: >-
                      ULID de la política referida. El server snapshotea sus reglas a `politica[]` y guarda su versión
                      en `policyVersion`. Opcional: una campaña puede no tener política.
                    example: 01JWQ8ZK5M7N9P2R4T6V8X0YB6
                  policyVersion:
                    type: integer
                    exclusiveMinimum: 0
                    description: >-
                      Versión de la política al momento del snapshot (server-set). Permite detectar drift contra la
                      política actual.
                    example: 1
                  lookId:
                    type:
                      - string
                      - 'null'
                    pattern: ^[0-9A-HJKMNP-TV-Z]{26}$
                    description: >-
                      ULID del look (diseño) referido. El server snapshotea variante/colores/URLs a `look` y su versión
                      a `lookVersion`. null en PATCH = quitar el diseño.
                    example: 01JWQ8ZK5M7N9P2R4T6V8X0YC7
                  look:
                    $ref: '#/components/schemas/LookSnapshot'
                  lookVersion:
                    type: integer
                    exclusiveMinimum: 0
                    description: >-
                      Versión del look al momento del snapshot (server-set). Permite detectar drift contra el look
                      actual.
                    example: 1
                  legalText:
                    type: string
                    description: Texto legal opcional anexado al cupón (términos, condiciones, restricciones).
                    example: >-
                      Promoción válida hasta agotar inventario. No acumulable con otras ofertas. Aplica solo en
                      sucursales participantes.
                  tags:
                    type: array
                    items:
                      type: string
                      minLength: 1
                      maxLength: 32
                      pattern: ^[a-z0-9-]+$
                    maxItems: 10
                    description: >-
                      Tags asignados a la campaña. Se mutan via PUT/DELETE /v1/campaigns/{campaignId}/tags/{tagName}, no
                      via PATCH del recurso. Máximo 10 por campaña.
                    example:
                      - promo-verano
                      - q2-2026
                  exhaustedNotifiedAt:
                    type: string
                    description: >-
                      Server-set: cuándo se anunció la exhaustión por presupuesto de la campaña (evento
                      campaign.exhausted). Solo lectura.
                    example: '2026-07-02T18:32:00Z'
                  bulkMintedAt:
                    type: string
                    description: >-
                      Server-set: cuándo la descarga masiva reclamó la campaña completa. Presente ⇔ la campaña se agotó
                      por descarga y su set de cupones puede volver a descargarse. Solo lectura.
                    example: '2026-07-03T15:00:00Z'
                  bulkMintedBy:
                    type: string
                    description: >-
                      Server-set: quién reclamó el lote (email del admin o `apikey:<keyHash>` de la integración). Solo
                      lectura.
                    example: admin@empresa.mx
                  termsAccepted:
                    type: boolean
                    description: >-
                      Confirmación explícita del admin de que aceptó los términos al crear la campaña. Debe ser `true`.
                      No se persiste; gate del request schema.
                    example: true
                required:
                  - campaignId
                  - tenantId
                  - name
                  - maxCoupons
                  - createdAt
                  - startDate
                  - endDate
                  - timezone
                  - lastSerial
                  - status
                  - createdBy
                  - listPrice
                  - finalPrice
                  - finalPriceJustification
                  - couponMessage
                  - termsAccepted
                additionalProperties: false
        '400':
          description: >-
            `VALIDATION_ERROR` — El body no cumple el shape del request (Zod). El detalle viene en `error.issues`.
            `INVALID_JSON` — El body no es JSON válido. `BAD_REQUEST` — Request malformado: falta el body o un parámetro
            de path requerido. `POLICY_INACTIVE` — La política referenciada está archivada; solo políticas activas se
            pueden snapshotear a una campaña. `LOOK_INACTIVE` — El diseño (look) referenciado está archivado; solo
            diseños activos se pueden asignar.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: '`UNAUTHORIZED` — Falta el header Authorization o la credencial es inválida/revocada.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: '`FORBIDDEN` — La credencial es válida pero no porta el scope que la operación exige.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: >-
            `NOT_FOUND` — El recurso no existe para tu tenant/ambiente. Anti-enumeración: identificadores de otro tenant
            o malformados colapsan a este mismo código. `POLICY_NOT_FOUND` — El `policyId` referenciado no existe en tu
            tenant. `LOOK_NOT_FOUND` — El `lookId` referenciado no existe en tu tenant.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          description: >-
            `CAMPAIGN_ARCHIVED` — La campaña está archivada (estado terminal): no se puede editar, canjear ni asignarle
            tags. `CANNOT_REVERT_TO_DRAFT` — Una campaña publicada no puede regresar a borrador. Para detener el minteo,
            suspéndela (`status: suspendida`). `MINT_MECHANISM_LOCKED` — `mintMechanism` solo es editable mientras la
            campaña es borrador; queda congelado al activarla. `CAMPAIGN_LOCKED` — La campaña ya registró canjes: los
            campos que definen el beneficio quedan bloqueados. `error.fields` lista cuáles.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: '`SERVER_ERROR` — Error interno no controlado. Reintentable.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
      x-audience: machine
      x-developer: true
    delete:
      operationId: deleteCampaign
      x-required-scope: campaign:write
      x-error-codes:
        '400':
          - BAD_REQUEST
        '401':
          - UNAUTHORIZED
        '403':
          - FORBIDDEN
        '404':
          - NOT_FOUND
        '500':
          - SERVER_ERROR
      tags:
        - campanas
      security:
        - bearerAuth: []
      summary: 'Archiva campana (soft delete) (scope: campaign:write)'
      parameters:
        - schema:
            type: string
            pattern: ^[0-9A-HJKMNP-TV-Z]{26}$
            description: ULID del recurso.
            example: 01ARZ3NDEKTSV4RRFFQ69G5FAV
          required: true
          name: id
          in: path
      responses:
        '200':
          description: 'Archivada. Devuelve el recurso completo con `status: "archivada"`. Idempotente.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  campaignId:
                    type: string
                    pattern: ^[0-9A-HJKMNP-TV-Z]{26}$
                    description: Identificador ULID de la campaña. Server-set al crear (el cliente no lo envía).
                    example: 01ARZ3NDEKTSV4RRFFQ69G5FAV
                  tenantId:
                    type: string
                    format: uuid
                    description: Identificador UUID del tenant dueño de la campaña. Server-set desde el authorizer.
                    example: 7a3e8b12-9c4d-4f5e-a6b7-c8d9e0f1a2b3
                  customer:
                    type: string
                    description: >-
                      Marca/comercio que aparece en el cupón emitido (la UI lo etiqueta como 'Marca'; default = nombre
                      de la organización del tenant). Nombre de campo `cliente` por compatibilidad con v1.
                    example: Cafetería del Centro
                  description:
                    type: string
                    maxLength: 128
                    description: >-
                      Descripción interna del admin (máximo 128 caracteres). No es el mensaje al cliente final ni la
                      justificación del precio.
                    example: Push de junio para mover inventario de café especial
                  name:
                    type: string
                    minLength: 1
                    description: Nombre legible de la campaña, mostrado en consolas de admin.
                    example: Promo Día del Padre 2026
                  maxCoupons:
                    type: integer
                    exclusiveMinimum: 0
                    description: Total de cupones que la campaña puede emitir. Cap implícito de anti-abuso junto con maxUses.
                    example: 500
                  maxUses:
                    type: integer
                    exclusiveMinimum: 0
                    description: Cuántas veces cada cupón individual puede canjearse. Default 1 (un solo uso por cupón).
                    example: 1
                  createdAt:
                    type: string
                    description: Instante de creación en ISO 8601 UTC (sufijo `Z`). Server-set.
                    example: '2026-05-16T14:30:00.000Z'
                  startDate:
                    type: string
                    description: >-
                      Inicio del rango de validez. Wall-clock interpretado en la `timezone` de la campaña. Acepta
                      YYYY-MM-DD (se normaliza a T00:00:00) o YYYY-MM-DDTHH:MM:SS sin offset.
                    example: '2026-06-01T00:00:00'
                  endDate:
                    type: string
                    description: >-
                      Fin del rango de validez. Mismo formato que startDate; se normaliza a T23:59:59 si solo viene la
                      fecha.
                    example: '2026-06-30T23:59:59'
                  timezone:
                    type: string
                    minLength: 1
                    description: >-
                      IANA timezone de la campaña, por ejemplo `America/Mexico_City`. Es la única fuente de verdad para
                      resolver startDate/endDate a UTC. No acepta offsets (`-06:00`) ni abreviaciones (`CST`).
                    example: America/Mexico_City
                  lastSerial:
                    type: integer
                    minimum: 0
                    description: 'Folio del último cupón emitido. Server-managed: incrementa con cada createCoupon.'
                    example: 42
                  status:
                    type: string
                    enum:
                      - draft
                      - active
                      - expired
                      - paused
                      - archived
                    description: >-
                      Estado de la campaña en su ciclo de vida. `activa` es el default al crear; el admin puede mover a
                      `suspendida` o `archivada` vía PATCH.
                    example: active
                  mintMechanism:
                    type: string
                    description: >-
                      Mecanismo de minteo de la campaña. `"autoservicio"` = minteo público self-service de Cupontli
                      (`POST /v1/mint`). Cualquier otro valor = `keyHash` de la api key de la integración que mintea por
                      la API autenticada. Ausencia = sin mecanismo declarado (minteo público cerrado).
                    example: self-service
                  createdBy:
                    type: string
                    description: Identificador del usuario Cognito o api key que creó la campaña. Server-set.
                    example: admin@ejemplo.com
                  updatedBy:
                    type: string
                    description: >-
                      Identificador del principal que actualizó la campaña por última vez vía controller. Server-set en
                      cada PATCH.
                    example: admin@ejemplo.com
                  updatedAt:
                    type: string
                    description: >-
                      Instante de la última actualización vía controller, en ISO 8601 UTC. No se bumpea por escrituras
                      de sistema (totalRedemptions, lastSerial).
                    example: '2026-05-17T09:15:22.000Z'
                  products:
                    type: array
                    items:
                      type: object
                      properties:
                        pid:
                          type: string
                          minLength: 1
                          maxLength: 128
                          description: >-
                            Identificador del producto en el catálogo del tenant (free-form: SKU, id interno, etc.). La
                            UI manual genera `manual:<uuid>`.
                          example: CAFE-250G
                        name:
                          type: string
                          minLength: 1
                          description: Nombre legible del producto.
                          example: Café especial 250g
                        quantity:
                          type: integer
                          exclusiveMinimum: 0
                          description: Unidades de este producto incluidas en la promoción.
                          example: 3
                        price:
                          type: number
                          exclusiveMinimum: 0
                          description: Precio unitario del producto en su moneda.
                          example: 180
                        moneda:
                          type: string
                          minLength: 1
                          description: Código de moneda (ISO 4217 sugerido, por ejemplo `MXN`, `USD`, `COP`).
                          example: MXN
                        pais:
                          type: string
                          minLength: 1
                          description: Código de país donde aplica este producto (alfa-2 sugerido).
                          example: MX
                        category:
                          type: string
                          minLength: 1
                          description: Categoría libre del producto para clasificación interna.
                          example: bebidas-cafe
                      required:
                        - pid
                        - name
                        - quantity
                        - price
                        - moneda
                        - pais
                        - category
                      additionalProperties: false
                    description: >-
                      Listado de productos incluidos en la promoción. Cada uno con cantidad y precio. `listPrice` se
                      calcula como Σ productos[i].price × cantidad.
                  listPrice:
                    type: number
                    minimum: 0
                    description: >-
                      Precio de lista del paquete, calculado por el servidor como Σ productos[i].price × cantidad. No se
                      acepta del cliente.
                    example: 540
                  finalPrice:
                    type: number
                    minimum: 0
                    description: >-
                      Precio efectivo que paga el cliente final. Un solo número que colapsa las cuatro mecánicas
                      promocionales (porcentaje, monto fijo, precio fijo, NxM). Para regalo, usar `0`.
                    example: 360
                  finalPriceJustification:
                    type: string
                    minLength: 20
                    maxLength: 500
                    description: >-
                      Texto de auditoría que explica por qué este precio (audiencia: equipo financiero del tenant).
                      Entre 20 y 500 caracteres. Distinto a `couponMessage` que es customer-facing.
                    example: >-
                      Descuento promocional del 33% sobre el precio de lista para impulsar volumen durante junio. Cierre
                      presupuestal Q2.
                  couponMessage:
                    type: string
                    minLength: 1
                    maxLength: 80
                    description: >-
                      Texto promocional customer-facing que ve el cliente final (máximo 80 caracteres). Aquí vive el
                      `sabor` promocional (`3x2`, `20% off`, etc.).
                    example: 3x2 en cafés - hasta el 30 de junio
                  offerSubtitle:
                    type: string
                    maxLength: 60
                    description: >-
                      Subtítulo corto customer-facing bajo el mensaje del cupón (máximo 60 caracteres). Opcional; cadena
                      vacía lo elimina.
                    example: Solo en sucursal · presenta tu QR en caja
                  maxBudget:
                    type: number
                    minimum: 0
                    description: >-
                      Tope absoluto del costo agregado del incentivo. Si está definido, el validador rechaza canjes que
                      excederían `(totalRedemptions+1) × (listPrice − finalPrice)` con `PRESUPUESTO_AGOTADO`.
                    example: 90000
                  totalRedemptions:
                    type: integer
                    minimum: 0
                    default: 0
                    description: >-
                      Contador atómico de canjes exitosos. Server-managed: se incrementa transaccionalmente en
                      `validateCoupon` junto con el patch del cupón.
                    example: 17
                  policyRules:
                    type: array
                    items:
                      type: object
                      properties:
                        descripcion:
                          type: string
                          minLength: 1
                          description: >-
                            Texto descriptivo de la regla que se reporta al POS cuando esta regla falla en la
                            validación.
                          example: Solo aplica en sucursales de la zona metropolitana
                        expr:
                          type: string
                          minLength: 1
                          description: >-
                            Expresión filtrex de UNA comparación simple (var op valor, o var in (lista)) que debe
                            evaluar a true para que la regla pase. Los strings literales van con comillas dobles, no
                            simples. No se permiten and/or ni expresiones compuestas: usa varias reglas (AND implícito
                            entre ellas).
                          example: ciudad in ("CDMX", "Monterrey", "Guadalajara")
                      required:
                        - descripcion
                        - expr
                      additionalProperties: false
                    description: >-
                      Reglas filtrex evaluadas al canjear el cupón. AND implícito entre reglas; cada una se evalúa por
                      separado para reportar fallas individuales en `error.details.failures[]`. Server-derived: es el
                      snapshot congelado de las reglas de la política referida por `policyId` al momento de crear la
                      campaña — el cliente NO lo envía.
                  policyId:
                    type: string
                    pattern: ^[0-9A-HJKMNP-TV-Z]{26}$
                    description: >-
                      ULID de la política referida. El server snapshotea sus reglas a `politica[]` y guarda su versión
                      en `policyVersion`. Opcional: una campaña puede no tener política.
                    example: 01JWQ8ZK5M7N9P2R4T6V8X0YB6
                  policyVersion:
                    type: integer
                    exclusiveMinimum: 0
                    description: >-
                      Versión de la política al momento del snapshot (server-set). Permite detectar drift contra la
                      política actual.
                    example: 1
                  lookId:
                    type:
                      - string
                      - 'null'
                    pattern: ^[0-9A-HJKMNP-TV-Z]{26}$
                    description: >-
                      ULID del look (diseño) referido. El server snapshotea variante/colores/URLs a `look` y su versión
                      a `lookVersion`. null en PATCH = quitar el diseño.
                    example: 01JWQ8ZK5M7N9P2R4T6V8X0YC7
                  look:
                    $ref: '#/components/schemas/LookSnapshot'
                  lookVersion:
                    type: integer
                    exclusiveMinimum: 0
                    description: >-
                      Versión del look al momento del snapshot (server-set). Permite detectar drift contra el look
                      actual.
                    example: 1
                  legalText:
                    type: string
                    description: Texto legal opcional anexado al cupón (términos, condiciones, restricciones).
                    example: >-
                      Promoción válida hasta agotar inventario. No acumulable con otras ofertas. Aplica solo en
                      sucursales participantes.
                  tags:
                    type: array
                    items:
                      type: string
                      minLength: 1
                      maxLength: 32
                      pattern: ^[a-z0-9-]+$
                    maxItems: 10
                    description: >-
                      Tags asignados a la campaña. Se mutan via PUT/DELETE /v1/campaigns/{campaignId}/tags/{tagName}, no
                      via PATCH del recurso. Máximo 10 por campaña.
                    example:
                      - promo-verano
                      - q2-2026
                  exhaustedNotifiedAt:
                    type: string
                    description: >-
                      Server-set: cuándo se anunció la exhaustión por presupuesto de la campaña (evento
                      campaign.exhausted). Solo lectura.
                    example: '2026-07-02T18:32:00Z'
                  bulkMintedAt:
                    type: string
                    description: >-
                      Server-set: cuándo la descarga masiva reclamó la campaña completa. Presente ⇔ la campaña se agotó
                      por descarga y su set de cupones puede volver a descargarse. Solo lectura.
                    example: '2026-07-03T15:00:00Z'
                  bulkMintedBy:
                    type: string
                    description: >-
                      Server-set: quién reclamó el lote (email del admin o `apikey:<keyHash>` de la integración). Solo
                      lectura.
                    example: admin@empresa.mx
                  termsAccepted:
                    type: boolean
                    description: >-
                      Confirmación explícita del admin de que aceptó los términos al crear la campaña. Debe ser `true`.
                      No se persiste; gate del request schema.
                    example: true
                required:
                  - campaignId
                  - tenantId
                  - name
                  - maxCoupons
                  - createdAt
                  - startDate
                  - endDate
                  - timezone
                  - lastSerial
                  - status
                  - createdBy
                  - listPrice
                  - finalPrice
                  - finalPriceJustification
                  - couponMessage
                  - termsAccepted
                additionalProperties: false
        '400':
          description: '`BAD_REQUEST` — Request malformado: falta el body o un parámetro de path requerido.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: '`UNAUTHORIZED` — Falta el header Authorization o la credencial es inválida/revocada.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: '`FORBIDDEN` — La credencial es válida pero no porta el scope que la operación exige.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: >-
            `NOT_FOUND` — El recurso no existe para tu tenant/ambiente. Anti-enumeración: identificadores de otro tenant
            o malformados colapsan a este mismo código.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: '`SERVER_ERROR` — Error interno no controlado. Reintentable.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
      x-audience: machine
      x-developer: true
  /v1/policies:
    get:
      operationId: listPolicys
      x-required-scope: policy:read
      x-error-codes:
        '400':
          - BAD_REQUEST
          - INVALID_QUERY
        '401':
          - UNAUTHORIZED
        '403':
          - FORBIDDEN
        '500':
          - SERVER_ERROR
      tags:
        - policies
      security:
        - bearerAuth: []
      summary: 'Lista policies (scope: policy:read)'
      parameters:
        - schema:
            type: string
            enum:
              - 'true'
              - 'false'
            description: 'Si ''true'', incluye políticas soft-deleted (activo:false). Default: solo activas.'
            example: 'false'
          required: false
          name: incluyeInactivas
          in: query
        - schema:
            type: string
            description: >-
              Tamaño de página solicitado, entero entre 1 y 100. Default 50 si se omite. Valores fuera de rango
              devuelven `400 INVALID_QUERY`.
            example: '50'
          required: false
          name: pageSize
          in: query
        - schema:
            type: string
            description: >-
              Cursor opaco devuelto en `nextToken` del response anterior. Reenviar tal cual para pedir la siguiente
              página.
          required: false
          name: nextToken
          in: query
      responses:
        '200':
          description: Listado paginado.
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      $ref: '#/components/schemas/Policy'
                    description: >-
                      Items de esta página. Puede contener menos que `pageSize` si es la última página o si el endpoint
                      aplica filtros post-query.
                  nextToken:
                    type: string
                    description: >-
                      Cursor opaco para pedir la siguiente página. Reenviar tal cual en `?nextToken=<valor>`. Ausente =
                      última página.
                    example: eyJwayI6IlQjMSIsInNrIjoiQyMxIn0=
                required:
                  - items
        '400':
          description: >-
            `BAD_REQUEST` — Request malformado: falta el body o un parámetro de path requerido. `INVALID_QUERY` — Query
            params inválidos (filtro desconocido, paginación malformada o rango de fechas ilegible).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: '`UNAUTHORIZED` — Falta el header Authorization o la credencial es inválida/revocada.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: '`FORBIDDEN` — La credencial es válida pero no porta el scope que la operación exige.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: '`SERVER_ERROR` — Error interno no controlado. Reintentable.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
      x-audience: machine
      x-developer: true
    post:
      operationId: createPolicy
      x-required-scope: policy:write
      x-error-codes:
        '400':
          - VALIDATION_ERROR
          - INVALID_JSON
          - BAD_REQUEST
        '401':
          - UNAUTHORIZED
        '403':
          - FORBIDDEN
        '500':
          - SERVER_ERROR
      tags:
        - policies
      security:
        - bearerAuth: []
      summary: 'Crea policie (scope: policy:write)'
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                  minLength: 1
                  description: Nombre legible de la política, mostrado en el picker.
                  example: Solo CDMX, monto mínimo
                description:
                  type: string
                  maxLength: 256
                  description: Nota humana opcional sobre la política.
                  example: Aplica a promociones de la zona metropolitana.
                rules:
                  type: array
                  items:
                    type: object
                    properties:
                      descripcion:
                        type: string
                        minLength: 1
                        description: Texto descriptivo de la regla que se reporta al POS cuando esta regla falla en la validación.
                        example: Solo aplica en sucursales de la zona metropolitana
                      expr:
                        type: string
                        minLength: 1
                        description: >-
                          Expresión filtrex de UNA comparación simple (var op valor, o var in (lista)) que debe evaluar
                          a true para que la regla pase. Los strings literales van con comillas dobles, no simples. No
                          se permiten and/or ni expresiones compuestas: usa varias reglas (AND implícito entre ellas).
                        example: ciudad in ("CDMX", "Monterrey", "Guadalajara")
                    required:
                      - descripcion
                      - expr
                    additionalProperties: false
                  minItems: 1
                  description: >-
                    Reglas filtrex evaluadas en AND. Cada regla lleva su descripción (que el validador reporta al POS
                    cuando esa regla falla).
              required:
                - name
                - rules
              additionalProperties: false
      responses:
        '200':
          description: Creado.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Policy'
        '400':
          description: >-
            `VALIDATION_ERROR` — El body no cumple el shape del request (Zod). El detalle viene en `error.issues`.
            `INVALID_JSON` — El body no es JSON válido. `BAD_REQUEST` — Request malformado: falta el body o un parámetro
            de path requerido.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: '`UNAUTHORIZED` — Falta el header Authorization o la credencial es inválida/revocada.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: '`FORBIDDEN` — La credencial es válida pero no porta el scope que la operación exige.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: '`SERVER_ERROR` — Error interno no controlado. Reintentable.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
      x-audience: machine
      x-developer: true
  /v1/policies/{id}:
    get:
      operationId: getPolicy
      x-required-scope: policy:read
      x-error-codes:
        '400':
          - BAD_REQUEST
        '401':
          - UNAUTHORIZED
        '403':
          - FORBIDDEN
        '404':
          - NOT_FOUND
        '500':
          - SERVER_ERROR
      tags:
        - policies
      security:
        - bearerAuth: []
      summary: 'Obtiene policie por id (scope: policy:read)'
      parameters:
        - schema:
            type: string
            pattern: ^[0-9A-HJKMNP-TV-Z]{26}$
            description: ULID del recurso.
            example: 01ARZ3NDEKTSV4RRFFQ69G5FAV
          required: true
          name: id
          in: path
      responses:
        '200':
          description: Encontrado.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Policy'
        '400':
          description: '`BAD_REQUEST` — Request malformado: falta el body o un parámetro de path requerido.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: '`UNAUTHORIZED` — Falta el header Authorization o la credencial es inválida/revocada.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: '`FORBIDDEN` — La credencial es válida pero no porta el scope que la operación exige.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: >-
            `NOT_FOUND` — El recurso no existe para tu tenant/ambiente. Anti-enumeración: identificadores de otro tenant
            o malformados colapsan a este mismo código.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: '`SERVER_ERROR` — Error interno no controlado. Reintentable.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
      x-audience: machine
      x-developer: true
    patch:
      operationId: updatePolicy
      x-required-scope: policy:write
      x-error-codes:
        '400':
          - VALIDATION_ERROR
          - INVALID_JSON
          - BAD_REQUEST
        '401':
          - UNAUTHORIZED
        '403':
          - FORBIDDEN
        '404':
          - NOT_FOUND
        '500':
          - SERVER_ERROR
      tags:
        - policies
      security:
        - bearerAuth: []
      summary: 'Actualiza policie (scope: policy:write)'
      parameters:
        - schema:
            type: string
            pattern: ^[0-9A-HJKMNP-TV-Z]{26}$
            description: ULID del recurso.
            example: 01ARZ3NDEKTSV4RRFFQ69G5FAV
          required: true
          name: id
          in: path
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                  minLength: 1
                  description: Nombre legible de la política, mostrado en el picker.
                  example: Solo CDMX, monto mínimo
                description:
                  type: string
                  maxLength: 256
                  description: Nota humana opcional sobre la política.
                  example: Aplica a promociones de la zona metropolitana.
                rules:
                  type: array
                  items:
                    type: object
                    properties:
                      descripcion:
                        type: string
                        minLength: 1
                        description: Texto descriptivo de la regla que se reporta al POS cuando esta regla falla en la validación.
                        example: Solo aplica en sucursales de la zona metropolitana
                      expr:
                        type: string
                        minLength: 1
                        description: >-
                          Expresión filtrex de UNA comparación simple (var op valor, o var in (lista)) que debe evaluar
                          a true para que la regla pase. Los strings literales van con comillas dobles, no simples. No
                          se permiten and/or ni expresiones compuestas: usa varias reglas (AND implícito entre ellas).
                        example: ciudad in ("CDMX", "Monterrey", "Guadalajara")
                    required:
                      - descripcion
                      - expr
                    additionalProperties: false
                  minItems: 1
                  description: >-
                    Reglas filtrex evaluadas en AND. Cada regla lleva su descripción (que el validador reporta al POS
                    cuando esa regla falla).
              additionalProperties: false
      responses:
        '200':
          description: Actualizado.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Policy'
        '400':
          description: >-
            `VALIDATION_ERROR` — El body no cumple el shape del request (Zod). El detalle viene en `error.issues`.
            `INVALID_JSON` — El body no es JSON válido. `BAD_REQUEST` — Request malformado: falta el body o un parámetro
            de path requerido.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: '`UNAUTHORIZED` — Falta el header Authorization o la credencial es inválida/revocada.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: '`FORBIDDEN` — La credencial es válida pero no porta el scope que la operación exige.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: >-
            `NOT_FOUND` — El recurso no existe para tu tenant/ambiente. Anti-enumeración: identificadores de otro tenant
            o malformados colapsan a este mismo código.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: '`SERVER_ERROR` — Error interno no controlado. Reintentable.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
      x-audience: machine
      x-developer: true
    delete:
      operationId: deletePolicy
      x-required-scope: policy:write
      x-error-codes:
        '400':
          - BAD_REQUEST
        '401':
          - UNAUTHORIZED
        '403':
          - FORBIDDEN
        '404':
          - NOT_FOUND
        '500':
          - SERVER_ERROR
      tags:
        - policies
      security:
        - bearerAuth: []
      summary: 'Archiva policie (soft delete) (scope: policy:write)'
      parameters:
        - schema:
            type: string
            pattern: ^[0-9A-HJKMNP-TV-Z]{26}$
            description: ULID del recurso.
            example: 01ARZ3NDEKTSV4RRFFQ69G5FAV
          required: true
          name: id
          in: path
      responses:
        '200':
          description: 'Archivada. Devuelve el recurso completo con `status: "archivada"`. Idempotente.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Policy'
        '400':
          description: '`BAD_REQUEST` — Request malformado: falta el body o un parámetro de path requerido.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: '`UNAUTHORIZED` — Falta el header Authorization o la credencial es inválida/revocada.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: '`FORBIDDEN` — La credencial es válida pero no porta el scope que la operación exige.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: >-
            `NOT_FOUND` — El recurso no existe para tu tenant/ambiente. Anti-enumeración: identificadores de otro tenant
            o malformados colapsan a este mismo código.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: '`SERVER_ERROR` — Error interno no controlado. Reintentable.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
      x-audience: machine
      x-developer: true
  /v1/looks:
    get:
      operationId: listLooks
      x-required-scope: campaign:read
      x-error-codes:
        '400':
          - BAD_REQUEST
          - INVALID_QUERY
        '401':
          - UNAUTHORIZED
        '403':
          - FORBIDDEN
        '500':
          - SERVER_ERROR
      tags:
        - looks
      security:
        - bearerAuth: []
      summary: 'Lista looks (scope: campaign:read)'
      parameters:
        - schema:
            type: string
            enum:
              - 'true'
              - 'false'
            description: 'Si ''true'', incluye looks soft-deleted (activo:false). Default: solo activos.'
            example: 'false'
          required: false
          name: incluyeInactivas
          in: query
        - schema:
            type: string
            description: >-
              Tamaño de página solicitado, entero entre 1 y 100. Default 50 si se omite. Valores fuera de rango
              devuelven `400 INVALID_QUERY`.
            example: '50'
          required: false
          name: pageSize
          in: query
        - schema:
            type: string
            description: >-
              Cursor opaco devuelto en `nextToken` del response anterior. Reenviar tal cual para pedir la siguiente
              página.
          required: false
          name: nextToken
          in: query
      responses:
        '200':
          description: Listado paginado.
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      $ref: '#/components/schemas/Look'
                    description: >-
                      Items de esta página. Puede contener menos que `pageSize` si es la última página o si el endpoint
                      aplica filtros post-query.
                  nextToken:
                    type: string
                    description: >-
                      Cursor opaco para pedir la siguiente página. Reenviar tal cual en `?nextToken=<valor>`. Ausente =
                      última página.
                    example: eyJwayI6IlQjMSIsInNrIjoiQyMxIn0=
                required:
                  - items
        '400':
          description: >-
            `BAD_REQUEST` — Request malformado: falta el body o un parámetro de path requerido. `INVALID_QUERY` — Query
            params inválidos (filtro desconocido, paginación malformada o rango de fechas ilegible).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: '`UNAUTHORIZED` — Falta el header Authorization o la credencial es inválida/revocada.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: '`FORBIDDEN` — La credencial es válida pero no porta el scope que la operación exige.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: '`SERVER_ERROR` — Error interno no controlado. Reintentable.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
      x-audience: machine
      x-developer: true
    post:
      operationId: createLook
      x-required-scope: campaign:write
      x-error-codes:
        '400':
          - VALIDATION_ERROR
          - INVALID_JSON
          - BAD_REQUEST
          - INVALID_ASSET_URL
        '401':
          - UNAUTHORIZED
        '403':
          - FORBIDDEN
        '500':
          - SERVER_ERROR
      tags:
        - looks
      security:
        - bearerAuth: []
      summary: 'Crea look (scope: campaign:write)'
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                  minLength: 1
                  maxLength: 64
                  description: Nombre legible del look, mostrado en la biblioteca y el picker.
                  example: Café Mañana
                variant:
                  type: string
                  enum:
                    - B1
                    - B2
                    - B4
                  description: >-
                    Estilo del cupón (disposición base de los slots): B1 Soleado, B2 Bloque de color, B4 Cupón
                    protagonista.
                  example: B1
                colors:
                  type: object
                  properties:
                    background:
                      type: string
                      pattern: ^#[0-9A-Fa-f]{6}$
                      description: Color de fondo del cupón, hex de 6 dígitos.
                      example: '#FBF5EA'
                    accent:
                      type: string
                      pattern: ^#[0-9A-Fa-f]{6}$
                      description: Color de acento (badges, borde del QR, bloque B2).
                      example: '#C75D43'
                    text:
                      type: string
                      pattern: ^#[0-9A-Fa-f]{6}$
                      description: Color del texto principal sobre el fondo.
                      example: '#2A211A'
                  required:
                    - background
                    - accent
                    - text
                  additionalProperties: false
                  description: Los 3 colores de marca del tenant.
                logoUrl:
                  type:
                    - string
                    - 'null'
                  maxLength: 1024
                  format: uri
                  description: >-
                    URL pública del logo en el bucket de looks. Debe apuntar al prefijo del tenant (el backend lo
                    verifica). null/ausente = sin logo.
                  example: >-
                    https://cupontliv2-looks.s3.us-east-1.amazonaws.com/b2d4e1c7-3a5f-4e6c-8b9d-1a2c3d4e5f60/01JWQ8ZK5M7N9P2R4T6V8X0YC7/logo-a3f9c2d41b7e8f06.webp
                heroUrl:
                  type:
                    - string
                    - 'null'
                  maxLength: 1024
                  format: uri
                  description: >-
                    URL pública de la imagen principal (hero) en el bucket de looks; mismo contrato que logoUrl.
                    null/ausente = look solo-logo.
              required:
                - name
                - variant
                - colors
              additionalProperties: false
      responses:
        '200':
          description: Creado.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Look'
        '400':
          description: >-
            `VALIDATION_ERROR` — El body no cumple el shape del request (Zod). El detalle viene en `error.issues`.
            `INVALID_JSON` — El body no es JSON válido. `BAD_REQUEST` — Request malformado: falta el body o un parámetro
            de path requerido. `INVALID_ASSET_URL` — `logoUrl`/`heroUrl` no apuntan al prefijo de assets de tu tenant.
            Sube el asset con POST /v1/looks/assets y usa la URL pública devuelta.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: '`UNAUTHORIZED` — Falta el header Authorization o la credencial es inválida/revocada.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: '`FORBIDDEN` — La credencial es válida pero no porta el scope que la operación exige.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: '`SERVER_ERROR` — Error interno no controlado. Reintentable.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
      x-audience: machine
      x-developer: true
  /v1/looks/{id}:
    get:
      operationId: getLook
      x-required-scope: campaign:read
      x-error-codes:
        '400':
          - BAD_REQUEST
        '401':
          - UNAUTHORIZED
        '403':
          - FORBIDDEN
        '404':
          - NOT_FOUND
        '500':
          - SERVER_ERROR
      tags:
        - looks
      security:
        - bearerAuth: []
      summary: 'Obtiene look por id (scope: campaign:read)'
      parameters:
        - schema:
            type: string
            pattern: ^[0-9A-HJKMNP-TV-Z]{26}$
            description: ULID del recurso.
            example: 01ARZ3NDEKTSV4RRFFQ69G5FAV
          required: true
          name: id
          in: path
      responses:
        '200':
          description: Encontrado.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Look'
        '400':
          description: '`BAD_REQUEST` — Request malformado: falta el body o un parámetro de path requerido.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: '`UNAUTHORIZED` — Falta el header Authorization o la credencial es inválida/revocada.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: '`FORBIDDEN` — La credencial es válida pero no porta el scope que la operación exige.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: >-
            `NOT_FOUND` — El recurso no existe para tu tenant/ambiente. Anti-enumeración: identificadores de otro tenant
            o malformados colapsan a este mismo código.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: '`SERVER_ERROR` — Error interno no controlado. Reintentable.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
      x-audience: machine
      x-developer: true
    patch:
      operationId: updateLook
      x-required-scope: campaign:write
      x-error-codes:
        '400':
          - VALIDATION_ERROR
          - INVALID_JSON
          - BAD_REQUEST
          - INVALID_ASSET_URL
        '401':
          - UNAUTHORIZED
        '403':
          - FORBIDDEN
        '404':
          - NOT_FOUND
        '500':
          - SERVER_ERROR
      tags:
        - looks
      security:
        - bearerAuth: []
      summary: 'Actualiza look (scope: campaign:write)'
      parameters:
        - schema:
            type: string
            pattern: ^[0-9A-HJKMNP-TV-Z]{26}$
            description: ULID del recurso.
            example: 01ARZ3NDEKTSV4RRFFQ69G5FAV
          required: true
          name: id
          in: path
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                  minLength: 1
                  maxLength: 64
                  description: Nombre legible del look, mostrado en la biblioteca y el picker.
                  example: Café Mañana
                variant:
                  type: string
                  enum:
                    - B1
                    - B2
                    - B4
                  description: >-
                    Estilo del cupón (disposición base de los slots): B1 Soleado, B2 Bloque de color, B4 Cupón
                    protagonista.
                  example: B1
                colors:
                  type: object
                  properties:
                    background:
                      type: string
                      pattern: ^#[0-9A-Fa-f]{6}$
                      description: Color de fondo del cupón, hex de 6 dígitos.
                      example: '#FBF5EA'
                    accent:
                      type: string
                      pattern: ^#[0-9A-Fa-f]{6}$
                      description: Color de acento (badges, borde del QR, bloque B2).
                      example: '#C75D43'
                    text:
                      type: string
                      pattern: ^#[0-9A-Fa-f]{6}$
                      description: Color del texto principal sobre el fondo.
                      example: '#2A211A'
                  required:
                    - background
                    - accent
                    - text
                  additionalProperties: false
                  description: Los 3 colores de marca del tenant.
                logoUrl:
                  type:
                    - string
                    - 'null'
                  maxLength: 1024
                  format: uri
                  description: >-
                    URL pública del logo en el bucket de looks. Debe apuntar al prefijo del tenant (el backend lo
                    verifica). null/ausente = sin logo.
                  example: >-
                    https://cupontliv2-looks.s3.us-east-1.amazonaws.com/b2d4e1c7-3a5f-4e6c-8b9d-1a2c3d4e5f60/01JWQ8ZK5M7N9P2R4T6V8X0YC7/logo-a3f9c2d41b7e8f06.webp
                heroUrl:
                  type:
                    - string
                    - 'null'
                  maxLength: 1024
                  format: uri
                  description: >-
                    URL pública de la imagen principal (hero) en el bucket de looks; mismo contrato que logoUrl.
                    null/ausente = look solo-logo.
              additionalProperties: false
      responses:
        '200':
          description: Actualizado.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Look'
        '400':
          description: >-
            `VALIDATION_ERROR` — El body no cumple el shape del request (Zod). El detalle viene en `error.issues`.
            `INVALID_JSON` — El body no es JSON válido. `BAD_REQUEST` — Request malformado: falta el body o un parámetro
            de path requerido. `INVALID_ASSET_URL` — `logoUrl`/`heroUrl` no apuntan al prefijo de assets de tu tenant.
            Sube el asset con POST /v1/looks/assets y usa la URL pública devuelta.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: '`UNAUTHORIZED` — Falta el header Authorization o la credencial es inválida/revocada.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: '`FORBIDDEN` — La credencial es válida pero no porta el scope que la operación exige.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: >-
            `NOT_FOUND` — El recurso no existe para tu tenant/ambiente. Anti-enumeración: identificadores de otro tenant
            o malformados colapsan a este mismo código.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: '`SERVER_ERROR` — Error interno no controlado. Reintentable.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
      x-audience: machine
      x-developer: true
    delete:
      operationId: deleteLook
      x-required-scope: campaign:write
      x-error-codes:
        '400':
          - BAD_REQUEST
        '401':
          - UNAUTHORIZED
        '403':
          - FORBIDDEN
        '404':
          - NOT_FOUND
        '500':
          - SERVER_ERROR
      tags:
        - looks
      security:
        - bearerAuth: []
      summary: 'Archiva look (soft delete) (scope: campaign:write)'
      parameters:
        - schema:
            type: string
            pattern: ^[0-9A-HJKMNP-TV-Z]{26}$
            description: ULID del recurso.
            example: 01ARZ3NDEKTSV4RRFFQ69G5FAV
          required: true
          name: id
          in: path
      responses:
        '200':
          description: 'Archivada. Devuelve el recurso completo con `status: "archivada"`. Idempotente.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Look'
        '400':
          description: '`BAD_REQUEST` — Request malformado: falta el body o un parámetro de path requerido.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: '`UNAUTHORIZED` — Falta el header Authorization o la credencial es inválida/revocada.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: '`FORBIDDEN` — La credencial es válida pero no porta el scope que la operación exige.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: >-
            `NOT_FOUND` — El recurso no existe para tu tenant/ambiente. Anti-enumeración: identificadores de otro tenant
            o malformados colapsan a este mismo código.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: '`SERVER_ERROR` — Error interno no controlado. Reintentable.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
      x-audience: machine
      x-developer: true
  /v1/looks/assets:
    post:
      operationId: presignLookAsset
      x-required-scope: campaign:write
      x-error-codes:
        '400':
          - VALIDATION_ERROR
          - INVALID_JSON
          - BAD_REQUEST
        '401':
          - UNAUTHORIZED
        '403':
          - FORBIDDEN
        '500':
          - SERVER_ERROR
      tags:
        - looks
      security:
        - bearerAuth: []
      summary: 'Firma un presigned PUT para subir un asset (logo/hero) del diseño de cupón (scope: campaign:write)'
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                slot:
                  type: string
                  enum:
                    - logo
                    - hero
                  description: Slot del asset dentro del look.
                  example: logo
                contentHash:
                  type: string
                  pattern: ^[0-9a-f]{64}$
                  description: >-
                    SHA-256 (hex) del archivo WebP, calculado por el cliente. Sus primeros 16 chars entran a la key — su
                    función es unicidad/inmutabilidad de la key, no integridad (el backend no re-verifica los bytes).
                  example: a3f9c2d41b7e8f06a3f9c2d41b7e8f06a3f9c2d41b7e8f06a3f9c2d41b7e8f06
                contentLength:
                  type: integer
                  exclusiveMinimum: 0
                  maximum: 400000
                  description: >-
                    Tamaño exacto del archivo en bytes. Va firmado en el presigned PUT: subir otro tamaño hace que S3
                    rechace el upload.
                  example: 184320
              required:
                - slot
                - contentHash
                - contentLength
              additionalProperties: false
      responses:
        '200':
          description: URL firmada de upload (expira ~5 min) + URL pública final del asset.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PresignLookAsset'
        '400':
          description: >-
            `VALIDATION_ERROR` — El body no cumple el shape del request (Zod). El detalle viene en `error.issues`.
            `INVALID_JSON` — El body no es JSON válido. `BAD_REQUEST` — Request malformado: falta el body o un parámetro
            de path requerido.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: '`UNAUTHORIZED` — Falta el header Authorization o la credencial es inválida/revocada.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: '`FORBIDDEN` — La credencial es válida pero no porta el scope que la operación exige.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: '`SERVER_ERROR` — Error interno no controlado. Reintentable.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
      x-audience: machine
      x-developer: true
  /v1/campaigns/{campaignId}/tags/{tagName}:
    put:
      operationId: assignTagToCampaign
      x-required-scope: campaign:write
      x-error-codes:
        '400':
          - BAD_REQUEST
        '401':
          - UNAUTHORIZED
        '403':
          - FORBIDDEN
        '404':
          - NOT_FOUND
        '409':
          - CAMPAIGN_ARCHIVED
          - TAG_LIMIT_EXCEEDED
        '500':
          - SERVER_ERROR
      tags:
        - campanas
      security:
        - bearerAuth: []
      summary: 'Asigna un tag a la campaña (idempotente) (scope: campaign:write)'
      description: >-
        Asigna el tag `{tagName}` a la campaña `{campaignId}`. Idempotente: si la campaña ya tiene el tag, devuelve 200
        con el estado actual sin escribir. Bloqueado sobre campañas archivadas (409 CAMPAIGN_ARCHIVED). Cap de 10 tags
        por campaña (409 TAG_LIMIT_EXCEEDED).
      parameters:
        - schema:
            type: string
            pattern: ^[0-9A-HJKMNP-TV-Z]{26}$
            description: ULID de la campaña.
            example: 01ARZ3NDEKTSV4RRFFQ69G5FAV
          required: true
          name: campaignId
          in: path
        - schema:
            type: string
            minLength: 1
            maxLength: 32
            pattern: ^[a-z0-9-]+$
            description: Nombre del tag normalizado (lowercase, alfanumérico + guiones, 1-32 chars).
            example: promo-verano
          required: true
          name: tagName
          in: path
      responses:
        '200':
          description: Tag asignado (o ya asignado, idempotente). Devuelve la campaña completa con el array `tags` actualizado.
          content:
            application/json:
              schema:
                type: object
                properties:
                  campaignId:
                    type: string
                    pattern: ^[0-9A-HJKMNP-TV-Z]{26}$
                    description: Identificador ULID de la campaña. Server-set al crear (el cliente no lo envía).
                    example: 01ARZ3NDEKTSV4RRFFQ69G5FAV
                  tenantId:
                    type: string
                    format: uuid
                    description: Identificador UUID del tenant dueño de la campaña. Server-set desde el authorizer.
                    example: 7a3e8b12-9c4d-4f5e-a6b7-c8d9e0f1a2b3
                  customer:
                    type: string
                    description: >-
                      Marca/comercio que aparece en el cupón emitido (la UI lo etiqueta como 'Marca'; default = nombre
                      de la organización del tenant). Nombre de campo `cliente` por compatibilidad con v1.
                    example: Cafetería del Centro
                  description:
                    type: string
                    maxLength: 128
                    description: >-
                      Descripción interna del admin (máximo 128 caracteres). No es el mensaje al cliente final ni la
                      justificación del precio.
                    example: Push de junio para mover inventario de café especial
                  name:
                    type: string
                    minLength: 1
                    description: Nombre legible de la campaña, mostrado en consolas de admin.
                    example: Promo Día del Padre 2026
                  maxCoupons:
                    type: integer
                    exclusiveMinimum: 0
                    description: Total de cupones que la campaña puede emitir. Cap implícito de anti-abuso junto con maxUses.
                    example: 500
                  maxUses:
                    type: integer
                    exclusiveMinimum: 0
                    description: Cuántas veces cada cupón individual puede canjearse. Default 1 (un solo uso por cupón).
                    example: 1
                  createdAt:
                    type: string
                    description: Instante de creación en ISO 8601 UTC (sufijo `Z`). Server-set.
                    example: '2026-05-16T14:30:00.000Z'
                  startDate:
                    type: string
                    description: >-
                      Inicio del rango de validez. Wall-clock interpretado en la `timezone` de la campaña. Acepta
                      YYYY-MM-DD (se normaliza a T00:00:00) o YYYY-MM-DDTHH:MM:SS sin offset.
                    example: '2026-06-01T00:00:00'
                  endDate:
                    type: string
                    description: >-
                      Fin del rango de validez. Mismo formato que startDate; se normaliza a T23:59:59 si solo viene la
                      fecha.
                    example: '2026-06-30T23:59:59'
                  timezone:
                    type: string
                    minLength: 1
                    description: >-
                      IANA timezone de la campaña, por ejemplo `America/Mexico_City`. Es la única fuente de verdad para
                      resolver startDate/endDate a UTC. No acepta offsets (`-06:00`) ni abreviaciones (`CST`).
                    example: America/Mexico_City
                  lastSerial:
                    type: integer
                    minimum: 0
                    description: 'Folio del último cupón emitido. Server-managed: incrementa con cada createCoupon.'
                    example: 42
                  status:
                    type: string
                    enum:
                      - draft
                      - active
                      - expired
                      - paused
                      - archived
                    description: >-
                      Estado de la campaña en su ciclo de vida. `activa` es el default al crear; el admin puede mover a
                      `suspendida` o `archivada` vía PATCH.
                    example: active
                  mintMechanism:
                    type: string
                    description: >-
                      Mecanismo de minteo de la campaña. `"autoservicio"` = minteo público self-service de Cupontli
                      (`POST /v1/mint`). Cualquier otro valor = `keyHash` de la api key de la integración que mintea por
                      la API autenticada. Ausencia = sin mecanismo declarado (minteo público cerrado).
                    example: self-service
                  createdBy:
                    type: string
                    description: Identificador del usuario Cognito o api key que creó la campaña. Server-set.
                    example: admin@ejemplo.com
                  updatedBy:
                    type: string
                    description: >-
                      Identificador del principal que actualizó la campaña por última vez vía controller. Server-set en
                      cada PATCH.
                    example: admin@ejemplo.com
                  updatedAt:
                    type: string
                    description: >-
                      Instante de la última actualización vía controller, en ISO 8601 UTC. No se bumpea por escrituras
                      de sistema (totalRedemptions, lastSerial).
                    example: '2026-05-17T09:15:22.000Z'
                  products:
                    type: array
                    items:
                      type: object
                      properties:
                        pid:
                          type: string
                          minLength: 1
                          maxLength: 128
                          description: >-
                            Identificador del producto en el catálogo del tenant (free-form: SKU, id interno, etc.). La
                            UI manual genera `manual:<uuid>`.
                          example: CAFE-250G
                        name:
                          type: string
                          minLength: 1
                          description: Nombre legible del producto.
                          example: Café especial 250g
                        quantity:
                          type: integer
                          exclusiveMinimum: 0
                          description: Unidades de este producto incluidas en la promoción.
                          example: 3
                        price:
                          type: number
                          exclusiveMinimum: 0
                          description: Precio unitario del producto en su moneda.
                          example: 180
                        moneda:
                          type: string
                          minLength: 1
                          description: Código de moneda (ISO 4217 sugerido, por ejemplo `MXN`, `USD`, `COP`).
                          example: MXN
                        pais:
                          type: string
                          minLength: 1
                          description: Código de país donde aplica este producto (alfa-2 sugerido).
                          example: MX
                        category:
                          type: string
                          minLength: 1
                          description: Categoría libre del producto para clasificación interna.
                          example: bebidas-cafe
                      required:
                        - pid
                        - name
                        - quantity
                        - price
                        - moneda
                        - pais
                        - category
                      additionalProperties: false
                    description: >-
                      Listado de productos incluidos en la promoción. Cada uno con cantidad y precio. `listPrice` se
                      calcula como Σ productos[i].price × cantidad.
                  listPrice:
                    type: number
                    minimum: 0
                    description: >-
                      Precio de lista del paquete, calculado por el servidor como Σ productos[i].price × cantidad. No se
                      acepta del cliente.
                    example: 540
                  finalPrice:
                    type: number
                    minimum: 0
                    description: >-
                      Precio efectivo que paga el cliente final. Un solo número que colapsa las cuatro mecánicas
                      promocionales (porcentaje, monto fijo, precio fijo, NxM). Para regalo, usar `0`.
                    example: 360
                  finalPriceJustification:
                    type: string
                    minLength: 20
                    maxLength: 500
                    description: >-
                      Texto de auditoría que explica por qué este precio (audiencia: equipo financiero del tenant).
                      Entre 20 y 500 caracteres. Distinto a `couponMessage` que es customer-facing.
                    example: >-
                      Descuento promocional del 33% sobre el precio de lista para impulsar volumen durante junio. Cierre
                      presupuestal Q2.
                  couponMessage:
                    type: string
                    minLength: 1
                    maxLength: 80
                    description: >-
                      Texto promocional customer-facing que ve el cliente final (máximo 80 caracteres). Aquí vive el
                      `sabor` promocional (`3x2`, `20% off`, etc.).
                    example: 3x2 en cafés - hasta el 30 de junio
                  offerSubtitle:
                    type: string
                    maxLength: 60
                    description: >-
                      Subtítulo corto customer-facing bajo el mensaje del cupón (máximo 60 caracteres). Opcional; cadena
                      vacía lo elimina.
                    example: Solo en sucursal · presenta tu QR en caja
                  maxBudget:
                    type: number
                    minimum: 0
                    description: >-
                      Tope absoluto del costo agregado del incentivo. Si está definido, el validador rechaza canjes que
                      excederían `(totalRedemptions+1) × (listPrice − finalPrice)` con `PRESUPUESTO_AGOTADO`.
                    example: 90000
                  totalRedemptions:
                    type: integer
                    minimum: 0
                    default: 0
                    description: >-
                      Contador atómico de canjes exitosos. Server-managed: se incrementa transaccionalmente en
                      `validateCoupon` junto con el patch del cupón.
                    example: 17
                  policyRules:
                    type: array
                    items:
                      type: object
                      properties:
                        descripcion:
                          type: string
                          minLength: 1
                          description: >-
                            Texto descriptivo de la regla que se reporta al POS cuando esta regla falla en la
                            validación.
                          example: Solo aplica en sucursales de la zona metropolitana
                        expr:
                          type: string
                          minLength: 1
                          description: >-
                            Expresión filtrex de UNA comparación simple (var op valor, o var in (lista)) que debe
                            evaluar a true para que la regla pase. Los strings literales van con comillas dobles, no
                            simples. No se permiten and/or ni expresiones compuestas: usa varias reglas (AND implícito
                            entre ellas).
                          example: ciudad in ("CDMX", "Monterrey", "Guadalajara")
                      required:
                        - descripcion
                        - expr
                      additionalProperties: false
                    description: >-
                      Reglas filtrex evaluadas al canjear el cupón. AND implícito entre reglas; cada una se evalúa por
                      separado para reportar fallas individuales en `error.details.failures[]`. Server-derived: es el
                      snapshot congelado de las reglas de la política referida por `policyId` al momento de crear la
                      campaña — el cliente NO lo envía.
                  policyId:
                    type: string
                    pattern: ^[0-9A-HJKMNP-TV-Z]{26}$
                    description: >-
                      ULID de la política referida. El server snapshotea sus reglas a `politica[]` y guarda su versión
                      en `policyVersion`. Opcional: una campaña puede no tener política.
                    example: 01JWQ8ZK5M7N9P2R4T6V8X0YB6
                  policyVersion:
                    type: integer
                    exclusiveMinimum: 0
                    description: >-
                      Versión de la política al momento del snapshot (server-set). Permite detectar drift contra la
                      política actual.
                    example: 1
                  lookId:
                    type:
                      - string
                      - 'null'
                    pattern: ^[0-9A-HJKMNP-TV-Z]{26}$
                    description: >-
                      ULID del look (diseño) referido. El server snapshotea variante/colores/URLs a `look` y su versión
                      a `lookVersion`. null en PATCH = quitar el diseño.
                    example: 01JWQ8ZK5M7N9P2R4T6V8X0YC7
                  look:
                    $ref: '#/components/schemas/LookSnapshot'
                  lookVersion:
                    type: integer
                    exclusiveMinimum: 0
                    description: >-
                      Versión del look al momento del snapshot (server-set). Permite detectar drift contra el look
                      actual.
                    example: 1
                  legalText:
                    type: string
                    description: Texto legal opcional anexado al cupón (términos, condiciones, restricciones).
                    example: >-
                      Promoción válida hasta agotar inventario. No acumulable con otras ofertas. Aplica solo en
                      sucursales participantes.
                  tags:
                    type: array
                    items:
                      type: string
                      minLength: 1
                      maxLength: 32
                      pattern: ^[a-z0-9-]+$
                    maxItems: 10
                    description: >-
                      Tags asignados a la campaña. Se mutan via PUT/DELETE /v1/campaigns/{campaignId}/tags/{tagName}, no
                      via PATCH del recurso. Máximo 10 por campaña.
                    example:
                      - promo-verano
                      - q2-2026
                  exhaustedNotifiedAt:
                    type: string
                    description: >-
                      Server-set: cuándo se anunció la exhaustión por presupuesto de la campaña (evento
                      campaign.exhausted). Solo lectura.
                    example: '2026-07-02T18:32:00Z'
                  bulkMintedAt:
                    type: string
                    description: >-
                      Server-set: cuándo la descarga masiva reclamó la campaña completa. Presente ⇔ la campaña se agotó
                      por descarga y su set de cupones puede volver a descargarse. Solo lectura.
                    example: '2026-07-03T15:00:00Z'
                  bulkMintedBy:
                    type: string
                    description: >-
                      Server-set: quién reclamó el lote (email del admin o `apikey:<keyHash>` de la integración). Solo
                      lectura.
                    example: admin@empresa.mx
                  termsAccepted:
                    type: boolean
                    description: >-
                      Confirmación explícita del admin de que aceptó los términos al crear la campaña. Debe ser `true`.
                      No se persiste; gate del request schema.
                    example: true
                required:
                  - campaignId
                  - tenantId
                  - name
                  - maxCoupons
                  - createdAt
                  - startDate
                  - endDate
                  - timezone
                  - lastSerial
                  - status
                  - createdBy
                  - listPrice
                  - finalPrice
                  - finalPriceJustification
                  - couponMessage
                  - termsAccepted
                additionalProperties: false
        '400':
          description: '`BAD_REQUEST` — Request malformado: falta el body o un parámetro de path requerido.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: '`UNAUTHORIZED` — Falta el header Authorization o la credencial es inválida/revocada.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: '`FORBIDDEN` — La credencial es válida pero no porta el scope que la operación exige.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: >-
            `NOT_FOUND` — El recurso no existe para tu tenant/ambiente. Anti-enumeración: identificadores de otro tenant
            o malformados colapsan a este mismo código.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          description: >-
            `CAMPAIGN_ARCHIVED` — La campaña está archivada (estado terminal): no se puede editar, canjear ni asignarle
            tags. `TAG_LIMIT_EXCEEDED` — El recurso ya tiene el máximo de 10 tags.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: '`SERVER_ERROR` — Error interno no controlado. Reintentable.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
      x-audience: machine
      x-developer: true
    delete:
      operationId: unassignTagFromCampaign
      x-required-scope: campaign:write
      x-error-codes:
        '400':
          - BAD_REQUEST
        '401':
          - UNAUTHORIZED
        '403':
          - FORBIDDEN
        '404':
          - NOT_FOUND
        '500':
          - SERVER_ERROR
      tags:
        - campanas
      security:
        - bearerAuth: []
      summary: 'Desasigna un tag de la campaña (idempotente) (scope: campaign:write)'
      description: >-
        Quita el tag `{tagName}` de la campaña `{campaignId}`. Idempotente: si el tag no estaba asignado, devuelve 200
        con el estado actual sin escribir. Permitido sobre campañas archivadas (asimetría intencional con assign, para
        limpieza).
      parameters:
        - schema:
            type: string
            pattern: ^[0-9A-HJKMNP-TV-Z]{26}$
            description: ULID de la campaña.
            example: 01ARZ3NDEKTSV4RRFFQ69G5FAV
          required: true
          name: campaignId
          in: path
        - schema:
            type: string
            minLength: 1
            maxLength: 32
            pattern: ^[a-z0-9-]+$
            description: Nombre del tag normalizado (lowercase, alfanumérico + guiones, 1-32 chars).
            example: promo-verano
          required: true
          name: tagName
          in: path
      responses:
        '200':
          description: Tag desasignado (o ya no estaba, idempotente). Devuelve la campaña completa con el array `tags` actualizado.
          content:
            application/json:
              schema:
                type: object
                properties:
                  campaignId:
                    type: string
                    pattern: ^[0-9A-HJKMNP-TV-Z]{26}$
                    description: Identificador ULID de la campaña. Server-set al crear (el cliente no lo envía).
                    example: 01ARZ3NDEKTSV4RRFFQ69G5FAV
                  tenantId:
                    type: string
                    format: uuid
                    description: Identificador UUID del tenant dueño de la campaña. Server-set desde el authorizer.
                    example: 7a3e8b12-9c4d-4f5e-a6b7-c8d9e0f1a2b3
                  customer:
                    type: string
                    description: >-
                      Marca/comercio que aparece en el cupón emitido (la UI lo etiqueta como 'Marca'; default = nombre
                      de la organización del tenant). Nombre de campo `cliente` por compatibilidad con v1.
                    example: Cafetería del Centro
                  description:
                    type: string
                    maxLength: 128
                    description: >-
                      Descripción interna del admin (máximo 128 caracteres). No es el mensaje al cliente final ni la
                      justificación del precio.
                    example: Push de junio para mover inventario de café especial
                  name:
                    type: string
                    minLength: 1
                    description: Nombre legible de la campaña, mostrado en consolas de admin.
                    example: Promo Día del Padre 2026
                  maxCoupons:
                    type: integer
                    exclusiveMinimum: 0
                    description: Total de cupones que la campaña puede emitir. Cap implícito de anti-abuso junto con maxUses.
                    example: 500
                  maxUses:
                    type: integer
                    exclusiveMinimum: 0
                    description: Cuántas veces cada cupón individual puede canjearse. Default 1 (un solo uso por cupón).
                    example: 1
                  createdAt:
                    type: string
                    description: Instante de creación en ISO 8601 UTC (sufijo `Z`). Server-set.
                    example: '2026-05-16T14:30:00.000Z'
                  startDate:
                    type: string
                    description: >-
                      Inicio del rango de validez. Wall-clock interpretado en la `timezone` de la campaña. Acepta
                      YYYY-MM-DD (se normaliza a T00:00:00) o YYYY-MM-DDTHH:MM:SS sin offset.
                    example: '2026-06-01T00:00:00'
                  endDate:
                    type: string
                    description: >-
                      Fin del rango de validez. Mismo formato que startDate; se normaliza a T23:59:59 si solo viene la
                      fecha.
                    example: '2026-06-30T23:59:59'
                  timezone:
                    type: string
                    minLength: 1
                    description: >-
                      IANA timezone de la campaña, por ejemplo `America/Mexico_City`. Es la única fuente de verdad para
                      resolver startDate/endDate a UTC. No acepta offsets (`-06:00`) ni abreviaciones (`CST`).
                    example: America/Mexico_City
                  lastSerial:
                    type: integer
                    minimum: 0
                    description: 'Folio del último cupón emitido. Server-managed: incrementa con cada createCoupon.'
                    example: 42
                  status:
                    type: string
                    enum:
                      - draft
                      - active
                      - expired
                      - paused
                      - archived
                    description: >-
                      Estado de la campaña en su ciclo de vida. `activa` es el default al crear; el admin puede mover a
                      `suspendida` o `archivada` vía PATCH.
                    example: active
                  mintMechanism:
                    type: string
                    description: >-
                      Mecanismo de minteo de la campaña. `"autoservicio"` = minteo público self-service de Cupontli
                      (`POST /v1/mint`). Cualquier otro valor = `keyHash` de la api key de la integración que mintea por
                      la API autenticada. Ausencia = sin mecanismo declarado (minteo público cerrado).
                    example: self-service
                  createdBy:
                    type: string
                    description: Identificador del usuario Cognito o api key que creó la campaña. Server-set.
                    example: admin@ejemplo.com
                  updatedBy:
                    type: string
                    description: >-
                      Identificador del principal que actualizó la campaña por última vez vía controller. Server-set en
                      cada PATCH.
                    example: admin@ejemplo.com
                  updatedAt:
                    type: string
                    description: >-
                      Instante de la última actualización vía controller, en ISO 8601 UTC. No se bumpea por escrituras
                      de sistema (totalRedemptions, lastSerial).
                    example: '2026-05-17T09:15:22.000Z'
                  products:
                    type: array
                    items:
                      type: object
                      properties:
                        pid:
                          type: string
                          minLength: 1
                          maxLength: 128
                          description: >-
                            Identificador del producto en el catálogo del tenant (free-form: SKU, id interno, etc.). La
                            UI manual genera `manual:<uuid>`.
                          example: CAFE-250G
                        name:
                          type: string
                          minLength: 1
                          description: Nombre legible del producto.
                          example: Café especial 250g
                        quantity:
                          type: integer
                          exclusiveMinimum: 0
                          description: Unidades de este producto incluidas en la promoción.
                          example: 3
                        price:
                          type: number
                          exclusiveMinimum: 0
                          description: Precio unitario del producto en su moneda.
                          example: 180
                        moneda:
                          type: string
                          minLength: 1
                          description: Código de moneda (ISO 4217 sugerido, por ejemplo `MXN`, `USD`, `COP`).
                          example: MXN
                        pais:
                          type: string
                          minLength: 1
                          description: Código de país donde aplica este producto (alfa-2 sugerido).
                          example: MX
                        category:
                          type: string
                          minLength: 1
                          description: Categoría libre del producto para clasificación interna.
                          example: bebidas-cafe
                      required:
                        - pid
                        - name
                        - quantity
                        - price
                        - moneda
                        - pais
                        - category
                      additionalProperties: false
                    description: >-
                      Listado de productos incluidos en la promoción. Cada uno con cantidad y precio. `listPrice` se
                      calcula como Σ productos[i].price × cantidad.
                  listPrice:
                    type: number
                    minimum: 0
                    description: >-
                      Precio de lista del paquete, calculado por el servidor como Σ productos[i].price × cantidad. No se
                      acepta del cliente.
                    example: 540
                  finalPrice:
                    type: number
                    minimum: 0
                    description: >-
                      Precio efectivo que paga el cliente final. Un solo número que colapsa las cuatro mecánicas
                      promocionales (porcentaje, monto fijo, precio fijo, NxM). Para regalo, usar `0`.
                    example: 360
                  finalPriceJustification:
                    type: string
                    minLength: 20
                    maxLength: 500
                    description: >-
                      Texto de auditoría que explica por qué este precio (audiencia: equipo financiero del tenant).
                      Entre 20 y 500 caracteres. Distinto a `couponMessage` que es customer-facing.
                    example: >-
                      Descuento promocional del 33% sobre el precio de lista para impulsar volumen durante junio. Cierre
                      presupuestal Q2.
                  couponMessage:
                    type: string
                    minLength: 1
                    maxLength: 80
                    description: >-
                      Texto promocional customer-facing que ve el cliente final (máximo 80 caracteres). Aquí vive el
                      `sabor` promocional (`3x2`, `20% off`, etc.).
                    example: 3x2 en cafés - hasta el 30 de junio
                  offerSubtitle:
                    type: string
                    maxLength: 60
                    description: >-
                      Subtítulo corto customer-facing bajo el mensaje del cupón (máximo 60 caracteres). Opcional; cadena
                      vacía lo elimina.
                    example: Solo en sucursal · presenta tu QR en caja
                  maxBudget:
                    type: number
                    minimum: 0
                    description: >-
                      Tope absoluto del costo agregado del incentivo. Si está definido, el validador rechaza canjes que
                      excederían `(totalRedemptions+1) × (listPrice − finalPrice)` con `PRESUPUESTO_AGOTADO`.
                    example: 90000
                  totalRedemptions:
                    type: integer
                    minimum: 0
                    default: 0
                    description: >-
                      Contador atómico de canjes exitosos. Server-managed: se incrementa transaccionalmente en
                      `validateCoupon` junto con el patch del cupón.
                    example: 17
                  policyRules:
                    type: array
                    items:
                      type: object
                      properties:
                        descripcion:
                          type: string
                          minLength: 1
                          description: >-
                            Texto descriptivo de la regla que se reporta al POS cuando esta regla falla en la
                            validación.
                          example: Solo aplica en sucursales de la zona metropolitana
                        expr:
                          type: string
                          minLength: 1
                          description: >-
                            Expresión filtrex de UNA comparación simple (var op valor, o var in (lista)) que debe
                            evaluar a true para que la regla pase. Los strings literales van con comillas dobles, no
                            simples. No se permiten and/or ni expresiones compuestas: usa varias reglas (AND implícito
                            entre ellas).
                          example: ciudad in ("CDMX", "Monterrey", "Guadalajara")
                      required:
                        - descripcion
                        - expr
                      additionalProperties: false
                    description: >-
                      Reglas filtrex evaluadas al canjear el cupón. AND implícito entre reglas; cada una se evalúa por
                      separado para reportar fallas individuales en `error.details.failures[]`. Server-derived: es el
                      snapshot congelado de las reglas de la política referida por `policyId` al momento de crear la
                      campaña — el cliente NO lo envía.
                  policyId:
                    type: string
                    pattern: ^[0-9A-HJKMNP-TV-Z]{26}$
                    description: >-
                      ULID de la política referida. El server snapshotea sus reglas a `politica[]` y guarda su versión
                      en `policyVersion`. Opcional: una campaña puede no tener política.
                    example: 01JWQ8ZK5M7N9P2R4T6V8X0YB6
                  policyVersion:
                    type: integer
                    exclusiveMinimum: 0
                    description: >-
                      Versión de la política al momento del snapshot (server-set). Permite detectar drift contra la
                      política actual.
                    example: 1
                  lookId:
                    type:
                      - string
                      - 'null'
                    pattern: ^[0-9A-HJKMNP-TV-Z]{26}$
                    description: >-
                      ULID del look (diseño) referido. El server snapshotea variante/colores/URLs a `look` y su versión
                      a `lookVersion`. null en PATCH = quitar el diseño.
                    example: 01JWQ8ZK5M7N9P2R4T6V8X0YC7
                  look:
                    $ref: '#/components/schemas/LookSnapshot'
                  lookVersion:
                    type: integer
                    exclusiveMinimum: 0
                    description: >-
                      Versión del look al momento del snapshot (server-set). Permite detectar drift contra el look
                      actual.
                    example: 1
                  legalText:
                    type: string
                    description: Texto legal opcional anexado al cupón (términos, condiciones, restricciones).
                    example: >-
                      Promoción válida hasta agotar inventario. No acumulable con otras ofertas. Aplica solo en
                      sucursales participantes.
                  tags:
                    type: array
                    items:
                      type: string
                      minLength: 1
                      maxLength: 32
                      pattern: ^[a-z0-9-]+$
                    maxItems: 10
                    description: >-
                      Tags asignados a la campaña. Se mutan via PUT/DELETE /v1/campaigns/{campaignId}/tags/{tagName}, no
                      via PATCH del recurso. Máximo 10 por campaña.
                    example:
                      - promo-verano
                      - q2-2026
                  exhaustedNotifiedAt:
                    type: string
                    description: >-
                      Server-set: cuándo se anunció la exhaustión por presupuesto de la campaña (evento
                      campaign.exhausted). Solo lectura.
                    example: '2026-07-02T18:32:00Z'
                  bulkMintedAt:
                    type: string
                    description: >-
                      Server-set: cuándo la descarga masiva reclamó la campaña completa. Presente ⇔ la campaña se agotó
                      por descarga y su set de cupones puede volver a descargarse. Solo lectura.
                    example: '2026-07-03T15:00:00Z'
                  bulkMintedBy:
                    type: string
                    description: >-
                      Server-set: quién reclamó el lote (email del admin o `apikey:<keyHash>` de la integración). Solo
                      lectura.
                    example: admin@empresa.mx
                  termsAccepted:
                    type: boolean
                    description: >-
                      Confirmación explícita del admin de que aceptó los términos al crear la campaña. Debe ser `true`.
                      No se persiste; gate del request schema.
                    example: true
                required:
                  - campaignId
                  - tenantId
                  - name
                  - maxCoupons
                  - createdAt
                  - startDate
                  - endDate
                  - timezone
                  - lastSerial
                  - status
                  - createdBy
                  - listPrice
                  - finalPrice
                  - finalPriceJustification
                  - couponMessage
                  - termsAccepted
                additionalProperties: false
        '400':
          description: '`BAD_REQUEST` — Request malformado: falta el body o un parámetro de path requerido.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: '`UNAUTHORIZED` — Falta el header Authorization o la credencial es inválida/revocada.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: '`FORBIDDEN` — La credencial es válida pero no porta el scope que la operación exige.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: >-
            `NOT_FOUND` — El recurso no existe para tu tenant/ambiente. Anti-enumeración: identificadores de otro tenant
            o malformados colapsan a este mismo código.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: '`SERVER_ERROR` — Error interno no controlado. Reintentable.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
      x-audience: machine
      x-developer: true
  /v1/campaigns/{campaignId}/coupons:
    post:
      operationId: bulkExportCoupons
      x-required-scope: coupon:export
      x-error-codes:
        '400':
          - BAD_REQUEST
          - CAMPAIGN_NOT_ACTIVE
          - EXPORT_TOO_LARGE
        '401':
          - UNAUTHORIZED
        '403':
          - FORBIDDEN
        '404':
          - NOT_FOUND
        '409':
          - CAMPAIGN_ARCHIVED
          - CAMPAIGN_INCOMPLETE
          - BULK_EXPORT_UNAVAILABLE
        '500':
          - SERVER_ERROR
      tags:
        - campanas
      security:
        - bearerAuth: []
      summary: 'Descarga masiva: reclama la campaña completa y devuelve TODOS sus cupones (scope: coupon:export)'
      description: >-
        ⚠️ SIDE EFFECT: el PRIMER llamado ES el acto de minteo — reclama la campaña completa y la deja agotada (ningún
        otro canal podrá generar cupones: ni mint público ni POST /v1/cupon). Requiere una campaña ACTIVA sin cupones
        emitidos; una que ya emitió por otro canal se rechaza con 409 BULK_EXPORT_UNAVAILABLE. Los llamados siguientes
        re-derivan el MISMO set sin side effects (tokens determinísticos) — re-descargar es seguro. Límite duro: 5,000
        cupones por campaña (400 EXPORT_TOO_LARGE) — la respuesta es síncrona y Lambda la corta en 6 MB. Sin body: todo
        viaja en el path. Cada `url` es la liga viva del cupón (payload del QR para impresión).
      parameters:
        - schema:
            type: string
            pattern: ^[0-9A-HJKMNP-TV-Z]{26}$
            description: ID de la campaña (ULID).
            example: 01J9ZX8K3M4N5P6Q7R8S9T0V1W
          required: true
          name: campaignId
          in: path
      responses:
        '200':
          description: >-
            El lote completo (claim recién hecho o re-descarga). `bulkMintedAt`/`bulkMintedBy` dicen cuándo y quién
            reclamó.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DescargaMasivaResponseData'
        '400':
          description: >-
            `BAD_REQUEST` — Request malformado: falta el body o un parámetro de path requerido. `CAMPAIGN_NOT_ACTIVE` —
            La campaña no está activa (borrador o suspendida); la operación exige `status: activa`. `EXPORT_TOO_LARGE` —
            La campaña excede el límite de 5,000 cupones para descarga masiva síncrona.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: '`UNAUTHORIZED` — Falta el header Authorization o la credencial es inválida/revocada.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: '`FORBIDDEN` — La credencial es válida pero no porta el scope que la operación exige.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: >-
            `NOT_FOUND` — El recurso no existe para tu tenant/ambiente. Anti-enumeración: identificadores de otro tenant
            o malformados colapsan a este mismo código.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          description: >-
            `CAMPAIGN_ARCHIVED` — La campaña está archivada (estado terminal): no se puede editar, canjear ni asignarle
            tags. `CAMPAIGN_INCOMPLETE` — La campaña está activa pero le faltan datos requeridos (estado inconsistente).
            Contacta soporte. `BULK_EXPORT_UNAVAILABLE` — La campaña ya emitió cupones por otro canal (o un mint
            concurrente ganó el claim); la descarga masiva exige reclamar la campaña completa desde cero.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: '`SERVER_ERROR` — Error interno no controlado. Reintentable.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
      x-audience: machine
      x-developer: true
  /v1/coupons:
    post:
      operationId: createCoupon
      x-required-scope: coupon:create
      x-error-codes:
        '400':
          - VALIDATION_ERROR
          - INVALID_JSON
          - BAD_REQUEST
          - CAMPAIGN_NOT_ACTIVE
          - CAMPAIGN_NOT_STARTED
          - CAMPAIGN_ENDED
          - MAX_COUPONS_REACHED
        '401':
          - UNAUTHORIZED
        '403':
          - FORBIDDEN
        '404':
          - NOT_FOUND
        '409':
          - CAMPAIGN_ARCHIVED
          - CAMPAIGN_INCOMPLETE
        '500':
          - SERVER_ERROR
          - CAMPAIGN_ERROR
          - TRANSACTION_ERROR
      tags:
        - coupons
      security:
        - bearerAuth: []
      summary: 'Emite un cupón individual de una campaña (scope: coupon:create)'
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                campaignId:
                  type: string
                  pattern: ^[0-9A-HJKMNP-TV-Z]{26}$
                  description: ULID de la campaña desde la que se emite el cupón.
                  example: 01ARZ3NDEKTSV4RRFFQ69G5FAV
              required:
                - campaignId
              additionalProperties: false
      responses:
        '200':
          description: Cupón emitido.
          content:
            application/json:
              schema:
                type: object
                properties:
                  couponToken:
                    type: string
                    description: >-
                      Token base64url completo del cupón emitido. Codifica `tenantId`, `campaignId`, `serial`,
                      `environment` y la firma HMAC.
                    example: >-
                      eyJ2IjoxLCJ0IjoiN2EzZThiMTItOWM0ZC00ZjVlLWE2YjctYzhkOWUwZjFhMmIzIiwiYyI6ImUxYzdmM2EwLTJiOWQtNGM1Zi04YTZlLTdkM2IxZjJjNGE1ZSIsImYiOjQyLCJzIjoiSzVMTXhOM3BRclN0N3ZXeFl6MUEyQjNDNEQ1RTZGNyJ9
                required:
                  - couponToken
        '400':
          description: >-
            `VALIDATION_ERROR` — El body no cumple el shape del request (Zod). El detalle viene en `error.issues`.
            `INVALID_JSON` — El body no es JSON válido. `BAD_REQUEST` — Request malformado: falta el body o un parámetro
            de path requerido. `CAMPAIGN_NOT_ACTIVE` — La campaña no está activa (borrador o suspendida); la operación
            exige `status: activa`. `CAMPAIGN_NOT_STARTED` — La vigencia de la campaña aún no inicia (`startDate`).
            `CAMPAIGN_ENDED` — La vigencia de la campaña ya terminó (`endDate`). `MAX_COUPONS_REACHED` — La campaña
            agotó su `maxCoupons`; no quedan folios por emitir.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: '`UNAUTHORIZED` — Falta el header Authorization o la credencial es inválida/revocada.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: '`FORBIDDEN` — La credencial es válida pero no porta el scope que la operación exige.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: >-
            `NOT_FOUND` — El recurso no existe para tu tenant/ambiente. Anti-enumeración: identificadores de otro tenant
            o malformados colapsan a este mismo código.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          description: >-
            `CAMPAIGN_ARCHIVED` — La campaña está archivada (estado terminal): no se puede editar, canjear ni asignarle
            tags. `CAMPAIGN_INCOMPLETE` — La campaña está activa pero le faltan datos requeridos (estado inconsistente).
            Contacta soporte.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: >-
            `SERVER_ERROR` — Error interno no controlado. Reintentable. `CAMPAIGN_ERROR` — No se pudo leer o validar la
            campaña del cupón (falla interna). Reintentable. `TRANSACTION_ERROR` — La transacción de emisión falló
            (contención). Reintentable; ningún cupón quedó emitido.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
      x-audience: machine
      x-developer: true
  /v1/coupons/redeem:
    post:
      operationId: redeemCoupon
      x-required-scope: coupon:redeem
      x-error-codes:
        '400':
          - VALIDATION_ERROR
          - INVALID_JSON
          - BAD_REQUEST
          - INVALID_TOKEN
          - INVALID_COUPON
          - CAMPAIGN_NOT_ACTIVE
          - CAMPAIGN_NOT_STARTED
          - CAMPAIGN_ENDED
          - INVALID_FOLIO
          - COUPON_CANCELED
          - COUPON_EXHAUSTED
          - VALIDATION_RULES_FAILED
        '401':
          - UNAUTHORIZED
        '403':
          - FORBIDDEN
          - TENANT_MISMATCH
          - ENVIRONMENT_MISMATCH
        '404':
          - NOT_FOUND
        '409':
          - CAMPAIGN_ARCHIVED
          - MISSING_ATTRIBUTE
          - BUDGET_EXHAUSTED
        '500':
          - SERVER_ERROR
          - CAMPAIGN_ERROR
          - COUPON_ERROR
      tags:
        - coupons
      security:
        - bearerAuth: []
      summary: 'Valida y canjea un cupón en el POS (scope: coupon:redeem)'
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                couponToken:
                  type: string
                  description: Token base64url del cupón presentado en el POS.
                  example: >-
                    eyJ2IjoxLCJ0IjoiN2EzZThiMTItOWM0ZC00ZjVlLWE2YjctYzhkOWUwZjFhMmIzIiwiYyI6ImUxYzdmM2EwLTJiOWQtNGM1Zi04YTZlLTdkM2IxZjJjNGE1ZSIsImYiOjQyLCJzIjoiSzVMTXhOM3BRclN0N3ZXeFl6MUEyQjNDNEQ1RTZGNyJ9
                context:
                  type: object
                  additionalProperties: {}
                  description: >-
                    Pares clave/valor con los identificadores referenciados por la política filtrex de la campaña. El
                    POS puede descubrir qué claves enviar vía `POST /v1/coupons/fields`. Se mergea con el profile de la
                    api key (body gana en colisión).
                  example:
                    ciudad: CDMX
                    monto: 540
                    clienteRecurrente: true
              required:
                - couponToken
                - context
              additionalProperties: false
      responses:
        '200':
          description: Cupón válido y ya canjeado.
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    description: Mensaje confirmatorio del canje exitoso.
                    example: Cupón canjeado
                  breakdown:
                    type: object
                    properties:
                      products:
                        type: array
                        items:
                          type: object
                          properties:
                            pid:
                              type: string
                              description: UUID del producto.
                              example: b2d4e1c7-3a5f-4e6c-8b9d-1a2c3d4e5f60
                            name:
                              type: string
                              description: Nombre del producto.
                              example: Café especial 250g
                            quantity:
                              type: integer
                              exclusiveMinimum: 0
                              description: Unidades del producto en la promoción.
                              example: 3
                            price:
                              type: number
                              description: Precio unitario.
                              example: 180
                            moneda:
                              type: string
                              description: Código de moneda (ISO 4217 sugerido).
                              example: MXN
                            pais:
                              type: string
                              description: Código de país.
                              example: MX
                            category:
                              type: string
                              description: Categoría libre del producto.
                              example: bebidas-cafe
                          required:
                            - pid
                            - name
                            - quantity
                            - price
                            - moneda
                            - pais
                            - category
                        description: Productos incluidos en la promoción canjeada.
                      listPrice:
                        type: number
                        description: Precio de lista del paquete antes del descuento.
                        example: 540
                      finalPrice:
                        type: number
                        description: Precio efectivo que paga el cliente final.
                        example: 360
                      incentiveCost:
                        type: number
                        description: >-
                          Costo del incentivo asumido por el tenant (`listPrice − finalPrice`). Se incluye explícito
                          para que el integrador no tenga que duplicar la fórmula.
                        example: 180
                      moneda:
                        type: string
                        description: >-
                          Moneda del paquete, derivada de `productos[0].moneda`. Ausente si la campaña no tiene
                          productos itemizados (caso regalo sin paquete).
                        example: MXN
                    required:
                      - products
                      - listPrice
                      - finalPrice
                      - incentiveCost
                    description: Resumen económico del canje. Permite al POS imprimir el ticket sin volver a leer la campaña.
                required:
                  - message
                  - breakdown
        '400':
          description: >-
            `VALIDATION_ERROR` — El body no cumple el shape del request (Zod). El detalle viene en `error.issues`.
            `INVALID_JSON` — El body no es JSON válido. `BAD_REQUEST` — Request malformado: falta el body o un parámetro
            de path requerido. `INVALID_TOKEN` — El `couponToken` no se pudo interpretar (formato inválido o truncado).
            `INVALID_COUPON` — El cupón no es genuino (la firma HMAC no verifica) o no es operable. En superficies
            públicas este código también colapsa otros estados por anti-enumeración. `CAMPAIGN_NOT_ACTIVE` — La campaña
            no está activa (borrador o suspendida); la operación exige `status: activa`. `CAMPAIGN_NOT_STARTED` — La
            vigencia de la campaña aún no inicia (`startDate`). `CAMPAIGN_ENDED` — La vigencia de la campaña ya terminó
            (`endDate`). `INVALID_FOLIO` — El serial del cupón está fuera del rango emitido por la campaña.
            `COUPON_CANCELED` — El cupón fue cancelado; no se puede canjear. `COUPON_EXHAUSTED` — El cupón ya consumió
            todos sus usos (`maxUses`). `error.audit` trae el historial. `VALIDATION_RULES_FAILED` — El canje no cumple
            la política de la campaña. `error.failures` lista las reglas incumplidas.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: '`UNAUTHORIZED` — Falta el header Authorization o la credencial es inválida/revocada.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: >-
            `FORBIDDEN` — La credencial es válida pero no porta el scope que la operación exige. `TENANT_MISMATCH` — El
            cupón pertenece a otro tenant: tu credencial no puede operarlo. `ENVIRONMENT_MISMATCH` — El cupón es de otro
            ambiente (live vs test) que tu credencial.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: >-
            `NOT_FOUND` — El recurso no existe para tu tenant/ambiente. Anti-enumeración: identificadores de otro tenant
            o malformados colapsan a este mismo código.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          description: >-
            `CAMPAIGN_ARCHIVED` — La campaña está archivada (estado terminal): no se puede editar, canjear ni asignarle
            tags. `MISSING_ATTRIBUTE` — El `context` del canje no trae campos que la política exige. `error.attributes`
            lista los faltantes — consúltalos antes con POST /v1/coupons/fields. `BUDGET_EXHAUSTED` — El canje excedería
            el `maxBudget` de la campaña; se rechaza.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: >-
            `SERVER_ERROR` — Error interno no controlado. Reintentable. `CAMPAIGN_ERROR` — No se pudo leer o validar la
            campaña del cupón (falla interna). Reintentable. `COUPON_ERROR` — No se pudo leer o materializar el cupón
            (falla interna).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
      x-audience: machine
      x-developer: true
  /v1/coupons/fields:
    post:
      operationId: getCouponRedeemFields
      x-required-scope: coupon:redeem
      x-error-codes:
        '400':
          - VALIDATION_ERROR
          - INVALID_JSON
          - BAD_REQUEST
          - INVALID_TOKEN
          - INVALID_COUPON
          - CAMPAIGN_NOT_ACTIVE
          - CAMPAIGN_NOT_STARTED
          - CAMPAIGN_ENDED
          - INVALID_FOLIO
        '401':
          - UNAUTHORIZED
        '403':
          - FORBIDDEN
          - TENANT_MISMATCH
          - ENVIRONMENT_MISMATCH
        '404':
          - NOT_FOUND
        '409':
          - CAMPAIGN_ARCHIVED
        '500':
          - SERVER_ERROR
          - CAMPAIGN_ERROR
      tags:
        - coupons
      security:
        - bearerAuth: []
      summary: 'Lista los campos de context requeridos para canjear este cupón (scope: coupon:redeem)'
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                couponToken:
                  type: string
                  minLength: 1
                  description: Token base64url del cupón sobre el que se consultan los campos requeridos por su política.
                  example: >-
                    eyJ2IjoxLCJ0IjoiN2EzZThiMTItOWM0ZC00ZjVlLWE2YjctYzhkOWUwZjFhMmIzIiwiYyI6ImUxYzdmM2EwLTJiOWQtNGM1Zi04YTZlLTdkM2IxZjJjNGE1ZSIsImYiOjQyLCJzIjoiSzVMTXhOM3BRclN0N3ZXeFl6MUEyQjNDNEQ1RTZGNyJ9
              required:
                - couponToken
              additionalProperties: false
      responses:
        '200':
          description: Identificadores referenciados por la política filtrex de la campaña.
          content:
            application/json:
              schema:
                type: object
                properties:
                  fields:
                    type: array
                    items:
                      type: string
                    description: >-
                      Identificadores que la política filtrex de la campaña referencia. El POS debe enviar al menos
                      estos en `context` al llamar `POST /v1/coupons/redeem`.
                    example:
                      - ciudad
                      - monto
                      - clienteRecurrente
                required:
                  - fields
        '400':
          description: >-
            `VALIDATION_ERROR` — El body no cumple el shape del request (Zod). El detalle viene en `error.issues`.
            `INVALID_JSON` — El body no es JSON válido. `BAD_REQUEST` — Request malformado: falta el body o un parámetro
            de path requerido. `INVALID_TOKEN` — El `couponToken` no se pudo interpretar (formato inválido o truncado).
            `INVALID_COUPON` — El cupón no es genuino (la firma HMAC no verifica) o no es operable. En superficies
            públicas este código también colapsa otros estados por anti-enumeración. `CAMPAIGN_NOT_ACTIVE` — La campaña
            no está activa (borrador o suspendida); la operación exige `status: activa`. `CAMPAIGN_NOT_STARTED` — La
            vigencia de la campaña aún no inicia (`startDate`). `CAMPAIGN_ENDED` — La vigencia de la campaña ya terminó
            (`endDate`). `INVALID_FOLIO` — El serial del cupón está fuera del rango emitido por la campaña.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: '`UNAUTHORIZED` — Falta el header Authorization o la credencial es inválida/revocada.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: >-
            `FORBIDDEN` — La credencial es válida pero no porta el scope que la operación exige. `TENANT_MISMATCH` — El
            cupón pertenece a otro tenant: tu credencial no puede operarlo. `ENVIRONMENT_MISMATCH` — El cupón es de otro
            ambiente (live vs test) que tu credencial.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: >-
            `NOT_FOUND` — El recurso no existe para tu tenant/ambiente. Anti-enumeración: identificadores de otro tenant
            o malformados colapsan a este mismo código.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          description: >-
            `CAMPAIGN_ARCHIVED` — La campaña está archivada (estado terminal): no se puede editar, canjear ni asignarle
            tags.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: >-
            `SERVER_ERROR` — Error interno no controlado. Reintentable. `CAMPAIGN_ERROR` — No se pudo leer o validar la
            campaña del cupón (falla interna). Reintentable.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
      x-audience: machine
      x-developer: true
  /v1/coupons/preview:
    post:
      operationId: previewCoupon
      x-required-scope: coupon:redeem
      x-error-codes:
        '400':
          - VALIDATION_ERROR
          - INVALID_JSON
          - BAD_REQUEST
          - INVALID_TOKEN
          - INVALID_COUPON
        '401':
          - UNAUTHORIZED
        '403':
          - FORBIDDEN
          - TENANT_MISMATCH
          - ENVIRONMENT_MISMATCH
        '404':
          - NOT_FOUND
        '500':
          - SERVER_ERROR
          - CAMPAIGN_ERROR
          - COUPON_ERROR
      tags:
        - coupons
      security:
        - bearerAuth: []
      summary: 'Previsualiza un cupón en el POS sin canjearlo (scope: coupon:redeem)'
      description: >-
        Lectura NO consumidora para el AVISO previo al canje. Devuelve oferta + `estado`
        (ok/pausada/archivada/cancelado/agotado) + reúsos, SIN auditoría (a diferencia del lookup). NO emite veredicto
        de fechas: la vigencia es autoritativa solo en redeem. Genuinidad inválida (token/HMAC/tenant/env) y campaña
        inexistente devuelven 4xx; los estados 'suaves' viajan en el 200.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                couponToken:
                  type: string
                  minLength: 1
                  description: >-
                    Token base64url del cupón a previsualizar. Body (no path/query) para mantenerlo fuera de access logs
                    — mismo patrón que redeem/lookup/fields.
                  example: >-
                    eyJ2IjoxLCJ0IjoiN2EzZThiMTItOWM0ZC00ZjVlLWE2YjctYzhkOWUwZjFhMmIzIiwiYyI6ImUxYzdmM2EwLTJiOWQtNGM1Zi04YTZlLTdkM2IxZjJjNGE1ZSIsImYiOjQyLCJzIjoiSzVMTXhOM3BRclN0N3ZXeFl6MUEyQjNDNEQ1RTZGNyJ9
              required:
                - couponToken
              additionalProperties: false
      responses:
        '200':
          description: Previsualización del cupón (aviso, no veredicto).
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    enum:
                      - ok
                      - paused
                      - archived
                      - canceled
                      - exhausted
                    description: >-
                      Estado del cupón para AVISO al cajero (no veredicto autoritativo). `ok` = canjeable según
                      estado/reúsos; `pausada`/`archivada` = la campaña no está activa; `cancelado` = el cupón fue
                      cancelado; `agotado` = ya no le quedan reúsos. NO refleja la vigencia por fechas — eso solo lo
                      resuelve el redeem.
                    example: ok
                  serial:
                    type: integer
                    description: Folio del cupón dentro de la campaña.
                    example: 42
                  campaignName:
                    type: string
                    description: Nombre legible de la campaña.
                    example: Promo Día del Padre 2026
                  campaignCouponMessage:
                    type: string
                    description: Mensaje promocional customer-facing de la campaña.
                    example: 3x2 en cafés - hasta el 30 de junio
                  products:
                    type: array
                    items:
                      type: object
                      properties:
                        pid:
                          type: string
                          description: UUID del producto.
                          example: b2d4e1c7-3a5f-4e6c-8b9d-1a2c3d4e5f60
                        name:
                          type: string
                          description: Nombre del producto.
                          example: Café especial 250g
                        quantity:
                          type: integer
                          exclusiveMinimum: 0
                          description: Unidades del producto en la promoción.
                          example: 3
                        price:
                          type: number
                          description: Precio unitario.
                          example: 180
                        moneda:
                          type: string
                          description: Código de moneda (ISO 4217 sugerido).
                          example: MXN
                        pais:
                          type: string
                          description: Código de país.
                          example: MX
                        category:
                          type: string
                          description: Categoría libre del producto.
                          example: bebidas-cafe
                      required:
                        - pid
                        - name
                        - quantity
                        - price
                        - moneda
                        - pais
                        - category
                    description: Productos incluidos en la promoción.
                  campaignListPrice:
                    type: number
                    description: Precio de lista del paquete.
                    example: 540
                  campaignFinalPrice:
                    type: number
                    description: Precio efectivo tras aplicar el cupón.
                    example: 360
                  legalText:
                    type: string
                    description: Texto legal anexo al cupón.
                    example: Promoción válida hasta agotar inventario. No acumulable con otras ofertas.
                  campaignStartDate:
                    type: string
                    description: Inicio de la vigencia de la campaña (solo para mostrar — el preview no emite veredicto de fechas).
                    example: '2026-06-01T00:00:00'
                  campaignEndDate:
                    type: string
                    description: Fin de la vigencia de la campaña (solo para mostrar — el preview no emite veredicto de fechas).
                    example: '2026-06-30T23:59:59'
                  timezone:
                    type: string
                    description: Zona horaria de la campaña (para formatear las fechas en el POS).
                    example: America/Mexico_City
                  campaignMaxUses:
                    type: integer
                    description: Cuántas veces el cupón puede canjearse en total.
                    example: 1
                  couponUses:
                    type: integer
                    description: Cuántas veces este cupón se ha canjeado hasta ahora.
                    example: 0
                required:
                  - status
                  - serial
        '400':
          description: >-
            `VALIDATION_ERROR` — El body no cumple el shape del request (Zod). El detalle viene en `error.issues`.
            `INVALID_JSON` — El body no es JSON válido. `BAD_REQUEST` — Request malformado: falta el body o un parámetro
            de path requerido. `INVALID_TOKEN` — El `couponToken` no se pudo interpretar (formato inválido o truncado).
            `INVALID_COUPON` — El cupón no es genuino (la firma HMAC no verifica) o no es operable. En superficies
            públicas este código también colapsa otros estados por anti-enumeración.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: '`UNAUTHORIZED` — Falta el header Authorization o la credencial es inválida/revocada.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: >-
            `FORBIDDEN` — La credencial es válida pero no porta el scope que la operación exige. `TENANT_MISMATCH` — El
            cupón pertenece a otro tenant: tu credencial no puede operarlo. `ENVIRONMENT_MISMATCH` — El cupón es de otro
            ambiente (live vs test) que tu credencial.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: >-
            `NOT_FOUND` — El recurso no existe para tu tenant/ambiente. Anti-enumeración: identificadores de otro tenant
            o malformados colapsan a este mismo código.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: >-
            `SERVER_ERROR` — Error interno no controlado. Reintentable. `CAMPAIGN_ERROR` — No se pudo leer o validar la
            campaña del cupón (falla interna). Reintentable. `COUPON_ERROR` — No se pudo leer o materializar el cupón
            (falla interna).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
      x-audience: machine
      x-developer: true
  /v1/coupons/lookup:
    post:
      operationId: lookupCoupon
      x-required-scope: coupon:read
      x-error-codes:
        '400':
          - VALIDATION_ERROR
          - INVALID_JSON
          - BAD_REQUEST
          - INVALID_TOKEN
          - INVALID_COUPON
        '401':
          - UNAUTHORIZED
        '403':
          - FORBIDDEN
          - TENANT_MISMATCH
          - ENVIRONMENT_MISMATCH
        '404':
          - NOT_FOUND
        '500':
          - SERVER_ERROR
          - CAMPAIGN_FETCH_ERROR
          - CAMPAIGN_DATA_CORRUPT
          - COUPON_FETCH_ERROR
      tags:
        - coupons
      security:
        - bearerAuth: []
      summary: 'Detalle administrativo del cupón con historial de auditoría (scope: coupon:read)'
      requestBody:
        content:
          application/json:
            schema:
              anyOf:
                - type: object
                  properties:
                    couponToken:
                      type: string
                      minLength: 1
                      description: >-
                        Token base64url del cupón. Body en lugar de path param para que el token no aparezca en access
                        logs server-side (mismo patrón que redeem y fields).
                      example: >-
                        eyJ2IjoxLCJ0IjoiN2EzZThiMTItOWM0ZC00ZjVlLWE2YjctYzhkOWUwZjFhMmIzIiwiYyI6ImUxYzdmM2EwLTJiOWQtNGM1Zi04YTZlLTdkM2IxZjJjNGE1ZSIsImYiOjQyLCJzIjoiSzVMTXhOM3BRclN0N3ZXeFl6MUEyQjNDNEQ1RTZGNyJ9
                  required:
                    - couponToken
                  additionalProperties: false
                - type: object
                  properties:
                    campaignId:
                      type: string
                      minLength: 1
                      description: ULID de la campaña a la que pertenece el cupón.
                      example: 01J9Z3K7Q8R2T4V6X8Y0A1B2C3
                    serial:
                      type: integer
                      minimum: 0
                      description: Folio del cupón dentro de la campaña (el número impreso en el cupón).
                      example: 42
                  required:
                    - campaignId
                    - serial
                  additionalProperties: false
      responses:
        '200':
          description: Detalle del cupón.
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    description: Mensaje descriptivo del estado del cupón.
                    example: Cupón válido
                  campaignId:
                    type: string
                    description: Identificador de la campaña.
                    example: e1c7f3a0-2b9d-4c5f-8a6e-7d3b1f2c4a5e
                  serial:
                    type: integer
                    description: Folio del cupón dentro de la campaña.
                    example: 42
                  campaignName:
                    type: string
                    description: Nombre legible de la campaña.
                    example: Promo Día del Padre 2026
                  campaignCouponMessage:
                    type: string
                    description: Mensaje promocional customer-facing de la campaña (mismo que muestra `getMyCoupon`).
                    example: 3x2 en cafés - hasta el 30 de junio
                  campaignListPrice:
                    type: number
                    description: Precio de lista del paquete.
                    example: 540
                  campaignFinalPrice:
                    type: number
                    description: Precio efectivo tras aplicar el cupón.
                    example: 360
                  campaignStartDate:
                    type: string
                    description: Inicio de la validez de la campaña.
                    example: '2026-06-01T00:00:00'
                  campaignEndDate:
                    type: string
                    description: Fin de la validez de la campaña.
                    example: '2026-06-30T23:59:59'
                  campaignStatus:
                    type: string
                    description: Estado actual de la campaña.
                    example: active
                  products:
                    type: array
                    items:
                      type: object
                      properties:
                        pid:
                          type: string
                          description: UUID del producto.
                          example: b2d4e1c7-3a5f-4e6c-8b9d-1a2c3d4e5f60
                        name:
                          type: string
                          description: Nombre del producto.
                          example: Café especial 250g
                        quantity:
                          type: integer
                          exclusiveMinimum: 0
                          description: Unidades del producto en la promoción.
                          example: 3
                        price:
                          type: number
                          description: Precio unitario.
                          example: 180
                        moneda:
                          type: string
                          description: Código de moneda (ISO 4217 sugerido).
                          example: MXN
                        pais:
                          type: string
                          description: Código de país.
                          example: MX
                        category:
                          type: string
                          description: Categoría libre del producto.
                          example: bebidas-cafe
                      required:
                        - pid
                        - name
                        - quantity
                        - price
                        - moneda
                        - pais
                        - category
                    description: Productos incluidos en la promoción.
                  legalText:
                    type: string
                    description: Texto legal anexo al cupón.
                    example: Promoción válida hasta agotar inventario. No acumulable con otras ofertas.
                  redeemed:
                    type: boolean
                    description: '`true` si el cupón fue canjeado al menos una vez.'
                    example: false
                  campaignMaxUses:
                    type: integer
                    description: Cuántas veces el cupón puede canjearse en total.
                    example: 1
                  couponUses:
                    type: integer
                    description: Cuántas veces este cupón se ha canjeado hasta ahora.
                    example: 0
                  createdAt:
                    type: string
                    description: Instante de emisión del cupón (ISO 8601 UTC).
                    example: '2026-06-01T12:00:00.000Z'
                  canceled:
                    type: boolean
                    description: '`true` si el cupón fue cancelado por soporte.'
                    example: false
                  canceledBy:
                    type: string
                    description: Principal que canceló el cupón (email del admin | `apikey:<keyHash>`). Ausente si no se canceló.
                    example: soporte@tenant.com
                  canceledAt:
                    type: string
                    description: Instante de cancelación del cupón (ISO 8601 UTC). Ausente si no se canceló.
                    example: '2026-06-05T09:30:00.000Z'
                  redeemedAt:
                    type: array
                    items:
                      type: string
                    description: Timestamps ISO 8601 UTC de cada canje exitoso.
                    example:
                      - '2026-06-02T15:04:00.000Z'
                  redeemedBy:
                    type: array
                    items:
                      type: string
                    description: Principal de cada canje (email | `apikey:<keyHash>`), uno por canje.
                    example:
                      - apikey:3f9a...e21c
                  redeemedByDevice:
                    type: array
                    items:
                      type: string
                    description: Dispositivo de cada canje (placeholder `desconocido` hoy).
                    example:
                      - desconocido
                  redeemedFrom:
                    type: array
                    items:
                      type: string
                    description: Origen/lugar de cada canje (placeholder `desconocido` hoy).
                    example:
                      - desconocido
                required:
                  - message
                  - campaignId
                  - serial
                  - redeemed
                  - canceled
        '400':
          description: >-
            `VALIDATION_ERROR` — El body no cumple el shape del request (Zod). El detalle viene en `error.issues`.
            `INVALID_JSON` — El body no es JSON válido. `BAD_REQUEST` — Request malformado: falta el body o un parámetro
            de path requerido. `INVALID_TOKEN` — El `couponToken` no se pudo interpretar (formato inválido o truncado).
            `INVALID_COUPON` — El cupón no es genuino (la firma HMAC no verifica) o no es operable. En superficies
            públicas este código también colapsa otros estados por anti-enumeración.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: '`UNAUTHORIZED` — Falta el header Authorization o la credencial es inválida/revocada.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: >-
            `FORBIDDEN` — La credencial es válida pero no porta el scope que la operación exige. `TENANT_MISMATCH` — El
            cupón pertenece a otro tenant: tu credencial no puede operarlo. `ENVIRONMENT_MISMATCH` — El cupón es de otro
            ambiente (live vs test) que tu credencial.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: >-
            `NOT_FOUND` — El recurso no existe para tu tenant/ambiente. Anti-enumeración: identificadores de otro tenant
            o malformados colapsan a este mismo código.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: >-
            `SERVER_ERROR` — Error interno no controlado. Reintentable. `CAMPAIGN_FETCH_ERROR` — No se pudo leer la
            campaña del cupón (falla interna). `CAMPAIGN_DATA_CORRUPT` — La campaña existe pero sus datos están
            corruptos (falta el material de verificación). Contacta soporte. `COUPON_FETCH_ERROR` — No se pudo leer el
            registro del cupón (falla interna).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
      x-audience: machine
      x-developer: true
  /v1/coupons/cancel:
    post:
      operationId: cancelCoupon
      x-required-scope: coupon:cancel
      x-error-codes:
        '400':
          - VALIDATION_ERROR
          - INVALID_JSON
          - BAD_REQUEST
          - INVALID_TOKEN
          - INVALID_COUPON
        '401':
          - UNAUTHORIZED
        '403':
          - FORBIDDEN
          - TENANT_MISMATCH
          - ENVIRONMENT_MISMATCH
        '404':
          - NOT_FOUND
        '500':
          - SERVER_ERROR
          - CAMPAIGN_FETCH_ERROR
          - CAMPAIGN_DATA_CORRUPT
          - COUPON_FETCH_ERROR
          - COUPON_CANCEL_ERROR
      tags:
        - coupons
      security:
        - bearerAuth: []
      summary: 'Cancela un cupón — herramienta de soporte/auditoría. Idempotente. (scope: coupon:cancel)'
      requestBody:
        content:
          application/json:
            schema:
              anyOf:
                - type: object
                  properties:
                    couponToken:
                      type: string
                      minLength: 1
                      description: >-
                        Token base64url del cupón. Body en lugar de path param para que el token no aparezca en access
                        logs server-side (mismo patrón que redeem y fields).
                      example: >-
                        eyJ2IjoxLCJ0IjoiN2EzZThiMTItOWM0ZC00ZjVlLWE2YjctYzhkOWUwZjFhMmIzIiwiYyI6ImUxYzdmM2EwLTJiOWQtNGM1Zi04YTZlLTdkM2IxZjJjNGE1ZSIsImYiOjQyLCJzIjoiSzVMTXhOM3BRclN0N3ZXeFl6MUEyQjNDNEQ1RTZGNyJ9
                  required:
                    - couponToken
                  additionalProperties: false
                - type: object
                  properties:
                    campaignId:
                      type: string
                      minLength: 1
                      description: ULID de la campaña a la que pertenece el cupón.
                      example: 01J9Z3K7Q8R2T4V6X8Y0A1B2C3
                    serial:
                      type: integer
                      minimum: 0
                      description: Folio del cupón dentro de la campaña (el número impreso en el cupón).
                      example: 42
                  required:
                    - campaignId
                    - serial
                  additionalProperties: false
      responses:
        '200':
          description: Cupón cancelado (o ya estaba cancelado — idempotente).
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    description: Mensaje descriptivo del resultado.
                    example: Cupón cancelado
                  campaignId:
                    type: string
                    description: Identificador de la campaña.
                    example: 01J9ZSTF8E0AB2CDE3FGH4JKMN
                  serial:
                    type: integer
                    description: Folio del cupón dentro de la campaña.
                    example: 42
                  canceled:
                    type: boolean
                    description: '`true` tras la cancelación (siempre `true` en la respuesta exitosa).'
                    example: true
                  canceledBy:
                    type: string
                    description: Principal que canceló el cupón (email del admin | `apikey:<keyHash>`).
                    example: soporte@tenant.com
                  canceledAt:
                    type: string
                    description: Instante de cancelación del cupón (ISO 8601 UTC).
                    example: '2026-06-05T09:30:00.000Z'
                required:
                  - message
                  - campaignId
                  - serial
                  - canceled
        '400':
          description: >-
            `VALIDATION_ERROR` — El body no cumple el shape del request (Zod). El detalle viene en `error.issues`.
            `INVALID_JSON` — El body no es JSON válido. `BAD_REQUEST` — Request malformado: falta el body o un parámetro
            de path requerido. `INVALID_TOKEN` — El `couponToken` no se pudo interpretar (formato inválido o truncado).
            `INVALID_COUPON` — El cupón no es genuino (la firma HMAC no verifica) o no es operable. En superficies
            públicas este código también colapsa otros estados por anti-enumeración.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: '`UNAUTHORIZED` — Falta el header Authorization o la credencial es inválida/revocada.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: >-
            `FORBIDDEN` — La credencial es válida pero no porta el scope que la operación exige. `TENANT_MISMATCH` — El
            cupón pertenece a otro tenant: tu credencial no puede operarlo. `ENVIRONMENT_MISMATCH` — El cupón es de otro
            ambiente (live vs test) que tu credencial.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: >-
            `NOT_FOUND` — El recurso no existe para tu tenant/ambiente. Anti-enumeración: identificadores de otro tenant
            o malformados colapsan a este mismo código.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: >-
            `SERVER_ERROR` — Error interno no controlado. Reintentable. `CAMPAIGN_FETCH_ERROR` — No se pudo leer la
            campaña del cupón (falla interna). `CAMPAIGN_DATA_CORRUPT` — La campaña existe pero sus datos están
            corruptos (falta el material de verificación). Contacta soporte. `COUPON_FETCH_ERROR` — No se pudo leer el
            registro del cupón (falla interna). `COUPON_CANCEL_ERROR` — La cancelación falló al escribir (falla
            interna). Reintentable.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
      x-audience: machine
      x-developer: true
  /v1/coupon-view:
    post:
      operationId: getMyCoupon
      x-audience: public
      x-developer: true
      x-error-codes:
        '400':
          - VALIDATION_ERROR
          - INVALID_JSON
          - BAD_REQUEST
          - INVALID_COUPON
        '404':
          - NOT_FOUND
        '500':
          - SERVER_ERROR
          - CAMPAIGN_ERROR
          - COUPON_ERROR
      tags:
        - coupons
      security: []
      summary: Vista pública del cupón para el cliente final (QR + info de campaña)
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                couponToken:
                  type: string
                  minLength: 1
                  description: >-
                    Token base64url del cupón a mostrar. Body en lugar de path param para que el token no aparezca en
                    access logs (combinado con el fragment `/m/c#token` del frontend, el token nunca toca el server vía
                    URL).
                  example: >-
                    eyJ2IjoxLCJ0IjoiN2EzZThiMTItOWM0ZC00ZjVlLWE2YjctYzhkOWUwZjFhMmIzIiwiYyI6ImUxYzdmM2EwLTJiOWQtNGM1Zi04YTZlLTdkM2IxZjJjNGE1ZSIsImYiOjQyLCJzIjoiSzVMTXhOM3BRclN0N3ZXeFl6MUEyQjNDNEQ1RTZGNyJ9
                action:
                  type: string
                  enum:
                    - share
                  description: >-
                    Marca el request como un beacon de compartir (no una consulta del cupón). Solo se registra el
                    evento.
                  example: share
                method:
                  type: string
                  enum:
                    - web-share
                    - copy
                    - whatsapp
                    - email
                  description: Vía por la que el usuario compartió (solo relevante con action:share).
                  example: whatsapp
              required:
                - couponToken
              additionalProperties: false
      responses:
        '200':
          description: Datos de presentación del cupón.
          content:
            application/json:
              schema:
                type: object
                properties:
                  qrDataurl:
                    type: string
                    description: QR del cupón como data URL (`data:image/png;base64,...`) listo para renderizar en el cliente.
                    example: >-
                      data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==
                  campaignId:
                    type: string
                    description: Identificador de la campaña.
                    example: e1c7f3a0-2b9d-4c5f-8a6e-7d3b1f2c4a5e
                  serial:
                    type: integer
                    description: Folio del cupón dentro de la campaña.
                    example: 42
                  uses:
                    type: integer
                    description: Cuántas veces se ha canjeado este cupón.
                    example: 0
                  redeemed:
                    type: boolean
                    description: '`true` si el cupón fue canjeado al menos una vez.'
                    example: false
                  canceled:
                    type: boolean
                    description: >-
                      `true` si un admin revocó el cupón (coupon:cancel). La vista pública lo muestra inutilizable con
                      sello 'CANCELADO'.
                    example: false
                  campaignName:
                    type: string
                    description: Nombre legible de la campaña (denormalizado para evitar otro fetch).
                    example: Promo Día del Padre 2026
                  campaignCouponMessage:
                    type: string
                    description: Mensaje promocional customer-facing de la campaña.
                    example: 3x2 en cafés - hasta el 30 de junio
                  campaignOfferSubtitle:
                    type: string
                    description: Subtítulo corto bajo el mensaje del cupón (línea secundaria, opcional).
                    example: Solo en sucursal · presenta tu QR en caja
                  campaignListPrice:
                    type: number
                    description: Precio de lista para que el cliente final vea la oferta antes del canje.
                    example: 540
                  campaignFinalPrice:
                    type: number
                    description: Precio que pagará el cliente final tras aplicar el cupón.
                    example: 360
                  campaignStartDate:
                    type: string
                    description: Inicio de la validez de la campaña (wall-clock en la `timezone` de la campaña).
                    example: '2026-06-01T00:00:00'
                  campaignEndDate:
                    type: string
                    description: Fin de la validez de la campaña.
                    example: '2026-06-30T23:59:59'
                  campaignStatus:
                    type: string
                    description: Estado actual de la campaña (`activa`, `suspendida`, etc.).
                    example: active
                  products:
                    type: array
                    items:
                      type: object
                      properties:
                        pid:
                          type: string
                          description: UUID del producto.
                          example: b2d4e1c7-3a5f-4e6c-8b9d-1a2c3d4e5f60
                        name:
                          type: string
                          description: Nombre del producto.
                          example: Café especial 250g
                        quantity:
                          type: integer
                          exclusiveMinimum: 0
                          description: Unidades del producto en la promoción.
                          example: 3
                        price:
                          type: number
                          description: Precio unitario.
                          example: 180
                        moneda:
                          type: string
                          description: Código de moneda (ISO 4217 sugerido).
                          example: MXN
                        pais:
                          type: string
                          description: Código de país.
                          example: MX
                        category:
                          type: string
                          description: Categoría libre del producto.
                          example: bebidas-cafe
                      required:
                        - pid
                        - name
                        - quantity
                        - price
                        - moneda
                        - pais
                        - category
                    description: Productos incluidos en la promoción.
                  legalText:
                    type: string
                    description: Texto legal anexo (términos, condiciones, restricciones).
                    example: >-
                      Promoción válida hasta agotar inventario. No acumulable con otras ofertas. Aplica solo en
                      sucursales participantes.
                  campaignMaxUses:
                    type: integer
                    description: Cuántas veces este cupón puede canjearse en total.
                    example: 1
                required:
                  - qrDataurl
                  - campaignId
                  - serial
                  - uses
                  - redeemed
                  - canceled
        '400':
          description: >-
            `VALIDATION_ERROR` — El body no cumple el shape del request (Zod). El detalle viene en `error.issues`.
            `INVALID_JSON` — El body no es JSON válido. `BAD_REQUEST` — Request malformado: falta el body o un parámetro
            de path requerido. `INVALID_COUPON` — El cupón no es genuino (la firma HMAC no verifica) o no es operable.
            En superficies públicas este código también colapsa otros estados por anti-enumeración.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: >-
            `NOT_FOUND` — El recurso no existe para tu tenant/ambiente. Anti-enumeración: identificadores de otro tenant
            o malformados colapsan a este mismo código.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: >-
            `SERVER_ERROR` — Error interno no controlado. Reintentable. `CAMPAIGN_ERROR` — No se pudo leer o validar la
            campaña del cupón (falla interna). Reintentable. `COUPON_ERROR` — No se pudo leer o materializar el cupón
            (falla interna).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /v1/reports:
    get:
      operationId: listReportes
      x-required-scope: report:read
      x-error-codes:
        '400':
          - BAD_REQUEST
        '401':
          - UNAUTHORIZED
        '403':
          - FORBIDDEN
        '500':
          - SERVER_ERROR
      tags:
        - reportes
      security:
        - bearerAuth: []
      summary: 'Lista los jobs de reporte del tenant (scope: report:read)'
      description: >-
        Devuelve los jobs del tenant (newest-first), con su `status` derivado (staleness aplicada). Filtra por SCOPE
        accesible: un job de leads solo aparece para quien tiene `report:leads` — los params de un export de PII no se
        exponen a quien no podría descargarlo. NO incluye url/error: eso se obtiene por GET /v1/reports/{jobId}.
      responses:
        '200':
          description: Lista de jobs (resumen).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ListReportesResponseData'
        '400':
          description: '`BAD_REQUEST` — Request malformado: falta el body o un parámetro de path requerido.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: '`UNAUTHORIZED` — Falta el header Authorization o la credencial es inválida/revocada.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: '`FORBIDDEN` — La credencial es válida pero no porta el scope que la operación exige.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: '`SERVER_ERROR` — Error interno no controlado. Reintentable.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
      x-audience: machine
      x-developer: true
    post:
      operationId: createReporte
      x-required-scope: report:read
      x-error-codes:
        '400':
          - VALIDATION_ERROR
          - INVALID_JSON
          - BAD_REQUEST
          - UNSUPPORTED_REPORT_TYPE
        '401':
          - UNAUTHORIZED
        '403':
          - FORBIDDEN
        '500':
          - SERVER_ERROR
      tags:
        - reportes
      security:
        - bearerAuth: []
      summary: 'Solicita un reporte CSV (leads exige report:leads) (scope: report:read)'
      description: >-
        Crea un job de export y dispara su generación async. Responde de inmediato con el `jobId` y `status: pending` —
        NO los datos. El `params` se valida según el `type` (campaigns: rango de creación + estados; inventory: rango de
        fechas requerido + campaña/sucursal; leads: campaña requerida o 'todas'). El scope se refina por tipo: `leads`
        exige `report:leads`.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                type:
                  type: string
                  enum:
                    - campaigns
                    - inventory
                    - leads
                  description: Tipo de reporte. `leads` exige el scope `report:leads` (human-only).
                  example: inventory
                params:
                  description: >-
                    Params según el tipo — campanas: `{desde?, hasta?, estados?}`; inventario: `{desde, hasta,
                    campaignId?, storeKeyHash?}` (rango requerido); leads: `{campaignId}` (ULID o el literal 'todas').
                  example:
                    desde: '2026-06-01'
                    hasta: '2026-06-30'
              required:
                - type
              additionalProperties: false
      responses:
        '200':
          description: Job creado; usar el jobId para poll/descarga.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CreateReporteResponseData'
        '400':
          description: >-
            `VALIDATION_ERROR` — El body no cumple el shape del request (Zod). El detalle viene en `error.issues`.
            `INVALID_JSON` — El body no es JSON válido. `BAD_REQUEST` — Request malformado: falta el body o un parámetro
            de path requerido. `UNSUPPORTED_REPORT_TYPE` — El `type` de reporte solicitado no existe.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: '`UNAUTHORIZED` — Falta el header Authorization o la credencial es inválida/revocada.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: '`FORBIDDEN` — La credencial es válida pero no porta el scope que la operación exige.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: '`SERVER_ERROR` — Error interno no controlado. Reintentable.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
      x-audience: machine
      x-developer: true
  /v1/reports/{jobId}:
    get:
      operationId: getReporte
      x-required-scope: report:read
      x-error-codes:
        '400':
          - BAD_REQUEST
        '401':
          - UNAUTHORIZED
        '403':
          - FORBIDDEN
        '404':
          - NOT_FOUND
        '500':
          - SERVER_ERROR
      tags:
        - reportes
      security:
        - bearerAuth: []
      summary: 'Estado de un job de reporte + presign-on-read si está listo (leads exige report:leads) (scope: report:read)'
      description: >-
        Devuelve el `status` del job. Si `ready`, incluye un `url` presigned de TTL corto para descargar el CSV (se
        re-firma en cada GET — no cachear). Si `failed`, incluye `error`. El `failed` puede ser DERIVADO por staleness
        (job colgado >~15 min). Ownership: un job de otro tenant devuelve 404 (no 403, anti-enumeration). El scope se
        refina al tipo del job (descargar un reporte de leads exige `report:leads`).
      parameters:
        - schema:
            type: string
            pattern: ^[0-9A-HJKMNP-TV-Z]{26}$
            description: ID del job (ULID), devuelto por POST /v1/reports.
            example: 01J9ZX8K3M4N5P6Q7R8S9T0V1W
          required: true
          name: jobId
          in: path
      responses:
        '200':
          description: Estado del job (+ url si ready, + error si failed).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ReporteEstadoResponseData'
        '400':
          description: '`BAD_REQUEST` — Request malformado: falta el body o un parámetro de path requerido.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: '`UNAUTHORIZED` — Falta el header Authorization o la credencial es inválida/revocada.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: '`FORBIDDEN` — La credencial es válida pero no porta el scope que la operación exige.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: >-
            `NOT_FOUND` — El recurso no existe para tu tenant/ambiente. Anti-enumeración: identificadores de otro tenant
            o malformados colapsan a este mismo código.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: '`SERVER_ERROR` — Error interno no controlado. Reintentable.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
      x-audience: machine
      x-developer: true
webhooks:
  coupon.redeemed:
    post:
      summary: Canjeo exitoso
      description: >-
        Se entrega con POST a la URL del webhook suscrito a `coupon.redeemed`. Entrega best-effort SIN retry automático:
        responde 2xx rápido (<8s) y procesa async; las entregas fallidas quedan consultables en el dashboard
        (deliveries) y el estado siempre es re-derivable por la API (lookup/reportes).
      parameters:
        - schema:
            type: string
            description: >-
              Firma HMAC estilo Stripe: `t=<unix>,v1=<hmacSha256(secret, t + '.' + body)>`. Verifica contra el signing
              secret del webhook (GET /v1/webhooks/{id}/secret) y rechaza timestamps viejos (replay).
            example: t=1751826343,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
          required: true
          name: X-Cupontli-Signature
          in: header
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEventPayload'
      responses:
        '200':
          description: >-
            Cualquier 2xx confirma la recepción. Otro status (o timeout de 8s) registra la entrega como fallida — no hay
            retry.
  coupon.minted:
    post:
      summary: Cupón minteado
      description: >-
        Se entrega con POST a la URL del webhook suscrito a `coupon.minted`. Entrega best-effort SIN retry automático:
        responde 2xx rápido (<8s) y procesa async; las entregas fallidas quedan consultables en el dashboard
        (deliveries) y el estado siempre es re-derivable por la API (lookup/reportes).
      parameters:
        - schema:
            type: string
            description: >-
              Firma HMAC estilo Stripe: `t=<unix>,v1=<hmacSha256(secret, t + '.' + body)>`. Verifica contra el signing
              secret del webhook (GET /v1/webhooks/{id}/secret) y rechaza timestamps viejos (replay).
            example: t=1751826343,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
          required: true
          name: X-Cupontli-Signature
          in: header
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEventPayload'
      responses:
        '200':
          description: >-
            Cualquier 2xx confirma la recepción. Otro status (o timeout de 8s) registra la entrega como fallida — no hay
            retry.
  coupon.minted.bulk:
    post:
      summary: Minteo masivo
      description: >-
        Se entrega con POST a la URL del webhook suscrito a `coupon.minted.bulk`. Entrega best-effort SIN retry
        automático: responde 2xx rápido (<8s) y procesa async; las entregas fallidas quedan consultables en el dashboard
        (deliveries) y el estado siempre es re-derivable por la API (lookup/reportes).
      parameters:
        - schema:
            type: string
            description: >-
              Firma HMAC estilo Stripe: `t=<unix>,v1=<hmacSha256(secret, t + '.' + body)>`. Verifica contra el signing
              secret del webhook (GET /v1/webhooks/{id}/secret) y rechaza timestamps viejos (replay).
            example: t=1751826343,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
          required: true
          name: X-Cupontli-Signature
          in: header
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEventPayload'
      responses:
        '200':
          description: >-
            Cualquier 2xx confirma la recepción. Otro status (o timeout de 8s) registra la entrega como fallida — no hay
            retry.
  coupon.canceled:
    post:
      summary: Cupón cancelado
      description: >-
        Se entrega con POST a la URL del webhook suscrito a `coupon.canceled`. Entrega best-effort SIN retry automático:
        responde 2xx rápido (<8s) y procesa async; las entregas fallidas quedan consultables en el dashboard
        (deliveries) y el estado siempre es re-derivable por la API (lookup/reportes).
      parameters:
        - schema:
            type: string
            description: >-
              Firma HMAC estilo Stripe: `t=<unix>,v1=<hmacSha256(secret, t + '.' + body)>`. Verifica contra el signing
              secret del webhook (GET /v1/webhooks/{id}/secret) y rechaza timestamps viejos (replay).
            example: t=1751826343,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
          required: true
          name: X-Cupontli-Signature
          in: header
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEventPayload'
      responses:
        '200':
          description: >-
            Cualquier 2xx confirma la recepción. Otro status (o timeout de 8s) registra la entrega como fallida — no hay
            retry.
  campaign.exhausted:
    post:
      summary: Campaña agotada
      description: >-
        Se entrega con POST a la URL del webhook suscrito a `campaign.exhausted`. Entrega best-effort SIN retry
        automático: responde 2xx rápido (<8s) y procesa async; las entregas fallidas quedan consultables en el dashboard
        (deliveries) y el estado siempre es re-derivable por la API (lookup/reportes).
      parameters:
        - schema:
            type: string
            description: >-
              Firma HMAC estilo Stripe: `t=<unix>,v1=<hmacSha256(secret, t + '.' + body)>`. Verifica contra el signing
              secret del webhook (GET /v1/webhooks/{id}/secret) y rechaza timestamps viejos (replay).
            example: t=1751826343,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
          required: true
          name: X-Cupontli-Signature
          in: header
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEventPayload'
      responses:
        '200':
          description: >-
            Cualquier 2xx confirma la recepción. Otro status (o timeout de 8s) registra la entrega como fallida — no hay
            retry.
  coupon.redemption_rejected:
    post:
      summary: Validación rechazada
      description: >-
        Se entrega con POST a la URL del webhook suscrito a `coupon.redemption_rejected`. Entrega best-effort SIN retry
        automático: responde 2xx rápido (<8s) y procesa async; las entregas fallidas quedan consultables en el dashboard
        (deliveries) y el estado siempre es re-derivable por la API (lookup/reportes).
      parameters:
        - schema:
            type: string
            description: >-
              Firma HMAC estilo Stripe: `t=<unix>,v1=<hmacSha256(secret, t + '.' + body)>`. Verifica contra el signing
              secret del webhook (GET /v1/webhooks/{id}/secret) y rechaza timestamps viejos (replay).
            example: t=1751826343,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
          required: true
          name: X-Cupontli-Signature
          in: header
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEventPayload'
      responses:
        '200':
          description: >-
            Cualquier 2xx confirma la recepción. Otro status (o timeout de 8s) registra la entrega como fallida — no hay
            retry.
  campaign.created:
    post:
      summary: Campaña creada
      description: >-
        Se entrega con POST a la URL del webhook suscrito a `campaign.created`. Entrega best-effort SIN retry
        automático: responde 2xx rápido (<8s) y procesa async; las entregas fallidas quedan consultables en el dashboard
        (deliveries) y el estado siempre es re-derivable por la API (lookup/reportes).
      parameters:
        - schema:
            type: string
            description: >-
              Firma HMAC estilo Stripe: `t=<unix>,v1=<hmacSha256(secret, t + '.' + body)>`. Verifica contra el signing
              secret del webhook (GET /v1/webhooks/{id}/secret) y rechaza timestamps viejos (replay).
            example: t=1751826343,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
          required: true
          name: X-Cupontli-Signature
          in: header
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEventPayload'
      responses:
        '200':
          description: >-
            Cualquier 2xx confirma la recepción. Otro status (o timeout de 8s) registra la entrega como fallida — no hay
            retry.
  campaign.updated:
    post:
      summary: Campaña actualizada
      description: >-
        Se entrega con POST a la URL del webhook suscrito a `campaign.updated`. Entrega best-effort SIN retry
        automático: responde 2xx rápido (<8s) y procesa async; las entregas fallidas quedan consultables en el dashboard
        (deliveries) y el estado siempre es re-derivable por la API (lookup/reportes).
      parameters:
        - schema:
            type: string
            description: >-
              Firma HMAC estilo Stripe: `t=<unix>,v1=<hmacSha256(secret, t + '.' + body)>`. Verifica contra el signing
              secret del webhook (GET /v1/webhooks/{id}/secret) y rechaza timestamps viejos (replay).
            example: t=1751826343,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
          required: true
          name: X-Cupontli-Signature
          in: header
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEventPayload'
      responses:
        '200':
          description: >-
            Cualquier 2xx confirma la recepción. Otro status (o timeout de 8s) registra la entrega como fallida — no hay
            retry.
  campaign.activated:
    post:
      summary: Campaña activada
      description: >-
        Se entrega con POST a la URL del webhook suscrito a `campaign.activated`. Entrega best-effort SIN retry
        automático: responde 2xx rápido (<8s) y procesa async; las entregas fallidas quedan consultables en el dashboard
        (deliveries) y el estado siempre es re-derivable por la API (lookup/reportes).
      parameters:
        - schema:
            type: string
            description: >-
              Firma HMAC estilo Stripe: `t=<unix>,v1=<hmacSha256(secret, t + '.' + body)>`. Verifica contra el signing
              secret del webhook (GET /v1/webhooks/{id}/secret) y rechaza timestamps viejos (replay).
            example: t=1751826343,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
          required: true
          name: X-Cupontli-Signature
          in: header
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEventPayload'
      responses:
        '200':
          description: >-
            Cualquier 2xx confirma la recepción. Otro status (o timeout de 8s) registra la entrega como fallida — no hay
            retry.
  campaign.paused:
    post:
      summary: Campaña pausada
      description: >-
        Se entrega con POST a la URL del webhook suscrito a `campaign.paused`. Entrega best-effort SIN retry automático:
        responde 2xx rápido (<8s) y procesa async; las entregas fallidas quedan consultables en el dashboard
        (deliveries) y el estado siempre es re-derivable por la API (lookup/reportes).
      parameters:
        - schema:
            type: string
            description: >-
              Firma HMAC estilo Stripe: `t=<unix>,v1=<hmacSha256(secret, t + '.' + body)>`. Verifica contra el signing
              secret del webhook (GET /v1/webhooks/{id}/secret) y rechaza timestamps viejos (replay).
            example: t=1751826343,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
          required: true
          name: X-Cupontli-Signature
          in: header
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEventPayload'
      responses:
        '200':
          description: >-
            Cualquier 2xx confirma la recepción. Otro status (o timeout de 8s) registra la entrega como fallida — no hay
            retry.
  campaign.resumed:
    post:
      summary: Campaña reanudada
      description: >-
        Se entrega con POST a la URL del webhook suscrito a `campaign.resumed`. Entrega best-effort SIN retry
        automático: responde 2xx rápido (<8s) y procesa async; las entregas fallidas quedan consultables en el dashboard
        (deliveries) y el estado siempre es re-derivable por la API (lookup/reportes).
      parameters:
        - schema:
            type: string
            description: >-
              Firma HMAC estilo Stripe: `t=<unix>,v1=<hmacSha256(secret, t + '.' + body)>`. Verifica contra el signing
              secret del webhook (GET /v1/webhooks/{id}/secret) y rechaza timestamps viejos (replay).
            example: t=1751826343,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
          required: true
          name: X-Cupontli-Signature
          in: header
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEventPayload'
      responses:
        '200':
          description: >-
            Cualquier 2xx confirma la recepción. Otro status (o timeout de 8s) registra la entrega como fallida — no hay
            retry.
  campaign.archived:
    post:
      summary: Campaña archivada
      description: >-
        Se entrega con POST a la URL del webhook suscrito a `campaign.archived`. Entrega best-effort SIN retry
        automático: responde 2xx rápido (<8s) y procesa async; las entregas fallidas quedan consultables en el dashboard
        (deliveries) y el estado siempre es re-derivable por la API (lookup/reportes).
      parameters:
        - schema:
            type: string
            description: >-
              Firma HMAC estilo Stripe: `t=<unix>,v1=<hmacSha256(secret, t + '.' + body)>`. Verifica contra el signing
              secret del webhook (GET /v1/webhooks/{id}/secret) y rechaza timestamps viejos (replay).
            example: t=1751826343,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
          required: true
          name: X-Cupontli-Signature
          in: header
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEventPayload'
      responses:
        '200':
          description: >-
            Cualquier 2xx confirma la recepción. Otro status (o timeout de 8s) registra la entrega como fallida — no hay
            retry.
  coupon.email_delivered:
    post:
      summary: Correo entregado
      description: >-
        Se entrega con POST a la URL del webhook suscrito a `coupon.email_delivered`. Entrega best-effort SIN retry
        automático: responde 2xx rápido (<8s) y procesa async; las entregas fallidas quedan consultables en el dashboard
        (deliveries) y el estado siempre es re-derivable por la API (lookup/reportes). ⚠️ Evento de ALTO VOLUMEN
        (telemetría).
      parameters:
        - schema:
            type: string
            description: >-
              Firma HMAC estilo Stripe: `t=<unix>,v1=<hmacSha256(secret, t + '.' + body)>`. Verifica contra el signing
              secret del webhook (GET /v1/webhooks/{id}/secret) y rechaza timestamps viejos (replay).
            example: t=1751826343,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
          required: true
          name: X-Cupontli-Signature
          in: header
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEventPayload'
      responses:
        '200':
          description: >-
            Cualquier 2xx confirma la recepción. Otro status (o timeout de 8s) registra la entrega como fallida — no hay
            retry.
  coupon.email_bounced:
    post:
      summary: Correo rebotó
      description: >-
        Se entrega con POST a la URL del webhook suscrito a `coupon.email_bounced`. Entrega best-effort SIN retry
        automático: responde 2xx rápido (<8s) y procesa async; las entregas fallidas quedan consultables en el dashboard
        (deliveries) y el estado siempre es re-derivable por la API (lookup/reportes). ⚠️ Evento de ALTO VOLUMEN
        (telemetría).
      parameters:
        - schema:
            type: string
            description: >-
              Firma HMAC estilo Stripe: `t=<unix>,v1=<hmacSha256(secret, t + '.' + body)>`. Verifica contra el signing
              secret del webhook (GET /v1/webhooks/{id}/secret) y rechaza timestamps viejos (replay).
            example: t=1751826343,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
          required: true
          name: X-Cupontli-Signature
          in: header
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEventPayload'
      responses:
        '200':
          description: >-
            Cualquier 2xx confirma la recepción. Otro status (o timeout de 8s) registra la entrega como fallida — no hay
            retry.
  coupon.email_complained:
    post:
      summary: Marcado como spam
      description: >-
        Se entrega con POST a la URL del webhook suscrito a `coupon.email_complained`. Entrega best-effort SIN retry
        automático: responde 2xx rápido (<8s) y procesa async; las entregas fallidas quedan consultables en el dashboard
        (deliveries) y el estado siempre es re-derivable por la API (lookup/reportes). ⚠️ Evento de ALTO VOLUMEN
        (telemetría).
      parameters:
        - schema:
            type: string
            description: >-
              Firma HMAC estilo Stripe: `t=<unix>,v1=<hmacSha256(secret, t + '.' + body)>`. Verifica contra el signing
              secret del webhook (GET /v1/webhooks/{id}/secret) y rechaza timestamps viejos (replay).
            example: t=1751826343,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
          required: true
          name: X-Cupontli-Signature
          in: header
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEventPayload'
      responses:
        '200':
          description: >-
            Cualquier 2xx confirma la recepción. Otro status (o timeout de 8s) registra la entrega como fallida — no hay
            retry.
  coupon.email_opened:
    post:
      summary: Correo abierto
      description: >-
        Se entrega con POST a la URL del webhook suscrito a `coupon.email_opened`. Entrega best-effort SIN retry
        automático: responde 2xx rápido (<8s) y procesa async; las entregas fallidas quedan consultables en el dashboard
        (deliveries) y el estado siempre es re-derivable por la API (lookup/reportes). ⚠️ Evento de ALTO VOLUMEN
        (telemetría).
      parameters:
        - schema:
            type: string
            description: >-
              Firma HMAC estilo Stripe: `t=<unix>,v1=<hmacSha256(secret, t + '.' + body)>`. Verifica contra el signing
              secret del webhook (GET /v1/webhooks/{id}/secret) y rechaza timestamps viejos (replay).
            example: t=1751826343,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
          required: true
          name: X-Cupontli-Signature
          in: header
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEventPayload'
      responses:
        '200':
          description: >-
            Cualquier 2xx confirma la recepción. Otro status (o timeout de 8s) registra la entrega como fallida — no hay
            retry.
  coupon.email_clicked:
    post:
      summary: Clic en el correo
      description: >-
        Se entrega con POST a la URL del webhook suscrito a `coupon.email_clicked`. Entrega best-effort SIN retry
        automático: responde 2xx rápido (<8s) y procesa async; las entregas fallidas quedan consultables en el dashboard
        (deliveries) y el estado siempre es re-derivable por la API (lookup/reportes). ⚠️ Evento de ALTO VOLUMEN
        (telemetría).
      parameters:
        - schema:
            type: string
            description: >-
              Firma HMAC estilo Stripe: `t=<unix>,v1=<hmacSha256(secret, t + '.' + body)>`. Verifica contra el signing
              secret del webhook (GET /v1/webhooks/{id}/secret) y rechaza timestamps viejos (replay).
            example: t=1751826343,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
          required: true
          name: X-Cupontli-Signature
          in: header
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEventPayload'
      responses:
        '200':
          description: >-
            Cualquier 2xx confirma la recepción. Otro status (o timeout de 8s) registra la entrega como fallida — no hay
            retry.
  coupon.email_unsubscribed:
    post:
      summary: Se dio de baja del correo
      description: >-
        Se entrega con POST a la URL del webhook suscrito a `coupon.email_unsubscribed`. Entrega best-effort SIN retry
        automático: responde 2xx rápido (<8s) y procesa async; las entregas fallidas quedan consultables en el dashboard
        (deliveries) y el estado siempre es re-derivable por la API (lookup/reportes). ⚠️ Evento de ALTO VOLUMEN
        (telemetría).
      parameters:
        - schema:
            type: string
            description: >-
              Firma HMAC estilo Stripe: `t=<unix>,v1=<hmacSha256(secret, t + '.' + body)>`. Verifica contra el signing
              secret del webhook (GET /v1/webhooks/{id}/secret) y rechaza timestamps viejos (replay).
            example: t=1751826343,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
          required: true
          name: X-Cupontli-Signature
          in: header
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEventPayload'
      responses:
        '200':
          description: >-
            Cualquier 2xx confirma la recepción. Otro status (o timeout de 8s) registra la entrega como fallida — no hay
            retry.
  coupon.viewed:
    post:
      summary: Cupón visto
      description: >-
        Se entrega con POST a la URL del webhook suscrito a `coupon.viewed`. Entrega best-effort SIN retry automático:
        responde 2xx rápido (<8s) y procesa async; las entregas fallidas quedan consultables en el dashboard
        (deliveries) y el estado siempre es re-derivable por la API (lookup/reportes). ⚠️ Evento de ALTO VOLUMEN
        (telemetría).
      parameters:
        - schema:
            type: string
            description: >-
              Firma HMAC estilo Stripe: `t=<unix>,v1=<hmacSha256(secret, t + '.' + body)>`. Verifica contra el signing
              secret del webhook (GET /v1/webhooks/{id}/secret) y rechaza timestamps viejos (replay).
            example: t=1751826343,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
          required: true
          name: X-Cupontli-Signature
          in: header
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEventPayload'
      responses:
        '200':
          description: >-
            Cualquier 2xx confirma la recepción. Otro status (o timeout de 8s) registra la entrega como fallida — no hay
            retry.
  coupon.shared:
    post:
      summary: Cupón compartido
      description: >-
        Se entrega con POST a la URL del webhook suscrito a `coupon.shared`. Entrega best-effort SIN retry automático:
        responde 2xx rápido (<8s) y procesa async; las entregas fallidas quedan consultables en el dashboard
        (deliveries) y el estado siempre es re-derivable por la API (lookup/reportes). ⚠️ Evento de ALTO VOLUMEN
        (telemetría).
      parameters:
        - schema:
            type: string
            description: >-
              Firma HMAC estilo Stripe: `t=<unix>,v1=<hmacSha256(secret, t + '.' + body)>`. Verifica contra el signing
              secret del webhook (GET /v1/webhooks/{id}/secret) y rechaza timestamps viejos (replay).
            example: t=1751826343,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
          required: true
          name: X-Cupontli-Signature
          in: header
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEventPayload'
      responses:
        '200':
          description: >-
            Cualquier 2xx confirma la recepción. Otro status (o timeout de 8s) registra la entrega como fallida — no hay
            retry.
  coupon.detail_viewed:
    post:
      summary: Detalle consultado
      description: >-
        Se entrega con POST a la URL del webhook suscrito a `coupon.detail_viewed`. Entrega best-effort SIN retry
        automático: responde 2xx rápido (<8s) y procesa async; las entregas fallidas quedan consultables en el dashboard
        (deliveries) y el estado siempre es re-derivable por la API (lookup/reportes). ⚠️ Evento de ALTO VOLUMEN
        (telemetría).
      parameters:
        - schema:
            type: string
            description: >-
              Firma HMAC estilo Stripe: `t=<unix>,v1=<hmacSha256(secret, t + '.' + body)>`. Verifica contra el signing
              secret del webhook (GET /v1/webhooks/{id}/secret) y rechaza timestamps viejos (replay).
            example: t=1751826343,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
          required: true
          name: X-Cupontli-Signature
          in: header
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEventPayload'
      responses:
        '200':
          description: >-
            Cualquier 2xx confirma la recepción. Otro status (o timeout de 8s) registra la entrega como fallida — no hay
            retry.
  coupon.mint_rejected:
    post:
      summary: Minteo rechazado
      description: >-
        Se entrega con POST a la URL del webhook suscrito a `coupon.mint_rejected`. Entrega best-effort SIN retry
        automático: responde 2xx rápido (<8s) y procesa async; las entregas fallidas quedan consultables en el dashboard
        (deliveries) y el estado siempre es re-derivable por la API (lookup/reportes). ⚠️ Evento de ALTO VOLUMEN
        (telemetría).
      parameters:
        - schema:
            type: string
            description: >-
              Firma HMAC estilo Stripe: `t=<unix>,v1=<hmacSha256(secret, t + '.' + body)>`. Verifica contra el signing
              secret del webhook (GET /v1/webhooks/{id}/secret) y rechaza timestamps viejos (replay).
            example: t=1751826343,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
          required: true
          name: X-Cupontli-Signature
          in: header
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEventPayload'
      responses:
        '200':
          description: >-
            Cualquier 2xx confirma la recepción. Otro status (o timeout de 8s) registra la entrega como fallida — no hay
            retry.
  coupon.preview_rejected:
    post:
      summary: Intento de canje (no canjeable)
      description: >-
        Se entrega con POST a la URL del webhook suscrito a `coupon.preview_rejected`. Entrega best-effort SIN retry
        automático: responde 2xx rápido (<8s) y procesa async; las entregas fallidas quedan consultables en el dashboard
        (deliveries) y el estado siempre es re-derivable por la API (lookup/reportes). ⚠️ Evento de ALTO VOLUMEN
        (telemetría).
      parameters:
        - schema:
            type: string
            description: >-
              Firma HMAC estilo Stripe: `t=<unix>,v1=<hmacSha256(secret, t + '.' + body)>`. Verifica contra el signing
              secret del webhook (GET /v1/webhooks/{id}/secret) y rechaza timestamps viejos (replay).
            example: t=1751826343,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
          required: true
          name: X-Cupontli-Signature
          in: header
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEventPayload'
      responses:
        '200':
          description: >-
            Cualquier 2xx confirma la recepción. Otro status (o timeout de 8s) registra la entrega como fallida — no hay
            retry.
x-error-catalog:
  VALIDATION_ERROR:
    description: El body no cumple el shape del request (Zod). El detalle viene en `error.issues`.
  INVALID_JSON:
    description: El body no es JSON válido.
  BAD_REQUEST:
    description: 'Request malformado: falta el body o un parámetro de path requerido.'
  UNAUTHORIZED:
    description: Falta el header Authorization o la credencial es inválida/revocada.
  FORBIDDEN:
    description: La credencial es válida pero no porta el scope que la operación exige.
  NOT_FOUND:
    description: >-
      El recurso no existe para tu tenant/ambiente. Anti-enumeración: identificadores de otro tenant o malformados
      colapsan a este mismo código.
  SERVER_ERROR:
    description: Error interno no controlado. Reintentable.
  INVALID_QUERY:
    description: Query params inválidos (filtro desconocido, paginación malformada o rango de fechas ilegible).
  POLICY_NOT_FOUND:
    description: El `policyId` referenciado no existe en tu tenant.
  POLICY_INACTIVE:
    description: La política referenciada está archivada; solo políticas activas se pueden snapshotear a una campaña.
  LOOK_NOT_FOUND:
    description: El `lookId` referenciado no existe en tu tenant.
  LOOK_INACTIVE:
    description: El diseño (look) referenciado está archivado; solo diseños activos se pueden asignar.
  CAMPAIGN_ARCHIVED:
    description: 'La campaña está archivada (estado terminal): no se puede editar, canjear ni asignarle tags.'
  CANNOT_REVERT_TO_DRAFT:
    description: 'Una campaña publicada no puede regresar a borrador. Para detener el minteo, suspéndela (`status: suspendida`).'
  MINT_MECHANISM_LOCKED:
    description: '`mintMechanism` solo es editable mientras la campaña es borrador; queda congelado al activarla.'
  CAMPAIGN_LOCKED:
    description: 'La campaña ya registró canjes: los campos que definen el beneficio quedan bloqueados. `error.fields` lista cuáles.'
  TAG_LIMIT_EXCEEDED:
    description: El recurso ya tiene el máximo de 10 tags.
  CAMPAIGN_NOT_ACTIVE:
    description: 'La campaña no está activa (borrador o suspendida); la operación exige `status: activa`.'
  CAMPAIGN_INCOMPLETE:
    description: La campaña está activa pero le faltan datos requeridos (estado inconsistente). Contacta soporte.
  EXPORT_TOO_LARGE:
    description: La campaña excede el límite de 5,000 cupones para descarga masiva síncrona.
  BULK_EXPORT_UNAVAILABLE:
    description: >-
      La campaña ya emitió cupones por otro canal (o un mint concurrente ganó el claim); la descarga masiva exige
      reclamar la campaña completa desde cero.
  INVALID_ASSET_URL:
    description: >-
      `logoUrl`/`heroUrl` no apuntan al prefijo de assets de tu tenant. Sube el asset con POST /v1/looks/assets y usa la
      URL pública devuelta.
  UNSUPPORTED_REPORT_TYPE:
    description: El `type` de reporte solicitado no existe.
  CAMPAIGN_ERROR:
    description: No se pudo leer o validar la campaña del cupón (falla interna). Reintentable.
  MAX_COUPONS_REACHED:
    description: La campaña agotó su `maxCoupons`; no quedan folios por emitir.
  CAMPAIGN_NOT_STARTED:
    description: La vigencia de la campaña aún no inicia (`startDate`).
  CAMPAIGN_ENDED:
    description: La vigencia de la campaña ya terminó (`endDate`).
  TRANSACTION_ERROR:
    description: La transacción de emisión falló (contención). Reintentable; ningún cupón quedó emitido.
  INVALID_TOKEN:
    description: El `couponToken` no se pudo interpretar (formato inválido o truncado).
  TENANT_MISMATCH:
    description: 'El cupón pertenece a otro tenant: tu credencial no puede operarlo.'
  ENVIRONMENT_MISMATCH:
    description: El cupón es de otro ambiente (live vs test) que tu credencial.
  INVALID_COUPON:
    description: >-
      El cupón no es genuino (la firma HMAC no verifica) o no es operable. En superficies públicas este código también
      colapsa otros estados por anti-enumeración.
  COUPON_ERROR:
    description: No se pudo leer o materializar el cupón (falla interna).
  COUPON_CANCELED:
    description: El cupón fue cancelado; no se puede canjear.
  COUPON_EXHAUSTED:
    description: El cupón ya consumió todos sus usos (`maxUses`). `error.audit` trae el historial.
  MISSING_ATTRIBUTE:
    description: >-
      El `context` del canje no trae campos que la política exige. `error.attributes` lista los faltantes — consúltalos
      antes con POST /v1/coupons/fields.
  VALIDATION_RULES_FAILED:
    description: El canje no cumple la política de la campaña. `error.failures` lista las reglas incumplidas.
  BUDGET_EXHAUSTED:
    description: El canje excedería el `maxBudget` de la campaña; se rechaza.
  INVALID_FOLIO:
    description: El serial del cupón está fuera del rango emitido por la campaña.
  CAMPAIGN_FETCH_ERROR:
    description: No se pudo leer la campaña del cupón (falla interna).
  CAMPAIGN_DATA_CORRUPT:
    description: La campaña existe pero sus datos están corruptos (falta el material de verificación). Contacta soporte.
  COUPON_FETCH_ERROR:
    description: No se pudo leer el registro del cupón (falla interna).
  COUPON_CANCEL_ERROR:
    description: La cancelación falló al escribir (falla interna). Reintentable.
  LOOKUP_FAILED:
    description: Falla interna al resolver la credencial/invitación. Reintentable.
  REVOKE_FAILED:
    description: La revocación falló al escribir (falla interna). Reintentable.
