{
  "openapi": "3.1.0",
  "info": {
    "title": "API de MercadoSolar",
    "version": "1.1.0",
    "summary": "Superficie HTTP del marketplace solar de MercadoSolar en Guatemala.",
    "description": "MercadoSolar es un marketplace de energía solar en Guatemala: el cliente publica su\nproyecto y los instaladores certificados compiten en una licitación.\n\nEsta especificación cubre la superficie HTTP del portal. Casi toda es de uso interno del\nproducto: la autorización viaja en la cookie de sesión de Supabase que emite el propio\nportal al iniciar sesión, no hay emisión de llaves de API para terceros y no hay entorno\nde pruebas público. Un agente puede leerla para entender el modelo de datos y el flujo,\ny puede llamar sin sesión únicamente las operaciones marcadas como públicas.\n\nLas rutas bajo /api/admin no se documentan aquí a propósito: son la consola interna,\ncambian sin aviso y no son una superficie estable.\n\nContenido legible por agentes: /llms.txt (cuándo usar MercadoSolar), /developers\n(documentación), /sitemap.xml. Las páginas públicas también responden en Markdown si\npides Accept: text/markdown.\n\n## Versión (v1)\n\nLa forma canónica de cada ruta lleva la versión: /api/v1/... La forma sin\nversión (/api/...) sigue viva como alias y resuelve a lo mismo; es la que llama el propio\nportal desde el navegador. Toda respuesta de la API lleva la cabecera MS-API-Version.\n\nCompromiso: v1 no cambia de forma de manera incompatible. Agregar un campo a\nuna respuesta o un parámetro opcional NO es incompatible. Quitar un campo, cambiar su\ntipo, cambiar un código de error o mover una ruta, sí lo es, y eso estrena v2.\n\nObsolescencia: entre el aviso y el apagón hay al menos 180 días, y el aviso\nviaja en la propia respuesta: Deprecation (RFC 9745), Sunset (RFC 8594) y un\nLink rel=\"deprecation\". Una versión apagada responde 410, nunca 404 ni un silencio.\nHoy no hay ninguna ruta marcada como obsoleta.\n\n## Permisos con nombre\n\nCada operación declara el alcance que necesita. Un cliente que quiere el mínimo privilegio\nmanda la cabecera MS-SCOPES con la lista de alcances a los que se\nlimita, separados por espacios; cualquier operación fuera de esa lista responde 403\nalcance_insuficiente con el alcance que faltó en el WWW-Authenticate. La cabecera solo\npuede RESTRINGIR: sin ella, la petición se comporta como siempre. El catálogo completo,\nen formato de la RFC 9728, está en https://mercadosolar.com.gt/.well-known/oauth-protected-resource.\n\n## Cómo empezar sin cuenta\n\nEl entorno de pruebas (/api/v1/sandbox) es público, no pide llave y devuelve la\nmisma forma que las operaciones reales con datos sintéticos. Las rutas públicas de\nproducción tampoco piden llave. No emitimos llaves de API para terceros: para operar sobre\ndatos reales hace falta la sesión de un usuario del portal. Si necesitas más, hola@mercadosolar.com.gt.",
    "contact": {
      "name": "MercadoSolar",
      "email": "hola@mercadosolar.com.gt",
      "url": "https://mercadosolar.com.gt/contacto"
    },
    "license": {
      "name": "Uso propietario",
      "url": "https://mercadosolar.com.gt/terminos.html"
    }
  },
  "servers": [
    {
      "url": "https://mercadosolar.com.gt",
      "description": "Producción. La superficie versionada cuelga de /api/v1."
    }
  ],
  "x-api-version": "v1",
  "x-deprecation-policy": {
    "dias_de_aviso": 180,
    "cabeceras": [
      "Deprecation (RFC 9745)",
      "Sunset (RFC 8594)",
      "Link rel=\"deprecation\""
    ],
    "documentacion": "https://mercadosolar.com.gt/developers#versionado",
    "obsoletos": []
  },
  "externalDocs": {
    "description": "Documentación para desarrolladores y agentes",
    "url": "https://mercadosolar.com.gt/developers"
  },
  "tags": [
    {
      "name": "Público",
      "description": "Operaciones que no requieren sesión."
    },
    {
      "name": "Cliente",
      "description": "Operaciones del dueño de un proyecto solar."
    },
    {
      "name": "Acuerdos",
      "description": "Acuerdo de adjudicación bipartito entre cliente e instalador."
    },
    {
      "name": "Proyectos",
      "description": "Ciclo de vida de la instalación tras la adjudicación."
    },
    {
      "name": "Metadatos",
      "description": "Archivos legibles por máquina."
    },
    {
      "name": "Pruebas",
      "description": "Entorno de pruebas con datos sintéticos. Sin cuenta y sin llave."
    }
  ],
  "paths": {
    "/openapi.json": {
      "get": {
        "tags": [
          "Metadatos"
        ],
        "operationId": "obtener_openapi",
        "summary": "Descarga esta especificación",
        "description": "Devuelve la especificación OpenAPI 3.1 completa del portal. Pública, sin sesión. La misma especificación en YAML está en /openapi.yaml.",
        "security": [
          {},
          {
            "alcancesDeAgente": [
              "read:metadatos"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "La especificación.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/AlcanceInsuficiente"
          }
        }
      }
    },
    "/llms.txt": {
      "get": {
        "tags": [
          "Metadatos"
        ],
        "operationId": "obtener_llms_txt",
        "summary": "Guía para agentes",
        "description": "Archivo llms.txt con la descripción de MercadoSolar, cuándo conviene usarlo, qué NO hace y el índice de páginas y recursos legibles por máquina.",
        "security": [
          {},
          {
            "alcancesDeAgente": [
              "read:metadatos"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "El archivo llms.txt.",
            "content": {
              "text/plain": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/AlcanceInsuficiente"
          }
        }
      }
    },
    "/openapi.yaml": {
      "get": {
        "tags": [
          "Metadatos"
        ],
        "operationId": "obtener_openapi_yaml",
        "summary": "Descarga esta especificación en YAML",
        "description": "La misma especificación OpenAPI 3.1 que /openapi.json, serializada en YAML. Pública, sin sesión.",
        "security": [
          {},
          {
            "alcancesDeAgente": [
              "read:metadatos"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "La especificación en YAML.",
            "content": {
              "application/yaml": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/AlcanceInsuficiente"
          }
        }
      }
    },
    "/.well-known/oauth-protected-resource": {
      "get": {
        "tags": [
          "Metadatos"
        ],
        "operationId": "obtener_metadatos_de_recurso_protegido",
        "summary": "Metadatos de recurso protegido (RFC 9728)",
        "description": "Declara, para máquinas, el catálogo completo de alcances con nombre de esta API, el servidor de autorización que emite la sesión y dónde está la documentación. Es la URL a la que apunta el parámetro resource_metadata del WWW-Authenticate en los 401 y en los 403 por alcance insuficiente: un agente puede descubrir los permisos sin haber leído esta especificación antes.",
        "security": [
          {},
          {
            "alcancesDeAgente": [
              "read:metadatos"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "Los metadatos de recurso protegido.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MetadatosDeRecursoProtegido"
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/AlcanceInsuficiente"
          }
        }
      }
    },
    "/api/v1/sandbox": {
      "get": {
        "tags": [
          "Pruebas"
        ],
        "operationId": "obtener_indice_del_sandbox",
        "summary": "Índice del entorno de pruebas",
        "description": "Qué hay en el sandbox, con qué identificadores probarlo y qué operación real espeja cada una. Público, sin cuenta y sin llave: es la puerta de entrada para probar la forma de las respuestas antes de tener acceso a datos reales.",
        "security": [
          {},
          {
            "alcancesDeAgente": [
              "read:sandbox"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "El índice del entorno.",
            "headers": {
              "RateLimit": {
                "description": "Estado de la cuota en la ventana actual, en el formato de la RFC 9331: \"limit=60, remaining=59, reset=57\".",
                "schema": {
                  "type": "string"
                }
              },
              "RateLimit-Policy": {
                "description": "Politica aplicada, por ejemplo \"60;w=60\".",
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/IndiceDeSandbox"
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/AlcanceInsuficiente"
          },
          "429": {
            "$ref": "#/components/responses/DemasiadasPeticiones"
          }
        }
      }
    },
    "/api/v1/sandbox/proyectos/{id}/cotizaciones": {
      "get": {
        "tags": [
          "Pruebas"
        ],
        "operationId": "listar_cotizaciones_del_sandbox",
        "summary": "Espejo de las cotizaciones de un proyecto, con datos sintéticos",
        "description": "Devuelve exactamente la misma forma que la operación real del cliente, con datos inventados: tres cotizaciones, una con identidad desbloqueada y dos reservadas, y un hilo de preguntas. Sin sesión y sin llave. Un identificador distinto al del sandbox devuelve el mismo 404 que devolvería la operación real.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "Identificador del proyecto de pruebas, que publica /api/v1/sandbox.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "security": [
          {},
          {
            "alcancesDeAgente": [
              "read:sandbox"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "Las cotizaciones sintéticas.",
            "headers": {
              "RateLimit": {
                "description": "Estado de la cuota en la ventana actual, en el formato de la RFC 9331: \"limit=60, remaining=59, reset=57\".",
                "schema": {
                  "type": "string"
                }
              },
              "RateLimit-Policy": {
                "description": "Politica aplicada, por ejemplo \"60;w=60\".",
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CotizacionesDelProyecto"
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/AlcanceInsuficiente"
          },
          "404": {
            "description": "Ese proyecto no existe en el sandbox.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "proyecto_no_encontrado"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/DemasiadasPeticiones"
          }
        }
      }
    },
    "/api/v1/sandbox/acuerdos/{id}": {
      "get": {
        "tags": [
          "Pruebas"
        ],
        "operationId": "obtener_acuerdo_del_sandbox",
        "summary": "Espejo de un acuerdo de adjudicación, con datos sintéticos",
        "description": "La forma que devuelven las operaciones reales de acuerdos, expuesta en un GET para poder inspeccionarla sin escribir nada. El acuerdo de prueba está firmado por el cliente y pendiente del instalador: el estado en el que el contacto entre las partes todavía NO se desbloquea.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "Identificador del acuerdo de pruebas, que publica /api/v1/sandbox.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "security": [
          {},
          {
            "alcancesDeAgente": [
              "read:sandbox"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "El acuerdo sintético.",
            "headers": {
              "RateLimit": {
                "description": "Estado de la cuota en la ventana actual, en el formato de la RFC 9331: \"limit=60, remaining=59, reset=57\".",
                "schema": {
                  "type": "string"
                }
              },
              "RateLimit-Policy": {
                "description": "Politica aplicada, por ejemplo \"60;w=60\".",
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Acuerdo"
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/AlcanceInsuficiente"
          },
          "404": {
            "description": "Ese acuerdo no existe en el sandbox.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "acuerdo_no_encontrado"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/DemasiadasPeticiones"
          }
        }
      }
    },
    "/api/v1/eventos": {
      "post": {
        "tags": [
          "Público"
        ],
        "operationId": "registrar_evento_de_embudo",
        "summary": "Registra un evento de embudo",
        "description": "Única puerta de escritura de la analítica de embudo del portal. Es pública porque los dos primeros pasos que se miden (la calculadora y el formulario del cliente) ocurren sin sesión. No devuelve datos: responde 204 aunque el evento se pierda. Si hay sesión, el usuario y el rol se toman del servidor, nunca del cuerpo.",
        "security": [
          {},
          {
            "alcancesDeAgente": [
              "write:eventos"
            ]
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/EventoDeEmbudo"
              },
              "example": {
                "event": "calc_view",
                "session_id": "a1b2c3",
                "path": "/calculadora.html"
              }
            }
          }
        },
        "responses": {
          "204": {
            "description": "Evento aceptado. Sin cuerpo.",
            "headers": {
              "RateLimit": {
                "description": "Estado de la cuota en la ventana actual, en el formato de la RFC 9331: \"limit=60, remaining=59, reset=57\".",
                "schema": {
                  "type": "string"
                }
              },
              "RateLimit-Policy": {
                "description": "Politica aplicada, por ejemplo \"60;w=60\".",
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "400": {
            "description": "El cuerpo no es JSON, el evento no está en la lista o falta session_id.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "evento_desconocido"
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/AlcanceInsuficiente"
          },
          "429": {
            "$ref": "#/components/responses/DemasiadasPeticiones"
          }
        }
      }
    },
    "/api/v1/review/{slug}": {
      "post": {
        "tags": [
          "Público"
        ],
        "operationId": "enviar_resena_externa",
        "summary": "Envía una reseña de un proyecto anterior a MercadoSolar",
        "description": "Submit anónimo del formulario de autoservicio de reseñas externas. El control de acceso es el slug del enlace que comparte el instalador más la moderación: la reseña entra como pendiente y no se publica hasta que MercadoSolar la aprueba. El nombre real y el contacto quedan en columnas privadas; el público solo ve el nombre a mostrar.",
        "security": [
          {},
          {
            "alcancesDeAgente": [
              "write:resenas"
            ]
          }
        ],
        "parameters": [
          {
            "name": "slug",
            "in": "path",
            "required": true,
            "description": "Identificador del enlace de invitación que comparte el instalador.",
            "schema": {
              "type": "string",
              "minLength": 6,
              "maxLength": 64
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ResenaExterna"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Reseña recibida y en espera de moderación.",
            "headers": {
              "RateLimit": {
                "description": "Estado de la cuota en la ventana actual, en el formato de la RFC 9331: \"limit=60, remaining=59, reset=57\".",
                "schema": {
                  "type": "string"
                }
              },
              "RateLimit-Policy": {
                "description": "Politica aplicada, por ejemplo \"60;w=60\".",
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Ok"
                },
                "example": {
                  "ok": true
                }
              }
            }
          },
          "400": {
            "description": "Falta un campo obligatorio o el comentario incluye datos de contacto.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "nombre_publico_requerido"
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/AlcanceInsuficiente"
          },
          "404": {
            "description": "El enlace no existe o ya fue usado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "link_invalido"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/DemasiadasPeticiones"
          },
          "500": {
            "description": "La reseña no se pudo guardar.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "no_se_pudo_guardar"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/vendedores/aplicar": {
      "post": {
        "tags": [
          "Público"
        ],
        "operationId": "enviar_solicitud_de_vendedor",
        "summary": "Envía una solicitud para vender MercadoSolar",
        "description": "Submit anónimo del formulario de /vendedores. No crea ninguna cuenta ni habilita a nadie: la solicitud entra como nueva y una persona de MercadoSolar decide después. Requiere un token de Cloudflare Turnstile válido.",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SolicitudDeVendedor"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Solicitud recibida.",
            "headers": {
              "RateLimit": {
                "description": "Estado de la cuota en la ventana actual, en el formato de la RFC 9331: \"limit=60, remaining=59, reset=57\".",
                "schema": {
                  "type": "string"
                }
              },
              "RateLimit-Policy": {
                "description": "Politica aplicada, por ejemplo \"60;w=60\".",
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Ok"
                },
                "example": {
                  "ok": true
                }
              }
            }
          },
          "400": {
            "description": "Falta un campo obligatorio o el captcha no pasó.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "zona_requerida"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/DemasiadasPeticiones"
          },
          "500": {
            "description": "La solicitud no se pudo guardar.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "no_se_pudo_guardar"
                }
              }
            }
          },
          "503": {
            "description": "El captcha no está configurado en este entorno.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "captcha_no_configurado"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/client/proyectos/{id}/cotizaciones": {
      "get": {
        "tags": [
          "Cliente"
        ],
        "operationId": "listar_cotizaciones_del_proyecto",
        "summary": "Cotizaciones y preguntas de un proyecto",
        "description": "Única vía por la que el dueño de un proyecto lee las cotizaciones que recibió y el hilo de preguntas. Sirve una lista blanca de columnas y sustituye el identificador real del instalador por un alias por proyecto mientras la identidad siga reservada: el nombre del instalador solo viaja cuando la adjudicación ya lo desbloqueó.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "Identificador del proyecto.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Cotizaciones visibles, con su alias y el hilo de preguntas.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CotizacionesDelProyecto"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/NoAutenticado"
          },
          "403": {
            "description": "La sesión no es del dueño del proyecto.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "no_autorizado"
                }
              }
            }
          }
        },
        "security": [
          {
            "sesionDeSupabase": [
              "read:cotizaciones"
            ]
          }
        ]
      }
    },
    "/api/v1/client/proyectos/{id}/ganador": {
      "post": {
        "tags": [
          "Cliente"
        ],
        "operationId": "seleccionar_ganador",
        "summary": "Selecciona la cotización ganadora",
        "description": "Adjudica el proyecto a una de las cotizaciones recibidas. Se pasa el alias que ve el cliente, no el identificador del instalador: el servidor lo traduce. Dispara la devolución del 80 por ciento del fee a los instaladores que no ganaron.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "Identificador del proyecto.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "alias"
                ],
                "properties": {
                  "alias": {
                    "type": "string",
                    "description": "Alias de la cotización ganadora, tal como lo ve el cliente."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Proyecto adjudicado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Ok"
                }
              }
            }
          },
          "400": {
            "description": "Falta el alias o no corresponde a ninguna cotización.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "ganador_invalido"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/NoAutenticado"
          },
          "403": {
            "description": "La sesión no es del dueño del proyecto.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "no_autorizado"
                }
              }
            }
          },
          "409": {
            "description": "El proyecto no está en un estado adjudicable.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "estado_no_adjudicable"
                }
              }
            }
          }
        },
        "security": [
          {
            "sesionDeSupabase": [
              "write:licitacion"
            ]
          }
        ]
      }
    },
    "/api/v1/client/proyectos/{id}/preguntas": {
      "post": {
        "tags": [
          "Cliente"
        ],
        "operationId": "publicar_pregunta_del_proyecto",
        "summary": "Publica una pregunta sobre el proyecto",
        "description": "Escribe una pregunta en el hilo del proyecto. Puede ir dirigida a todos los instaladores que cotizaron, a uno solo, o a MercadoSolar. El texto se limita a 4000 caracteres.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "Identificador del proyecto.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "texto"
                ],
                "properties": {
                  "texto": {
                    "type": "string",
                    "maxLength": 4000,
                    "description": "La pregunta."
                  },
                  "toMs": {
                    "type": "boolean",
                    "description": "Dirigirla a MercadoSolar en vez de a los instaladores."
                  },
                  "alias": {
                    "type": "string",
                    "description": "Alias del instalador destinatario, si va dirigida a uno solo."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Pregunta publicada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Ok"
                }
              }
            }
          },
          "400": {
            "description": "Falta el texto, es demasiado largo o el destinatario no existe.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "falta_pregunta"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/NoAutenticado"
          },
          "403": {
            "description": "La sesión no es del dueño del proyecto.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "no_autorizado"
                }
              }
            }
          }
        },
        "security": [
          {
            "sesionDeSupabase": [
              "write:licitacion"
            ]
          }
        ]
      }
    },
    "/api/v1/compare-summary": {
      "post": {
        "tags": [
          "Cliente"
        ],
        "operationId": "resumir_cotizaciones_con_ia",
        "summary": "Resume y compara las cotizaciones recibidas",
        "description": "Devuelve, en streaming de texto, una comparación en lenguaje natural de las cotizaciones de un proyecto. Requiere al menos dos cotizaciones. Si el portal no tiene configurada su llave de modelo, responde 503 sin romper la pantalla.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "projectId"
                ],
                "properties": {
                  "projectId": {
                    "type": "string",
                    "format": "uuid",
                    "description": "Proyecto a comparar."
                  },
                  "question": {
                    "type": "string",
                    "description": "Pregunta concreta del cliente sobre las cotizaciones."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Resumen en streaming.",
            "content": {
              "text/plain": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "400": {
            "description": "Cuerpo inválido o menos de dos cotizaciones.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "datos_insuficientes"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/NoAutenticado"
          },
          "403": {
            "$ref": "#/components/responses/AlcanceInsuficiente"
          },
          "503": {
            "description": "El resumen con IA no está configurado en este entorno.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "sin_api_key"
                }
              }
            }
          }
        },
        "security": [
          {
            "sesionDeSupabase": [
              "read:cotizaciones"
            ]
          }
        ]
      }
    },
    "/api/v1/agreements": {
      "post": {
        "tags": [
          "Acuerdos"
        ],
        "operationId": "generar_acuerdo_de_adjudicacion",
        "summary": "Genera el acuerdo de adjudicación",
        "description": "Crea el acuerdo bipartito entre el cliente y el instalador ganador, en PDF, con su huella SHA-256. Exige que el proyecto ya tenga ganador y que esté pactada la ubicación del inversor.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "projectId"
                ],
                "properties": {
                  "projectId": {
                    "type": "string",
                    "format": "uuid",
                    "description": "Proyecto adjudicado."
                  },
                  "inverterLocation": {
                    "type": "string",
                    "description": "Ubicación del inversor elegida entre las que comprometió la cotización ganadora."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Acuerdo generado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Acuerdo"
                }
              }
            }
          },
          "400": {
            "description": "Parámetros inválidos.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "parametros_invalidos"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/NoAutenticado"
          },
          "403": {
            "description": "La sesión no es parte de este acuerdo.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "no_autorizado"
                }
              }
            }
          },
          "404": {
            "description": "El proyecto no existe.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "proyecto_no_encontrado"
                }
              }
            }
          },
          "409": {
            "description": "El proyecto no tiene ganador o no está en estado válido.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "sin_ganador"
                }
              }
            }
          },
          "500": {
            "description": "El PDF no se pudo generar.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "generacion_fallida"
                }
              }
            }
          }
        },
        "security": [
          {
            "sesionDeSupabase": [
              "write:acuerdos"
            ]
          }
        ]
      }
    },
    "/api/v1/agreements/{id}/sign": {
      "post": {
        "tags": [
          "Acuerdos"
        ],
        "operationId": "firmar_acuerdo",
        "summary": "Firma el acuerdo de adjudicación",
        "description": "Registra la aceptación de una de las dos partes, con su huella del documento. El contacto entre cliente e instalador solo se desbloquea cuando ambas firmaron. El instalador ganador debe tener su fee cubierto antes de poder firmar.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "Identificador del acuerdo.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "hash"
                ],
                "properties": {
                  "hash": {
                    "type": "string",
                    "description": "SHA-256 del PDF que la parte declara haber leído."
                  },
                  "fullName": {
                    "type": "string",
                    "description": "Nombre completo de quien firma."
                  },
                  "documentId": {
                    "type": "string",
                    "description": "DPI o identificación de quien firma."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Firma registrada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Acuerdo"
                }
              }
            }
          },
          "400": {
            "description": "Datos de firma inválidos.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "datos_firma_invalidos"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/NoAutenticado"
          },
          "403": {
            "description": "La sesión no es parte de este acuerdo.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "no_autorizado"
                }
              }
            }
          },
          "404": {
            "description": "El acuerdo no existe.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "acuerdo_no_encontrado"
                }
              }
            }
          },
          "409": {
            "description": "El acuerdo no es firmable, ya fue aceptado, o el fee está pendiente.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "fee_pendiente"
                }
              }
            }
          }
        },
        "security": [
          {
            "sesionDeSupabase": [
              "write:acuerdos"
            ]
          }
        ]
      }
    },
    "/api/v1/agreements/{id}/reject": {
      "post": {
        "tags": [
          "Acuerdos"
        ],
        "operationId": "rechazar_acuerdo",
        "summary": "Rechaza el acuerdo de adjudicación",
        "description": "Cualquiera de las dos partes puede rechazar el acuerdo antes de que quede firmado por ambas, con un motivo obligatorio. El rechazo abre la ruta de reasignación del proyecto.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "Identificador del acuerdo.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "reason"
                ],
                "properties": {
                  "reason": {
                    "type": "string",
                    "description": "Motivo del rechazo."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Rechazo registrado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Ok"
                }
              }
            }
          },
          "400": {
            "description": "Falta el motivo.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "motivo_requerido"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/NoAutenticado"
          },
          "403": {
            "description": "La sesión no es parte de este acuerdo.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "no_autorizado"
                }
              }
            }
          },
          "404": {
            "description": "El acuerdo no existe.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "acuerdo_no_encontrado"
                }
              }
            }
          },
          "409": {
            "description": "El acuerdo ya no es rechazable.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "acuerdo_no_rechazable"
                }
              }
            }
          }
        },
        "security": [
          {
            "sesionDeSupabase": [
              "write:acuerdos"
            ]
          }
        ]
      }
    },
    "/api/v1/agreements/{id}/pdf": {
      "get": {
        "tags": [
          "Acuerdos"
        ],
        "operationId": "descargar_acuerdo_pdf",
        "summary": "Descarga el PDF del acuerdo",
        "description": "Devuelve una redirección a una URL firmada y temporal del PDF del acuerdo. Solo para las partes del acuerdo y para MercadoSolar.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "Identificador del acuerdo.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "307": {
            "description": "Redirección a la URL firmada del PDF."
          },
          "401": {
            "$ref": "#/components/responses/NoAutenticado"
          },
          "403": {
            "description": "La sesión no es parte de este acuerdo.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "no_autorizado"
                }
              }
            }
          },
          "404": {
            "description": "El acuerdo no existe.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "acuerdo_no_encontrado"
                }
              }
            }
          },
          "500": {
            "description": "El PDF no está disponible.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "pdf_no_disponible"
                }
              }
            }
          }
        },
        "security": [
          {
            "sesionDeSupabase": [
              "read:acuerdos"
            ]
          }
        ]
      }
    },
    "/api/v1/projects/{id}/report-installation": {
      "post": {
        "tags": [
          "Proyectos"
        ],
        "operationId": "reportar_instalacion",
        "summary": "El instalador reporta la instalación terminada",
        "description": "Marca el proyecto como instalación reportada y abre la ventana en la que el cliente confirma o rechaza. Solo el instalador ganador del proyecto.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "Identificador del proyecto.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Instalación reportada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Ok"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/NoAutenticado"
          },
          "403": {
            "description": "La sesión no es del instalador ganador.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "no_autorizado"
                }
              }
            }
          },
          "404": {
            "description": "El proyecto no existe.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "proyecto_no_encontrado"
                }
              }
            }
          },
          "409": {
            "description": "El proyecto no está en un estado reportable.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "estado_invalido"
                }
              }
            }
          }
        },
        "security": [
          {
            "sesionDeSupabase": [
              "write:instalacion"
            ]
          }
        ]
      }
    },
    "/api/v1/projects/{id}/reject-installation": {
      "post": {
        "tags": [
          "Proyectos"
        ],
        "operationId": "rechazar_instalacion",
        "summary": "El cliente rechaza la instalación reportada",
        "description": "Abre una disputa sobre una instalación que el instalador dio por terminada, con un motivo obligatorio. Solo el dueño del proyecto.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "Identificador del proyecto.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "reason"
                ],
                "properties": {
                  "reason": {
                    "type": "string",
                    "description": "Qué quedó mal en la instalación."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Disputa abierta.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Ok"
                }
              }
            }
          },
          "400": {
            "description": "Falta el motivo.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "motivo_requerido"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/NoAutenticado"
          },
          "403": {
            "description": "La sesión no es del dueño del proyecto.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "no_autorizado"
                }
              }
            }
          },
          "404": {
            "description": "El proyecto no existe.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "proyecto_no_encontrado"
                }
              }
            }
          },
          "409": {
            "description": "El proyecto no está en un estado disputable.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "estado_invalido"
                }
              }
            }
          }
        },
        "security": [
          {
            "sesionDeSupabase": [
              "write:instalacion"
            ]
          }
        ]
      }
    }
  },
  "components": {
    "securitySchemes": {
      "sesionDeSupabase": {
        "type": "apiKey",
        "in": "cookie",
        "name": "sb-<referencia-del-proyecto>-auth-token",
        "description": "Cookie de sesión que emite el portal al iniciar sesión con Supabase Auth. No hay emisión de llaves de API para terceros: un agente que necesite operar tiene que hacerlo con la sesión de un usuario real del portal.\n\nLos nombres que cada operación lista junto a este esquema son los ALCANCES que necesita, del catálogo de `components.securitySchemes.alcancesDeAgente`. La sesión los concede todos por omisión; para pedir menos, ver ese esquema.",
        "x-resource-metadata": "https://mercadosolar.com.gt/.well-known/oauth-protected-resource"
      },
      "alcancesDeAgente": {
        "type": "apiKey",
        "in": "header",
        "name": "MS-SCOPES",
        "description": "Mínimo privilegio, opcional y a iniciativa del cliente. Manda la lista de alcances a los que te limitas, separados por espacios (RFC 6749 §3.3); el portal rechaza con 403 `alcance_insuficiente` cualquier operación fuera de esa lista y nombra el alcance que faltó en el `WWW-Authenticate` (RFC 6750 §3.1). La cabecera solo puede RESTRINGIR: no concede nada que la sesión no concediera ya, y sin ella todo funciona como siempre.\n\nCatálogo:\n- `read:metadatos`: Leer los archivos legibles por máquina: especificación, llms.txt y metadatos de recurso protegido.\n- `read:sandbox`: Leer el entorno de pruebas con datos sintéticos. No toca datos reales de nadie.\n- `write:eventos`: Registrar eventos de embudo anónimos.\n- `write:resenas`: Enviar una reseña externa por el enlace de autoservicio del instalador.\n- `read:cotizaciones`: Leer las cotizaciones y el hilo de preguntas de un proyecto propio.\n- `write:licitacion`: Seleccionar al instalador ganador y publicar preguntas en la licitación.\n- `read:acuerdos`: Leer y descargar el acuerdo de adjudicación.\n- `write:acuerdos`: Generar, firmar o rechazar un acuerdo de adjudicación.\n- `write:instalacion`: Reportar la instalación terminada o abrir una disputa.\n\nEl mismo catálogo, legible por máquina: https://mercadosolar.com.gt/.well-known/oauth-protected-resource",
        "x-scopes": {
          "read:metadatos": "Leer los archivos legibles por máquina: especificación, llms.txt y metadatos de recurso protegido.",
          "read:sandbox": "Leer el entorno de pruebas con datos sintéticos. No toca datos reales de nadie.",
          "write:eventos": "Registrar eventos de embudo anónimos.",
          "write:resenas": "Enviar una reseña externa por el enlace de autoservicio del instalador.",
          "read:cotizaciones": "Leer las cotizaciones y el hilo de preguntas de un proyecto propio.",
          "write:licitacion": "Seleccionar al instalador ganador y publicar preguntas en la licitación.",
          "read:acuerdos": "Leer y descargar el acuerdo de adjudicación.",
          "write:acuerdos": "Generar, firmar o rechazar un acuerdo de adjudicación.",
          "write:instalacion": "Reportar la instalación terminada o abrir una disputa."
        }
      }
    },
    "responses": {
      "NoAutenticado": {
        "description": "No hay sesión válida en la petición.",
        "headers": {
          "WWW-Authenticate": {
            "description": "Bearer con el parámetro resource_metadata de la RFC 9728, apuntando al catálogo de alcances y al servidor de autorización.",
            "schema": {
              "type": "string"
            }
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": "no_autenticado"
            }
          }
        }
      },
      "AlcanceInsuficiente": {
        "description": "La cabecera de alcances de la petición no incluye el que esta operación necesita. Solo puede ocurrir si el cliente mandó esa cabecera: es una restricción que él mismo se puso.",
        "headers": {
          "WWW-Authenticate": {
            "description": "Bearer con error=\"insufficient_scope\" y el alcance que faltó.",
            "schema": {
              "type": "string"
            }
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": "alcance_insuficiente",
              "alcance_requerido": "read:cotizaciones"
            }
          }
        }
      },
      "DemasiadasPeticiones": {
        "description": "Se agotó la cuota de la ventana actual. Espera lo que indique Retry-After.",
        "headers": {
          "RateLimit": {
            "description": "Estado de la cuota en la ventana actual, en el formato de la RFC 9331: \"limit=60, remaining=59, reset=57\".",
            "schema": {
              "type": "string"
            }
          },
          "RateLimit-Policy": {
            "description": "Politica aplicada, por ejemplo \"60;w=60\".",
            "schema": {
              "type": "string"
            }
          },
          "Retry-After": {
            "description": "Segundos que faltan para que la ventana se reinicie.",
            "schema": {
              "type": "integer",
              "minimum": 1
            }
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": "demasiadas_peticiones"
            }
          }
        }
      }
    },
    "schemas": {
      "Error": {
        "type": "object",
        "description": "Forma única de error del portal. El código legible viaja en `error`.",
        "required": [
          "error"
        ],
        "properties": {
          "error": {
            "type": "string",
            "description": "Código de error estable, en minúsculas con guiones bajos."
          },
          "detalle": {
            "type": "string",
            "description": "Detalle técnico, cuando lo hay."
          }
        }
      },
      "Ok": {
        "type": "object",
        "description": "Confirmación sin datos.",
        "required": [
          "ok"
        ],
        "properties": {
          "ok": {
            "type": "boolean",
            "const": true
          }
        }
      },
      "EventoDeEmbudo": {
        "type": "object",
        "description": "Un paso del embudo, con o sin sesión.",
        "required": [
          "event",
          "session_id"
        ],
        "properties": {
          "event": {
            "type": "string",
            "description": "Nombre del evento. Solo se aceptan los de esta lista.",
            "enum": [
              "page_view",
              "calc_view",
              "calc_result",
              "calc_cta",
              "project_form_start",
              "project_form_submit",
              "signup_start",
              "signup_complete",
              "project_published",
              "installer_quote_start",
              "installer_quote_submit"
            ]
          },
          "session_id": {
            "type": "string",
            "maxLength": 64,
            "description": "Identificador de navegador, no de persona."
          },
          "path": {
            "type": "string",
            "maxLength": 300,
            "description": "Ruta donde ocurrió el evento."
          },
          "project_id": {
            "type": "string",
            "format": "uuid",
            "description": "Proyecto relacionado, si aplica."
          },
          "props": {
            "type": "object",
            "description": "Hasta 10 claves escalares para segmentar. No admite datos personales.",
            "additionalProperties": {
              "type": [
                "string",
                "number",
                "boolean"
              ]
            }
          }
        }
      },
      "ResenaExterna": {
        "type": "object",
        "description": "Reseña de un proyecto anterior a MercadoSolar, enviada por el cliente referenciado.",
        "required": [
          "authorDisplay",
          "refName",
          "contact",
          "ratingOverall"
        ],
        "properties": {
          "authorDisplay": {
            "type": "string",
            "description": "Nombre o alias que se publica."
          },
          "refName": {
            "type": "string",
            "description": "Nombre real, privado, para verificar la reseña."
          },
          "contact": {
            "type": "string",
            "description": "Teléfono o WhatsApp, privado, para verificar la reseña."
          },
          "zone": {
            "type": "string",
            "description": "Zona o municipio del proyecto."
          },
          "projectYear": {
            "type": [
              "string",
              "integer"
            ],
            "description": "Año de la instalación."
          },
          "ratingOverall": {
            "type": "integer",
            "minimum": 1,
            "maximum": 5,
            "description": "Calificación general."
          },
          "ratingResponse": {
            "type": [
              "integer",
              "null"
            ],
            "minimum": 1,
            "maximum": 5,
            "description": "Respuesta y comunicación."
          },
          "ratingQuality": {
            "type": [
              "integer",
              "null"
            ],
            "minimum": 1,
            "maximum": 5,
            "description": "Calidad del trabajo."
          },
          "ratingCompliance": {
            "type": [
              "integer",
              "null"
            ],
            "minimum": 1,
            "maximum": 5,
            "description": "Cumplimiento de lo pactado."
          },
          "ratingPostsale": {
            "type": [
              "integer",
              "null"
            ],
            "minimum": 1,
            "maximum": 5,
            "description": "Servicio posventa."
          },
          "comment": {
            "type": "string",
            "description": "Comentario abierto. No puede incluir datos de contacto."
          },
          "photos": {
            "type": "array",
            "maxItems": 3,
            "description": "Hasta 3 fotos como data URL (JPEG, PNG o WebP), de 5 MB cada una como máximo.",
            "items": {
              "type": "string"
            }
          }
        }
      },
      "SolicitudDeVendedor": {
        "type": "object",
        "description": "Solicitud de alguien que quiere vender MercadoSolar en su zona.",
        "required": [
          "nombre",
          "telefono",
          "correo",
          "zona",
          "ocupacion",
          "disponibilidad",
          "captchaToken"
        ],
        "properties": {
          "nombre": {
            "type": "string",
            "minLength": 3,
            "maxLength": 120,
            "description": "Nombre completo."
          },
          "telefono": {
            "type": "string",
            "maxLength": 40,
            "description": "WhatsApp, con al menos 8 dígitos."
          },
          "correo": {
            "type": "string",
            "format": "email",
            "maxLength": 160,
            "description": "Correo de contacto."
          },
          "zona": {
            "type": "string",
            "maxLength": 160,
            "description": "Zona o municipio donde vendería."
          },
          "ocupacion": {
            "type": "string",
            "maxLength": 160,
            "description": "A qué se dedica hoy."
          },
          "disponibilidad": {
            "type": "string",
            "enum": [
              "completo",
              "parcial",
              "fines"
            ],
            "description": "Cuánto tiempo le puede dedicar."
          },
          "sobreMi": {
            "type": "string",
            "maxLength": 1000,
            "description": "Texto libre, opcional."
          },
          "captchaToken": {
            "type": "string",
            "description": "Token de Cloudflare Turnstile."
          }
        }
      },
      "Cotizacion": {
        "type": "object",
        "description": "Cotización tal como la ve el cliente. El instalador viaja como alias mientras la identidad siga reservada.",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "Identificador de la cotización."
          },
          "installer_id": {
            "type": "string",
            "description": "Alias del instalador dentro de este proyecto, NO su identificador real. El id real solo aparece en `identities`, y solo para los instaladores cuya identidad ya está desbloqueada."
          },
          "status": {
            "type": "string",
            "description": "Estado de la cotización."
          },
          "system_kwp": {
            "type": "number",
            "description": "Tamaño del sistema en kWp."
          },
          "panel_count": {
            "type": "integer",
            "description": "Cantidad de paneles."
          },
          "panel_power_w": {
            "type": "integer",
            "description": "Potencia de cada panel, en vatios."
          },
          "panel_brand": {
            "type": "string",
            "description": "Marca del panel."
          },
          "inverter_brand": {
            "type": "string",
            "description": "Marca del inversor."
          },
          "inverter_count": {
            "type": "integer",
            "description": "Cantidad de inversores."
          },
          "inverter_topology": {
            "type": "string",
            "description": "Topología: string, híbrido o microinversor."
          },
          "production_kwh_month": {
            "type": "number",
            "description": "Producción estimada, en kWh al mes."
          },
          "price_cash_q": {
            "type": "number",
            "description": "Precio de contado, en quetzales."
          },
          "price_cash_final_q": {
            "type": "number",
            "description": "Precio de contado con descuento, en quetzales."
          },
          "installment_48m_q": {
            "type": "number",
            "description": "Cuota mensual a 48 meses, en quetzales."
          },
          "warranty_materials_years": {
            "type": "integer",
            "description": "Años de garantía de materiales."
          },
          "quote_validity_days": {
            "type": "integer",
            "description": "Días de vigencia de la cotización."
          },
          "installation_days": {
            "type": "integer",
            "description": "Días estimados de instalación."
          },
          "inverter_location_commitments": {
            "type": "array",
            "description": "Ubicaciones de inversor que el instalador se compromete a cubrir al precio cotizado.",
            "items": {
              "type": "string"
            }
          }
        }
      },
      "Pregunta": {
        "type": "object",
        "description": "Pregunta del hilo del proyecto, con sus respuestas.",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "Identificador de la pregunta."
          },
          "installer_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "Alias de quien pregunta o a quien se dirige. `null` cuando pregunta el cliente."
          },
          "question": {
            "type": "string",
            "description": "Texto de la pregunta."
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "description": "Cuándo se publicó."
          },
          "to_ms": {
            "type": "boolean",
            "description": "Va dirigida a MercadoSolar."
          },
          "ms_answer": {
            "type": [
              "string",
              "null"
            ],
            "description": "Respuesta de MercadoSolar, si la hay."
          },
          "answers": {
            "type": "array",
            "description": "Respuestas de los instaladores.",
            "items": {
              "type": "object",
              "properties": {
                "id": {
                  "type": "string",
                  "format": "uuid",
                  "description": "Identificador de la respuesta."
                },
                "installer_id": {
                  "type": "string",
                  "description": "Alias del instalador que responde."
                },
                "answer": {
                  "type": "string",
                  "description": "Texto de la respuesta."
                },
                "created_at": {
                  "type": "string",
                  "format": "date-time",
                  "description": "Cuándo se respondió."
                }
              }
            }
          }
        }
      },
      "CotizacionesDelProyecto": {
        "type": "object",
        "description": "Lo que ve el dueño de un proyecto sobre su licitación.",
        "properties": {
          "quotations": {
            "type": "array",
            "description": "Cotizaciones visibles en el estado actual del proyecto.",
            "items": {
              "$ref": "#/components/schemas/Cotizacion"
            }
          },
          "identities": {
            "type": "object",
            "description": "Identidad real de los instaladores desbloqueados, indexada por su alias. Vacío mientras ninguno haya cubierto su fee y no haya ganador.",
            "additionalProperties": {
              "type": "object"
            }
          },
          "ratings": {
            "type": "object",
            "description": "Reputación de los instaladores desbloqueados, indexada por su alias. La estrella es parte de lo que el fee compra: con identidad reservada no viaja.",
            "additionalProperties": {
              "type": "object",
              "properties": {
                "avg": {
                  "type": "number",
                  "description": "Promedio de calificación."
                },
                "count": {
                  "type": "integer",
                  "description": "Cantidad de reseñas."
                }
              }
            }
          },
          "questions": {
            "type": "array",
            "description": "Hilo de preguntas y respuestas.",
            "items": {
              "$ref": "#/components/schemas/Pregunta"
            }
          }
        }
      },
      "MetadatosDeRecursoProtegido": {
        "type": "object",
        "description": "Metadatos de recurso protegido de la RFC 9728: el catálogo de alcances con nombre y el servidor de autorización que emite la sesión.",
        "required": [
          "resource",
          "scopes_supported"
        ],
        "properties": {
          "resource": {
            "type": "string",
            "format": "uri",
            "description": "Identificador del recurso protegido."
          },
          "resource_name": {
            "type": "string",
            "description": "Nombre legible del recurso."
          },
          "authorization_servers": {
            "type": "array",
            "description": "Emisores de la sesión. Cada uno publica su propio openid-configuration.",
            "items": {
              "type": "string",
              "format": "uri"
            }
          },
          "scopes_supported": {
            "type": "array",
            "description": "Catálogo de alcances con nombre que entiende esta API.",
            "items": {
              "type": "string",
              "enum": [
                "read:metadatos",
                "read:sandbox",
                "write:eventos",
                "write:resenas",
                "read:cotizaciones",
                "write:licitacion",
                "read:acuerdos",
                "write:acuerdos",
                "write:instalacion"
              ]
            }
          },
          "bearer_methods_supported": {
            "type": "array",
            "description": "Vacío a propósito: la sesión viaja en cookie, no en Authorization: Bearer. Se declara así en vez de mentir con [\"header\"].",
            "items": {
              "type": "string"
            }
          },
          "resource_documentation": {
            "type": "string",
            "format": "uri",
            "description": "Documentación para humanos."
          }
        }
      },
      "IndiceDeSandbox": {
        "type": "object",
        "description": "Qué hay en el entorno de pruebas y con qué identificadores ejercitarlo.",
        "properties": {
          "entorno": {
            "type": "string",
            "description": "Siempre \"sandbox\"."
          },
          "descripcion": {
            "type": "string",
            "description": "Qué es y qué no es este entorno."
          },
          "sin_llave": {
            "type": "boolean",
            "description": "Si se puede llamar sin credencial alguna."
          },
          "escritura": {
            "type": "boolean",
            "description": "Si acepta operaciones de escritura. Hoy no."
          },
          "identificadores": {
            "type": "object",
            "description": "Los identificadores sintéticos con los que probar.",
            "additionalProperties": {
              "type": "string"
            }
          },
          "operaciones": {
            "type": "array",
            "description": "Operaciones disponibles y qué operación real espeja cada una.",
            "items": {
              "type": "object",
              "properties": {
                "metodo": {
                  "type": "string",
                  "description": "Método HTTP."
                },
                "ruta": {
                  "type": "string",
                  "format": "uri",
                  "description": "URL completa, lista para llamar."
                },
                "espeja": {
                  "type": "string",
                  "description": "Operación real cuya forma reproduce."
                }
              }
            }
          }
        }
      },
      "Acuerdo": {
        "type": "object",
        "description": "Acuerdo de adjudicación bipartito.",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "Identificador del acuerdo."
          },
          "status": {
            "type": "string",
            "description": "Estado del acuerdo."
          },
          "hash": {
            "type": "string",
            "description": "SHA-256 del PDF vigente."
          },
          "client_signed_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Cuándo firmó el cliente."
          },
          "installer_signed_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Cuándo firmó el instalador."
          }
        }
      }
    }
  },
  "security": [
    {
      "sesionDeSupabase": []
    }
  ]
}