{
  "openapi": "3.1.0",
  "info": {
    "title": "Bloques API",
    "version": "1.0.0",
    "description": "REST API for Bloques — Peruvian electronic invoicing (SUNAT). Emits facturas (01) and boletas (03) through the SmartPSE PSE (emission is ASYNCHRONOUS: POST returns 202 with the document in `processing` and SUNAT's verdict arrives shortly after — poll, or send `Prefer: wait=N`), and exposes documents, files (PDF / signed XML / CDR), products, customers, series, usage and company profile.\n\nCRITICAL RULE: every price sent to the API (`unit_price` in items and products) is a FINAL price with IGV 18% already included. Bloques back-calculates the base (base = total / 1.18). Never send pre-tax prices.\n\nAuthentication: `Authorization: Bearer sk_live_…` tokens created in the web app (Configuración → API), with per-token scopes. Rate limit: 120 requests/minute/token. CORS enabled. Monetary amounts in responses are strings with 2 decimals. Error messages are in Spanish.",
    "contact": {
      "url": "https://enbloques.com/docs"
    }
  },
  "servers": [
    {
      "url": "https://enbloques.com/api/v1",
      "description": "Production"
    }
  ],
  "security": [
    {
      "bearerAuth": []
    }
  ],
  "tags": [
    {
      "name": "documents",
      "description": "Electronic documents (facturas and boletas): emission, queries, files and CSV export"
    },
    {
      "name": "quotes",
      "description": "Quotes (cotizaciones): priced documents never sent to SUNAT, convertible into real documents"
    },
    {
      "name": "products",
      "description": "Product catalog (final prices, IGV included)"
    },
    {
      "name": "customers",
      "description": "Customer directory"
    },
    {
      "name": "company",
      "description": "Series, monthly usage and company profile"
    }
  ],
  "paths": {
    "/documents": {
      "post": {
        "tags": [
          "documents"
        ],
        "operationId": "createDocument",
        "summary": "Create a document and queue it for emission (202)",
        "description": "Creates a factura or boleta, persists it as `processing` with its serie-correlativo already consumed, and QUEUES the signing + transmission to SUNAT. Responds **202 Accepted** with a `Location` header pointing at the document; the verdict (`accepted` / `rejected` / `error`) arrives seconds later and is retried automatically on transport failures. Send `Prefer: wait=N` (RFC 7240, max 30 s) to block for the verdict: then the response is 201 with the settled document and a `Preference-Applied` header, or still 202 if SUNAT is slow.\n\nA document that comes back `processing` is NOT a failure: never re-emit it (that would create a second comprobante) — poll `GET /documents/{id}`. While `processing`, `files.pdf` is already set and `GET /pdf` answers 200 (the PDF is rendered from the stored rows and its QR is complete — the Valor Resumen is a digest of the XML, not something SUNAT returns); `files.xml`/`cdr`/`zip` stay null and those three endpoints answer 409 `document_processing`.\n\nA `factura` requires a customer with RUC (doc_type \"6\"); a `boleta` must not have a RUC customer (omit `customer` for walk-in CLIENTES VARIOS), but a boleta whose PEN total exceeds S/ 700 must identify the buyer (SUNAT rule — otherwise 400 customer_invalid). Prices are FINAL with IGV included. Counts against the monthly plan limit. PRODUCTION EMISSION: every accepted document is fiscally binding.",
        "security": [
          {
            "bearerAuth": [
              "documents:write"
            ]
          }
        ],
        "parameters": [
          {
            "name": "Prefer",
            "in": "header",
            "required": false,
            "description": "`wait=N` (RFC 7240, seconds, capped at 30) asks the server to hold the request until SUNAT answers. It is a preference, not a guarantee: on timeout you still get 202. When honoured, the response carries `Preference-Applied: wait=N`.",
            "schema": {
              "type": "string",
              "example": "wait=25"
            }
          },
          {
            "name": "Idempotency-Key",
            "in": "header",
            "required": false,
            "description": "Safe-retry key (max 100 chars; use your business event ID, e.g. the order ID). Repeating a key already used by the company returns HTTP 200 with the ORIGINAL document and the `Idempotent-Replay: true` header instead of emitting again — regardless of the original document's status. Retry network failures with the SAME key. A document still in `processing` needs no retry at all — poll it. A document that settled as \"error\" is resendable with its own number (POST /documents/{id}/resend).",
            "schema": {
              "type": "string",
              "maxLength": 100
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CreateDocumentRequest"
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Document created and queued. `status` is `processing`; the `Location` header points at the document. Poll `GET /documents/{id}` for the verdict. When the document was emitted from a caja that has an NFC link, a top-level `nfc_window` object is included: the ~60-second window in which the customer can tap the caja's tag to download the receipt (it opens right away — the PDF already exists).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Document"
                }
              }
            },
            "headers": {
              "Location": {
                "description": "URL of the created document.",
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "201": {
            "description": "Only with `Prefer: wait`: the verdict arrived within the deadline. `status` is `accepted`, `rejected` or `error`. A rejection is still a 201 — the document exists.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Document"
                }
              }
            },
            "headers": {
              "Preference-Applied": {
                "description": "`wait=N`, echoing the honoured preference.",
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "200": {
            "description": "Idempotent replay: the Idempotency-Key was already used; the original document is returned and nothing new is emitted.",
            "headers": {
              "Idempotent-Replay": {
                "description": "Always \"true\" on replays.",
                "schema": {
                  "type": "string",
                  "enum": [
                    "true"
                  ]
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Document"
                }
              }
            }
          },
          "400": {
            "description": "`invalid_json` (body is not JSON), `validation` (field errors in `details`), or `customer_invalid` (factura without RUC, boleta with RUC, invalid identity document, unknown customer id).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "description": "`plan_limit`: monthly plan limit reached (details: used, limit, plan).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "409": {
            "description": "`company_not_ready`: the company has not completed PSE onboarding.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "`series_not_found` (series missing, inactive or wrong prefix for the type), `product_not_found` (item references a missing/inactive product), `modifier_not_found` (item references a modifier that does not exist, is inactive, or belongs to another product or company) or `extra_not_found` (same four cases for an `extra_ids` entry).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "description": "`internal`: unexpected server error.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "get": {
        "tags": [
          "documents"
        ],
        "operationId": "listDocuments",
        "summary": "List documents (filters + pagination, newest first)",
        "description": "Returns documents WITHOUT line items (fetch a single document for items).",
        "security": [
          {
            "bearerAuth": [
              "documents:read"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/FromParam"
          },
          {
            "$ref": "#/components/parameters/ToParam"
          },
          {
            "$ref": "#/components/parameters/TypeParam"
          },
          {
            "$ref": "#/components/parameters/StatusParam"
          },
          {
            "$ref": "#/components/parameters/QParam"
          },
          {
            "$ref": "#/components/parameters/PageParam"
          },
          {
            "name": "per_page",
            "in": "query",
            "description": "Page size. Default 25, max 100.",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100,
              "default": 25
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Paginated document list.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/DocumentList"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/documents/{id}": {
      "get": {
        "tags": [
          "documents"
        ],
        "operationId": "getDocument",
        "summary": "Get one document (includes line items)",
        "security": [
          {
            "bearerAuth": [
              "documents:read"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/DocumentId"
          }
        ],
        "responses": {
          "200": {
            "description": "The document, including `items`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Document"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/documents/{id}/resend": {
      "post": {
        "tags": [
          "documents"
        ],
        "operationId": "resendDocument",
        "summary": "Re-send a rejected document reusing its serie-correlativo",
        "description": "Re-emits a document that SUNAT rejected (status `rejected`) or that never transmitted (status `error`), reusing the SAME serie-correlativo: a rejected comprobante legally never existed, so its number may be reused. Send the corrected body (same shape as POST /documents; `type` and `series` are ignored — the document's identity is immutable). An `accepted` document cannot be resent (409 `not_resendable` — SUNAT would answer 1033 \"el comprobante ya fue informado\"). Re-runs every validation and the plan-limit check; a resend that ends `accepted` consumes one unit of the monthly quota, a re-rejection releases it (invariant: only accepted documents count). Prices are FINAL with IGV included. PRODUCTION EMISSION: an accepted resend is fiscally binding.\n\nLike a first emission, this QUEUES the transmission: it answers 202 with the document back in `processing`, or 200 with the settled document when `Prefer: wait=N` is honoured.",
        "security": [
          {
            "bearerAuth": [
              "documents:write"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/DocumentId"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CreateDocumentRequest"
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Resend queued. `status` is `processing`; poll `GET /documents/{id}` for the verdict.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Document"
                }
              }
            }
          },
          "200": {
            "description": "Document re-emitted reusing its number. `status` is `accepted` or `rejected` (a SUNAT rejection re-persists the document and may be resent again). If the customer email failed, a top-level `email_error` string is included. An accepted resend from a caja with an NFC link also includes `nfc_window` (see POST /documents).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Document"
                }
              }
            }
          },
          "400": {
            "description": "`invalid_json` (body is not JSON), `validation` (field errors in `details`), or `customer_invalid` (factura without RUC, boleta with RUC, invalid identity document, unknown customer id).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "description": "`plan_limit`: monthly plan limit reached (details: used, limit, plan).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "description": "`not_resendable`: the document is `accepted` (immutable — SUNAT 1033) or already `processing` (a resend is in flight), so its number can't be reused. Also `company_not_ready`: the company has not completed PSE onboarding.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "`series_not_found` (series missing, inactive or wrong prefix for the type), `product_not_found` (item references a missing/inactive product), `modifier_not_found` (item references a modifier that does not exist, is inactive, or belongs to another product or company) or `extra_not_found` (same four cases for an `extra_ids` entry).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "description": "`internal`: unexpected server error.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/documents/{id}/pdf": {
      "get": {
        "tags": [
          "documents"
        ],
        "operationId": "getDocumentPdf",
        "summary": "Download the printable PDF",
        "description": "Returns `application/pdf` as attachment `{file_name}.pdf`. Without `template`, serves the company's default template (cached). With `template`, re-renders on the fly. **Available from the moment the document is created**, `processing` included: the PDF is rendered from the stored rows and its QR already carries the Valor Resumen, which Bloques computes from the XML rather than receiving from SUNAT. A document in `processing` is re-rendered on every request (never cached) so that a later rejection shows its mark.",
        "security": [
          {
            "bearerAuth": [
              "documents:read"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/DocumentId"
          },
          {
            "name": "template",
            "in": "query",
            "description": "PDF template override.",
            "schema": {
              "type": "string",
              "enum": [
                "clasica",
                "moderna",
                "minimal",
                "lino",
                "oscura",
                "ticket80"
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The PDF file.",
            "content": {
              "application/pdf": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "description": "`pdf_failed`: the PDF could not be rendered.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/documents/{id}/xml": {
      "get": {
        "tags": [
          "documents"
        ],
        "operationId": "getDocumentXml",
        "summary": "Download the signed XML or the raw unsigned XML",
        "description": "Default: the signed XML as `application/xml` (attachment `{file_name}.xml`) — the legally valid file. With `unsigned=true`: the raw unsigned UBL XML. Use `/documents/{id}/zip` to get the signed XML and the CDR in a single download. Returns 409 `document_processing` while the document is still in `processing` (the PDF is not gated this way).",
        "security": [
          {
            "bearerAuth": [
              "documents:read"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/DocumentId"
          },
          {
            "name": "unsigned",
            "in": "query",
            "description": "`true` returns the raw unsigned UBL XML instead of the signed one, as attachment `{file_name}-sin-firmar.xml`.",
            "schema": {
              "type": "boolean",
              "default": false
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Signed XML (default) or unsigned XML (`unsigned=true`).",
            "content": {
              "application/xml": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "`not_found` (document missing / other company) or `file_not_found` (the requested file does not exist, e.g. signing never happened).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "$ref": "#/components/responses/NotFound",
            "description": "`document_processing` — SmartPSE has not returned this file yet (it only exists once SUNAT answered). 409 and not 404 on purpose: the document exists, its verdict does not. Poll `GET /documents/{id}` and retry. The PDF, unlike these three, is served from the start."
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/documents/{id}/cdr": {
      "get": {
        "tags": [
          "documents"
        ],
        "operationId": "getDocumentCdr",
        "summary": "Download SUNAT's CDR (constancia de recepción, XML)",
        "description": "Returns `application/xml` as attachment `{file_name}-cdr.xml`. The CDR only exists once SUNAT answered (status accepted or rejected); `files.cdr` is null otherwise. Returns 409 `document_processing` while the document is still in `processing` (the PDF is not gated this way).",
        "security": [
          {
            "bearerAuth": [
              "documents:read"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/DocumentId"
          }
        ],
        "responses": {
          "200": {
            "description": "The CDR XML.",
            "content": {
              "application/xml": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "`not_found` (document missing / other company) or `file_not_found` (no CDR for this document).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "$ref": "#/components/responses/NotFound",
            "description": "`document_processing` — SmartPSE has not returned this file yet (it only exists once SUNAT answered). 409 and not 404 on purpose: the document exists, its verdict does not. Poll `GET /documents/{id}` and retry. The PDF, unlike these three, is served from the start."
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/documents/{id}/zip": {
      "get": {
        "tags": [
          "documents"
        ],
        "operationId": "getDocumentZip",
        "summary": "Download the signed XML and the CDR in one ZIP",
        "description": "Returns `application/zip` as attachment `{file_name}.zip`, holding `{file_name}.xml` (signed) and `{file_name}-cdr.xml` (CDR). Includes whichever files exist — a rejected document is signed but has no CDR — and 404s only when neither does. This is the download the app offers. Returns 409 `document_processing` while the document is still in `processing` (the PDF is not gated this way).",
        "security": [
          {
            "bearerAuth": [
              "documents:read"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/DocumentId"
          }
        ],
        "responses": {
          "200": {
            "description": "The ZIP archive.",
            "content": {
              "application/zip": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "`not_found` (document missing / other company) or `file_not_found` (neither a signed XML nor a CDR exists).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "$ref": "#/components/responses/NotFound",
            "description": "`document_processing` — SmartPSE has not returned this file yet (it only exists once SUNAT answered). 409 and not 404 on purpose: the document exists, its verdict does not. Poll `GET /documents/{id}` and retry. The PDF, unlike these three, is served from the start."
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/documents/export": {
      "get": {
        "tags": [
          "documents"
        ],
        "operationId": "exportDocuments",
        "summary": "Export documents as CSV",
        "description": "Same filters as the document list. UTF-8 CSV with BOM, attachment `documentos-{ruc}.csv`, max 5000 rows, newest first. Columns: full_number, type, status, issue_date, issue_time, due_date, currency, customer_doc_type, customer_doc_number, customer_name, total_gravado, total_exonerado, total_inafecto, total_igv, total_value, total, amount_in_words, sunat_hash, cdr_description, source, emailed_to, created_at, id.",
        "security": [
          {
            "bearerAuth": [
              "documents:read"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/FromParam"
          },
          {
            "$ref": "#/components/parameters/ToParam"
          },
          {
            "$ref": "#/components/parameters/TypeParam"
          },
          {
            "$ref": "#/components/parameters/StatusParam"
          },
          {
            "$ref": "#/components/parameters/QParam"
          }
        ],
        "responses": {
          "200": {
            "description": "The CSV file.",
            "content": {
              "text/csv": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/quotes": {
      "post": {
        "tags": [
          "quotes"
        ],
        "operationId": "createQuote",
        "summary": "Create a quote (cotización)",
        "description": "Creates a priced document that is NEVER sent to SUNAT: no UBL, no signing, no correlativo, and it does NOT count against the plan limit. Gets a sequential reference like COT-0001. Prices are FINAL with IGV included. Convert it later into a real document by emitting with `from_quote_id`.",
        "security": [
          {
            "bearerAuth": [
              "quotes:write"
            ]
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CreateQuoteRequest"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The created quote, including `items`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Quote"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      },
      "get": {
        "tags": [
          "quotes"
        ],
        "operationId": "listQuotes",
        "summary": "List quotes (filters + pagination, newest first)",
        "description": "Returns quotes WITHOUT line items (fetch a single quote for items).",
        "security": [
          {
            "bearerAuth": [
              "quotes:read"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/FromParam"
          },
          {
            "$ref": "#/components/parameters/ToParam"
          },
          {
            "name": "converted",
            "in": "query",
            "description": "Filter by conversion state (`true` = converted, `false` = not yet).",
            "schema": {
              "type": "boolean"
            }
          },
          {
            "$ref": "#/components/parameters/QParam"
          },
          {
            "$ref": "#/components/parameters/PageParam"
          },
          {
            "name": "per_page",
            "in": "query",
            "description": "Page size. Default 25, max 100.",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100,
              "default": 25
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Paginated quote list.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/QuoteList"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/quotes/{id}": {
      "get": {
        "tags": [
          "quotes"
        ],
        "operationId": "getQuote",
        "summary": "Get one quote (includes line items)",
        "security": [
          {
            "bearerAuth": [
              "quotes:read"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/QuoteId"
          }
        ],
        "responses": {
          "200": {
            "description": "The quote, including `items`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Quote"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      },
      "put": {
        "tags": [
          "quotes"
        ],
        "operationId": "updateQuote",
        "summary": "Replace an editable quote",
        "description": "Replaces the quote's contents (its `code` is preserved). Same body as create. Fails with 400 if the quote has already been converted.",
        "security": [
          {
            "bearerAuth": [
              "quotes:write"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/QuoteId"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CreateQuoteRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The updated quote, including `items`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Quote"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      },
      "delete": {
        "tags": [
          "quotes"
        ],
        "operationId": "deleteQuote",
        "summary": "Delete a quote",
        "description": "Deletes the quote. Fails with 409 if it has already been converted into a document.",
        "security": [
          {
            "bearerAuth": [
              "quotes:write"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/QuoteId"
          }
        ],
        "responses": {
          "200": {
            "description": "Deletion acknowledgment.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "deleted": {
                      "type": "boolean"
                    },
                    "id": {
                      "type": "string",
                      "format": "uuid"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "description": "`validation`: the quote was already converted and cannot be deleted.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/quotes/{id}/pdf": {
      "get": {
        "tags": [
          "quotes"
        ],
        "operationId": "getQuotePdf",
        "summary": "Download the quote PDF",
        "description": "Returns `application/pdf` as attachment `{code}.pdf`. Rendered on demand (never cached). `?template=clasica` (default), `minimal` or `lino`.",
        "security": [
          {
            "bearerAuth": [
              "quotes:read"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/QuoteId"
          },
          {
            "name": "template",
            "in": "query",
            "description": "PDF template.",
            "schema": {
              "type": "string",
              "enum": [
                "clasica",
                "minimal",
                "lino",
                "oscura"
              ],
              "default": "clasica"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The PDF file.",
            "content": {
              "application/pdf": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/products": {
      "post": {
        "tags": [
          "products"
        ],
        "operationId": "createProduct",
        "summary": "Create a catalog product",
        "security": [
          {
            "bearerAuth": [
              "products:write"
            ]
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ProductCreateRequest"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Product created.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Product"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "409": {
            "description": "`duplicate_code`: another product already uses that code.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "`invalid_image_key`: one of the `images[].key` values was not uploaded by this company (it must live under this company's own prefix — re-upload it through POST /api/products/images and send back the reference verbatim).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      },
      "get": {
        "tags": [
          "products"
        ],
        "operationId": "listProducts",
        "summary": "List products (active only by default, ordered by code)",
        "security": [
          {
            "bearerAuth": [
              "products:read"
            ]
          }
        ],
        "parameters": [
          {
            "name": "q",
            "in": "query",
            "description": "Search by name or code.",
            "schema": {
              "type": "string",
              "maxLength": 100
            }
          },
          {
            "name": "include_inactive",
            "in": "query",
            "description": "`true` includes deactivated products.",
            "schema": {
              "type": "boolean",
              "default": false
            }
          },
          {
            "$ref": "#/components/parameters/PageParam"
          },
          {
            "$ref": "#/components/parameters/PerPage50Param"
          }
        ],
        "responses": {
          "200": {
            "description": "Paginated product list.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProductList"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/products/{id}": {
      "get": {
        "tags": [
          "products"
        ],
        "operationId": "getProduct",
        "summary": "Get one product",
        "security": [
          {
            "bearerAuth": [
              "products:read"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/ProductId"
          }
        ],
        "responses": {
          "200": {
            "description": "The product.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Product"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      },
      "put": {
        "tags": [
          "products"
        ],
        "operationId": "updateProduct",
        "summary": "Update a product (partial)",
        "description": "Send only the fields to change. Already-emitted documents keep their item snapshots.",
        "security": [
          {
            "bearerAuth": [
              "products:write"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/ProductId"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ProductUpdateRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated product.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Product"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "description": "`duplicate_code`: the new code already belongs to another product.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "`invalid_image_key`: one of the `images[].key` values was not uploaded by this company (it must live under this company's own prefix — re-upload it through POST /api/products/images and send back the reference verbatim).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      },
      "delete": {
        "tags": [
          "products"
        ],
        "operationId": "deactivateProduct",
        "summary": "Deactivate a product (soft delete)",
        "description": "Marks the product inactive (`active: false`) and returns it. It disappears from default listings and can no longer be used in emissions; history is untouched. Reactivate via PUT with `{\"active\": true}`.",
        "security": [
          {
            "bearerAuth": [
              "products:write"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/ProductId"
          }
        ],
        "responses": {
          "200": {
            "description": "Deactivated product.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Product"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/customers": {
      "post": {
        "tags": [
          "customers"
        ],
        "operationId": "createCustomer",
        "summary": "Create or update (upsert) a customer",
        "description": "Name is required. Document is optional: omit doc_type/doc_number (or send doc_type \"0\" with no number) to catalogue a nameless row (doc_number null); a type without its number, a number without its type, or doc_type \"0\" WITH a number are all 400. Identified customers upsert keyed by doc_type + doc_number: if the customer exists, name/email/phone/address are overwritten with what is sent (absent → cleared). `custom_fields` is the exception: absent → stored values are PRESERVED; present → validated against the company's active field definitions and replaced wholesale (400 custom_fields_invalid on mismatch; requires the campos-personalizados module, else 403 module_not_enabled). Always returns 201. Note: emitting a document with inline customer data also upserts the directory (and never touches custom fields). Nameless rows never collide with each other (Postgres treats NULL as distinct in the unique index).",
        "security": [
          {
            "bearerAuth": [
              "customers:write"
            ]
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CustomerCreateRequest"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Customer created or updated.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Customer"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      },
      "get": {
        "tags": [
          "customers"
        ],
        "operationId": "listCustomers",
        "summary": "List/search customers (ordered by name)",
        "security": [
          {
            "bearerAuth": [
              "customers:read"
            ]
          }
        ],
        "parameters": [
          {
            "name": "q",
            "in": "query",
            "description": "Search by name, nickname or document number.",
            "schema": {
              "type": "string",
              "maxLength": 100
            }
          },
          {
            "$ref": "#/components/parameters/PageParam"
          },
          {
            "$ref": "#/components/parameters/PerPage50Param"
          }
        ],
        "responses": {
          "200": {
            "description": "Paginated customer list.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CustomerList"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/customers/{id}": {
      "get": {
        "tags": [
          "customers"
        ],
        "operationId": "getCustomer",
        "summary": "One customer by id",
        "security": [
          {
            "bearerAuth": [
              "customers:read"
            ]
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "Customer id."
          }
        ],
        "responses": {
          "200": {
            "description": "The customer.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Customer"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      },
      "patch": {
        "tags": [
          "customers"
        ],
        "operationId": "updateCustomer",
        "summary": "Edit a customer (a document can only be added, never changed)",
        "description": "Partial update: absent fields are untouched; `null` clears email/phone/address. `doc_type`/`doc_number` are accepted **only to give a document to a customer that has none** (a name-only row): send both together. Changing or removing a document the customer already has returns 409 `identity_locked` — that pair keys the directory upsert, and rewriting it would move sales already made to a different person. If another customer already holds that document, 409 `document_taken`. Documents already issued are never rewritten: their frozen snapshot still reads SIN DOCUMENTO. `custom_fields`, when present, is validated against the active definitions and replaces the stored object wholesale (requires the campos-personalizados module).",
        "security": [
          {
            "bearerAuth": [
              "customers:write"
            ]
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "Customer id."
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CustomerUpdateRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated customer.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Customer"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "description": "The document cannot be changed (identity_locked) or already belongs to another customer (document_taken)."
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      },
      "delete": {
        "tags": [
          "customers"
        ],
        "operationId": "deleteCustomer",
        "summary": "Delete a customer",
        "description": "Removes the customer from the directory. Hard delete, not reversible — unlike products, which are only deactivated. Already issued documents, quotes, sales notes and bookings are untouched: each keeps its own immutable copy of the customer data and simply loses the live link. Emitting again to the same document number recreates the customer.",
        "security": [
          {
            "bearerAuth": [
              "customers:write"
            ]
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "Customer id."
          }
        ],
        "responses": {
          "200": {
            "description": "Deletion acknowledgment.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "deleted": {
                      "type": "boolean"
                    },
                    "id": {
                      "type": "string",
                      "format": "uuid"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/series": {
      "get": {
        "tags": [
          "company"
        ],
        "operationId": "listSeries",
        "summary": "Numbering series per document type",
        "description": "Accepts any valid token (no scope required).",
        "responses": {
          "200": {
            "description": "Series with their next correlative.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SeriesList"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/usage": {
      "get": {
        "tags": [
          "company"
        ],
        "operationId": "getUsage",
        "summary": "Current month's consumption vs plan limit",
        "description": "America/Lima calendar month. Documents with status accepted, rejected or processing count; `error` does not. Accepts any valid token (no scope required).",
        "responses": {
          "200": {
            "description": "Usage summary.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Usage"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/company": {
      "get": {
        "tags": [
          "company"
        ],
        "operationId": "getCompany",
        "summary": "Public profile of the authenticated company",
        "description": "Never exposes PSE credentials. Accepts any valid token (no scope required).",
        "responses": {
          "200": {
            "description": "Company profile.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Company"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "sk_live_…",
        "description": "API token created in Bloques → Configuración → API (shown once at creation; token management is session-only by design). Scopes: *, documents:read, documents:write, quotes:read, quotes:write, products:read, products:write, customers:read, customers:write, expenses:write. expenses:write has no v1 REST endpoint yet; only the MCP tool create_expense consumes it. Rate limit: 120 requests/minute/token."
      }
    },
    "parameters": {
      "DocumentId": {
        "name": "id",
        "in": "path",
        "required": true,
        "description": "Document UUID.",
        "schema": {
          "type": "string",
          "format": "uuid"
        }
      },
      "ProductId": {
        "name": "id",
        "in": "path",
        "required": true,
        "description": "Product UUID.",
        "schema": {
          "type": "string",
          "format": "uuid"
        }
      },
      "QuoteId": {
        "name": "id",
        "in": "path",
        "required": true,
        "description": "Quote UUID.",
        "schema": {
          "type": "string",
          "format": "uuid"
        }
      },
      "FromParam": {
        "name": "from",
        "in": "query",
        "description": "Issue date lower bound (YYYY-MM-DD, inclusive).",
        "schema": {
          "type": "string",
          "format": "date"
        }
      },
      "ToParam": {
        "name": "to",
        "in": "query",
        "description": "Issue date upper bound (YYYY-MM-DD, inclusive).",
        "schema": {
          "type": "string",
          "format": "date"
        }
      },
      "TypeParam": {
        "name": "type",
        "in": "query",
        "description": "Document type filter.",
        "schema": {
          "type": "string",
          "enum": [
            "factura",
            "boleta"
          ]
        }
      },
      "StatusParam": {
        "name": "status",
        "in": "query",
        "description": "Status filter.",
        "schema": {
          "type": "string",
          "enum": [
            "accepted",
            "rejected",
            "error",
            "processing"
          ]
        }
      },
      "QParam": {
        "name": "q",
        "in": "query",
        "description": "Free-text search over full number, customer name and customer document (max 100 chars).",
        "schema": {
          "type": "string",
          "maxLength": 100
        }
      },
      "PageParam": {
        "name": "page",
        "in": "query",
        "description": "Page number, starting at 1.",
        "schema": {
          "type": "integer",
          "minimum": 1,
          "default": 1
        }
      },
      "PerPage50Param": {
        "name": "per_page",
        "in": "query",
        "description": "Page size. Default 50, max 100.",
        "schema": {
          "type": "integer",
          "minimum": 1,
          "maximum": 100,
          "default": 50
        }
      }
    },
    "responses": {
      "BadRequest": {
        "description": "`invalid_json` (body is not valid JSON) or `validation` (field errors; `details` is an array of `{path, message}`).",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "Unauthorized": {
        "description": "`invalid_token` (missing, malformed, revoked or orphaned token) or `unauthorized` (no credentials).",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "Forbidden": {
        "description": "`insufficient_scope` (the token lacks the scope this endpoint requires) or, on the /quotes endpoints, `module_not_enabled` (the company does not have the Cotizaciones module enabled).",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "NotFound": {
        "description": "`not_found`: the resource does not exist or belongs to another company.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "RateLimited": {
        "description": "`rate_limited`: more than 120 requests per minute with this token.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      }
    },
    "schemas": {
      "Error": {
        "type": "object",
        "required": [
          "error"
        ],
        "properties": {
          "error": {
            "type": "object",
            "required": [
              "code",
              "message"
            ],
            "properties": {
              "code": {
                "type": "string",
                "description": "Machine-readable error code.",
                "examples": [
                  "validation",
                  "customer_invalid",
                  "plan_limit",
                  "series_not_found",
                  "product_not_found",
                  "company_not_ready",
                  "provider_error",
                  "invalid_token",
                  "insufficient_scope",
                  "module_not_enabled",
                  "rate_limited",
                  "not_found",
                  "file_not_found",
                  "duplicate_code",
                  "duplicate_modifier_name",
                  "duplicate_modifier_code",
                  "modifier_not_found",
                  "extra_not_found",
                  "invalid_json",
                  "pdf_failed",
                  "internal"
                ]
              },
              "message": {
                "type": "string",
                "description": "Human-readable message (Spanish)."
              },
              "details": {
                "description": "Optional extra context. For `validation`: array of {path, message}. For `plan_limit`: {used, limit, plan}."
              }
            }
          }
        }
      },
      "CustomerInput": {
        "type": "object",
        "description": "Customer for a document. Either `id` (existing customer) or inline fields. Name-only (OMIT doc_number) catalogues a nameless directory row (doc_type \"0\") and is valid on quotes and sales notes; factura still requires RUC and boleta requires DNI/CE/passport on a named customer. Sending doc_type \"0\" WITH doc_number \"0\" is the walk-in sentinel: the name travels in the snapshot and nothing is catalogued. Half an identity (a number without its type, a type 1/4/6/7 without its number) is a 400, never a nameless row. Omit the whole object on boletas for CLIENTES VARIOS — unless the PEN total exceeds S/ 700, where SUNAT requires an identified buyer (omitting it returns 400 customer_invalid). Walk-in CLIENTES VARIOS is never catalogued.",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "Existing customer — mutually exclusive with the inline fields."
          },
          "doc_type": {
            "type": "string",
            "enum": [
              "0",
              "1",
              "4",
              "6",
              "7"
            ],
            "description": "Identity document: 0 none, 1 DNI, 4 carnet de extranjería, 6 RUC, 7 passport. factura requires \"6\"; boleta forbids \"6\"."
          },
          "doc_number": {
            "type": "string",
            "maxLength": 15,
            "description": "RUC: 11 digits with check digit. DNI: 8 digits. Omit it (with type \"0\" or no type) to catalogue a nameless customer; send \"0\" with type \"0\" for a named walk-in that is NOT catalogued."
          },
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 500
          },
          "email": {
            "type": "string",
            "format": "email",
            "maxLength": 320,
            "description": "Where the PDF + XML is emailed."
          },
          "address": {
            "type": "string",
            "maxLength": 500
          }
        }
      },
      "ItemInput": {
        "type": "object",
        "description": "One line. Reference a catalog product (product_id OR code) or describe a free-form line (description + unit_price). Product fields (price, unit, affectation, description) act as defaults that the inline fields override.",
        "properties": {
          "product_id": {
            "type": "string",
            "format": "uuid",
            "description": "Catalog product by id."
          },
          "code": {
            "type": "string",
            "maxLength": 50,
            "description": "Catalog product by your own code."
          },
          "modifier_id": {
            "type": "string",
            "format": "uuid",
            "description": "Product modifier (see `modifiers` on Product). REQUIRES product_id or code of the parent product. Takes the modifier's price and code and composes the line description as \"{product name} — {modifier name}\"; everything else (affectation, ISC, ICBPER, unit, currency) is inherited from the parent product. The modifier's OWN `price_tiers` apply (it never inherits the parent's) — but it DOES inherit the parent's `pool_tiers`, so \"Polo Niño\" 5 + \"Polo Adulto\" 7 reach the 12 threshold together, each read from its own table. An explicit unit_price or description on the item wins over the modifier. Modifiers are referenced by id only — their code is not a lookup key."
          },
          "extra_ids": {
            "type": "array",
            "maxItems": 30,
            "items": {
              "type": "string",
              "format": "uuid"
            },
            "description": "Ids of the product's `extras` (see `extras` on Product) charged on this line. Each must belong to that product, be active and be OFFERED for the chosen modifier (`prices[modifier_id] === null` = not offered → 400 `validation`; missing/inactive/other product/other company → 422 `extra_not_found`). Their per-unit amounts are SUMMED on top of the resolved unit price — also on top of an explicit `unit_price` — and the line description is composed as \"{description} + {extra} + {extra}\". REQUIRES product_id or code; no duplicates."
          },
          "description": {
            "type": "string",
            "minLength": 1,
            "maxLength": 500,
            "description": "Free-form line description (requires unit_price) or override of the product name."
          },
          "group_title": {
            "type": "string",
            "maxLength": 80,
            "description": "ITEM GROUP (section) this line belongs to — lets one document carry several separate jobs (\"Servicio web proyecto 1\" with 3 lines, \"Servicio ERP proyecto 2\" with 2). Repeat the SAME title on consecutive lines to group them: each group prints with a heading and its own subtotal, and the document still has ONE grand total. Purely presentational — the group never reaches the SUNAT XML and changes no amount."
          },
          "group_index": {
            "type": "integer",
            "minimum": 1,
            "description": "Explicit group ordinal. Only needed to keep two ADJACENT groups apart when they would share a title (or have none). Wins over group_title. Stored indices are renumbered 1..N by order of appearance, so they need not be contiguous or start at 1."
          },
          "quantity": {
            "type": "number",
            "exclusiveMinimum": 0,
            "default": 1,
            "description": "Up to 3 decimals."
          },
          "unit_price": {
            "type": "number",
            "minimum": 0,
            "description": "FINAL unit price the customer pays, IGV (18%) INCLUDED. 0 is allowed and means the line is FREE (a sample, a replacement, a promo item) — it is NOT the same as omitting the field, which prices the line from the catalog. Overrides the product price — and any volume tier — but the line still adds its quantity to the global tier count when its product has `pool_tiers`. Omit it to take the catalog price: that is the `price_tiers` entry matching this line's QUALIFYING quantity when the product (or the modifier, which has its own tiers and never inherits the parent's) defines them, and the plain `unit_price` otherwise. The qualifying quantity is this line's own `quantity`, UNLESS the product has `pool_tiers` on: then it is the SUM of the quantities of every line in the document whose product also has it on (7 shorts + 5 polos = 12 garments, each still priced from ITS OWN tiers). Passing unit_price always wins over any tier."
          },
          "unit_code": {
            "type": "string",
            "enum": [
              "NIU",
              "ZZ",
              "KGM",
              "GRM",
              "LTR",
              "MTR",
              "MTK",
              "MTQ",
              "CEN",
              "DZN",
              "BX",
              "PK",
              "BG",
              "BO",
              "GLL",
              "HUR",
              "DAY",
              "TNE",
              "SET",
              "PR"
            ],
            "description": "SUNAT catalog 03 unit. Default NIU (unidad); ZZ = servicio."
          },
          "affectation": {
            "type": "string",
            "enum": [
              "10",
              "20",
              "30",
              "11",
              "12",
              "13",
              "14",
              "15",
              "16",
              "21",
              "31",
              "32",
              "33",
              "34",
              "35",
              "36"
            ],
            "description": "IGV affectation (SUNAT catálogo 07). Onerous: 10 gravado (default), 20 exonerado, 30 inafecto. Transferencia gratuita (the line is given away): 11 premio, 12 donación, 13 retiro, 14 publicidad, 15 bonificación, 16 entrega a trabajadores (gravadas); 21 exonerada; 31 bonificación, 32 retiro, 33 muestras médicas, 34 convenio colectivo, 35 premio, 36 publicidad (inafectas). On a free line `unit_price` is the REFERENCE price (what it would have cost, IGV included when gravada) and still must be > 0: the comprobante is issued with price 0.00 and that value travels as valor referencial. Free lines take no isc_rate, disc_rate or icbper, and are only valid on facturas/boletas — a quote with one returns 422."
          },
          "isc_rate": {
            "type": "number",
            "minimum": 0,
            "maximum": 1,
            "description": "ISC al valor as a fraction (e.g. 0.10 = 10%); gravado lines only. Overrides the product. The final price still INCLUDES it — base/ISC/IGV are derived."
          },
          "disc_rate": {
            "type": "number",
            "minimum": 0,
            "exclusiveMaximum": 1,
            "description": "Descuento por línea (catálogo 53 código 00) as a fraction 0 ≤ d < 1; gravado lines only. Lowers the line base and IGV; it nets into LineExtensionAmount (no document-level AllowanceTotalAmount)."
          },
          "icbper": {
            "type": "boolean",
            "description": "Afecto a ICBPER (bolsa plástica): adds the fixed per-bag amount (S/ 0.50) on top of the price. Overrides the product default."
          },
          "attributed_staff_id": {
            "type": "string",
            "format": "uuid",
            "description": "Optional staff attribution (module `personal`): id of the staff member who served this line. Validated against the company's staff; silently dropped when the module is disabled. Never sent to SUNAT."
          }
        }
      },
      "PaymentInput": {
        "type": "object",
        "properties": {
          "type": {
            "type": "string",
            "enum": [
              "contado",
              "credito"
            ],
            "default": "contado"
          },
          "installments": {
            "type": "array",
            "maxItems": 36,
            "description": "Required when type is credito. Amounts must sum to the monto neto pendiente de pago (tolerance ±0.01): the document total, minus the detraction amount when the factura carries a `detraction` (RS 193-2020).",
            "items": {
              "type": "object",
              "required": [
                "amount",
                "due_date"
              ],
              "properties": {
                "amount": {
                  "type": "number",
                  "exclusiveMinimum": 0
                },
                "due_date": {
                  "type": "string",
                  "format": "date",
                  "pattern": "^\\d{4}-\\d{2}-\\d{2}$"
                }
              }
            }
          }
        }
      },
      "CreateDocumentRequest": {
        "type": "object",
        "required": [
          "type",
          "items"
        ],
        "properties": {
          "type": {
            "type": "string",
            "enum": [
              "factura",
              "boleta"
            ],
            "description": "factura = 01 (requires RUC customer); boleta = 03 (consumers)."
          },
          "series": {
            "type": "string",
            "pattern": "^[A-Z][A-Z0-9]{3}$",
            "description": "4-char series; prefix F for facturas, B for boletas. Defaults to the company's active series for the type (F001/B001)."
          },
          "cash_register_id": {
            "type": "string",
            "format": "uuid",
            "description": "Optional cash register (caja) to emit from. When set, the series is resolved per the company's series mode (company | per_branch | per_register) and the register + its branch are recorded on the document. If the mode requires a series assigned to the register/branch and none exists, emission fails with 422 series_not_found."
          },
          "issue_date": {
            "type": "string",
            "format": "date",
            "pattern": "^\\d{4}-\\d{2}-\\d{2}$",
            "description": "Defaults to today in America/Lima. Max 7 days in the past; never in the future."
          },
          "currency": {
            "type": "string",
            "enum": [
              "PEN",
              "USD"
            ],
            "default": "PEN"
          },
          "customer": {
            "$ref": "#/components/schemas/CustomerInput"
          },
          "items": {
            "type": "array",
            "minItems": 1,
            "maxItems": 100,
            "items": {
              "$ref": "#/components/schemas/ItemInput"
            }
          },
          "payment": {
            "$ref": "#/components/schemas/PaymentInput"
          },
          "notes": {
            "type": "string",
            "maxLength": 1000,
            "description": "Free text printed on the PDF (not part of the XML)."
          },
          "send_email": {
            "type": "boolean",
            "description": "Email the PDF + XML to the customer. Default: the company setting."
          },
          "collection_method": {
            "type": "string",
            "enum": [
              "efectivo",
              "transferencia",
              "yape",
              "plin",
              "tarjeta",
              "deposito",
              "otro"
            ],
            "description": "How the sale was collected. Internal business data: it does NOT reach SUNAT or the PDF — not to be confused with `payment` (the XML payment terms) or `detraction.payment_method` (catalogue 59). Omit to leave it unset."
          },
          "from_quote_id": {
            "type": "string",
            "format": "uuid",
            "description": "Source quote (cotización) UUID. On a SUNAT-accepted emission, that quote is stamped as converted (idempotent; ignored on rejected/error). Rebuild items/customer from the quote yourself."
          },
          "recargo_consumo": {
            "type": "object",
            "description": "Recargo al consumo (restaurants/bars): a surcharge with NO IGV that is added to the total. Overrides the company setting per document.",
            "properties": {
              "apply": {
                "type": "boolean",
                "description": "Enable/disable for this document. Default: the company setting."
              },
              "rate": {
                "type": "number",
                "minimum": 0,
                "maximum": 0.13,
                "description": "Rate as a fraction 0–0.13 (e.g. 0.05 = 5%). Default: the company setting."
              }
            }
          },
          "detraction": {
            "type": "object",
            "description": "Detracción (SPOT). Facturas only, and only if the company has a Banco de la Nación detraction account configured (Settings). It does NOT change any amount: the document is issued for the full total and the customer deposits the detracted share into the issuer's account. Adds catálogo 51 operation type 1001, the cac:PaymentMeans + cac:PaymentTerms blocks and leyenda 2006 to the XML.",
            "required": [
              "code"
            ],
            "properties": {
              "code": {
                "type": "string",
                "description": "Good/service subject to detraction — SUNAT catálogo 54.",
                "examples": [
                  "022"
                ]
              },
              "payment_method": {
                "type": "string",
                "description": "Deposit payment method — SUNAT catálogo 59. Default \"001\" (depósito en cuenta).",
                "default": "001",
                "examples": [
                  "001"
                ]
              },
              "percent": {
                "type": "number",
                "exclusiveMinimum": 0,
                "maximum": 100,
                "default": 12,
                "description": "Percentage, NOT a fraction (12 = 12%). Note this differs from recargo_consumo.rate, which is a fraction."
              },
              "amount": {
                "type": "number",
                "exclusiveMinimum": 0,
                "description": "Amount to detract, ALWAYS in PEN regardless of the document currency (SUNAT requires it in soles). Defaults to round2(total × percent / 100); REQUIRED when currency is not PEN, since the document carries no exchange rate."
              }
            }
          }
        }
      },
      "Detraction": {
        "type": "object",
        "description": "Detracción (SPOT) recorded on a document; null when the operation is not subject to it. Amounts are strings.",
        "required": [
          "code",
          "payment_method",
          "account",
          "percent",
          "amount"
        ],
        "properties": {
          "code": {
            "type": "string",
            "description": "SUNAT catálogo 54 good/service code.",
            "examples": [
              "022"
            ]
          },
          "payment_method": {
            "type": [
              "string",
              "null"
            ],
            "description": "SUNAT catálogo 59 payment method.",
            "examples": [
              "001"
            ]
          },
          "account": {
            "type": [
              "string",
              "null"
            ],
            "description": "Issuer's Banco de la Nación detraction account, as stamped at emission."
          },
          "percent": {
            "type": [
              "string",
              "null"
            ],
            "description": "Applied percentage (\"12.00\" = 12%) — same units as the request field, so this object can be sent back as-is when resending.",
            "examples": [
              "12.00"
            ]
          },
          "amount": {
            "type": [
              "string",
              "null"
            ],
            "description": "Detracted amount in PEN. Not subtracted from totals.total."
          }
        }
      },
      "CustomerSnapshot": {
        "type": "object",
        "description": "Customer data frozen at emission time. NOTE: camelCase keys. The directory `nickname` is deliberately NOT part of it — the snapshot is the fiscal identity that reaches SUNAT.",
        "required": [
          "docType",
          "docNumber",
          "name"
        ],
        "properties": {
          "docType": {
            "type": "string",
            "enum": [
              "0",
              "1",
              "4",
              "6",
              "7"
            ]
          },
          "docNumber": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "email": {
            "type": [
              "string",
              "null"
            ]
          },
          "address": {
            "type": [
              "string",
              "null"
            ]
          }
        }
      },
      "DocumentTotals": {
        "type": "object",
        "description": "All amounts are strings with 2 decimals.",
        "required": [
          "gravado",
          "exonerado",
          "inafecto",
          "igv",
          "total_value",
          "recargo_consumo",
          "total"
        ],
        "properties": {
          "gravado": {
            "type": "string",
            "description": "Sum of taxed line bases (without IGV)."
          },
          "exonerado": {
            "type": "string",
            "description": "Sum of exempt line bases."
          },
          "inafecto": {
            "type": "string",
            "description": "Sum of out-of-scope line bases."
          },
          "igv": {
            "type": "string",
            "description": "Total IGV."
          },
          "isc": {
            "type": "string",
            "description": "Total ISC al valor. \"0.00\" when no line carries ISC."
          },
          "icbper": {
            "type": "string",
            "description": "Total ICBPER (bolsas plásticas). \"0.00\" when nothing is afecto."
          },
          "descuento": {
            "type": "string",
            "description": "Total line discounts (catálogo 53 código 00). \"0.00\" when none. Already netted into gravado and total_value."
          },
          "gratuitas": {
            "type": "string",
            "description": "Total valor de venta – operaciones gratuitas (reference value of the free lines). Informed, NOT charged: outside total_value and total."
          },
          "igv_gratuitas": {
            "type": "string",
            "description": "IGV of the gravadas gratuitas. Informative — outside igv and total."
          },
          "total_value": {
            "type": "string",
            "description": "Sum of all line bases (net of line discounts)."
          },
          "recargo_consumo": {
            "type": "string",
            "description": "Recargo al consumo amount (no IGV). \"0.00\" when not applied."
          },
          "recargo_rate": {
            "type": [
              "string",
              "null"
            ],
            "description": "Applied recargo rate as a fraction (e.g. \"0.0500\"); null when not applied."
          },
          "total": {
            "type": "string",
            "description": "Grand total payable = goods with IGV + recargo — exactly what was charged."
          }
        }
      },
      "DocumentSunat": {
        "type": "object",
        "properties": {
          "hash": {
            "type": [
              "string",
              "null"
            ],
            "description": "Signature hash (printed under the QR code)."
          },
          "settled_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true,
            "description": "When the document left `processing` (SUNAT answered, or the attempts ran out). Null while still in flight."
          },
          "cdr_description": {
            "type": [
              "string",
              "null"
            ],
            "description": "SUNAT's CDR message (acceptance/rejection description)."
          },
          "observations": {
            "description": "SUNAT observations/errors (array) or null."
          },
          "error_message": {
            "type": [
              "string",
              "null"
            ],
            "description": "Set when status is \"error\" (PSE/transport failure)."
          }
        }
      },
      "DocumentFiles": {
        "type": "object",
        "description": "Download paths. **`pdf` is available from the moment the document is created**; `xml`, `cdr` and `zip` are null while `status` is `processing` and those endpoints return 409 `document_processing` until SUNAT answers.",
        "properties": {
          "pdf": {
            "type": "string",
            "nullable": true
          },
          "xml": {
            "type": "string",
            "nullable": true
          },
          "cdr": {
            "type": [
              "string",
              "null"
            ],
            "description": "null while no CDR exists.",
            "nullable": true
          },
          "zip": {
            "type": [
              "string",
              "null"
            ],
            "description": "Signed XML + CDR in one archive; null while neither exists.",
            "nullable": true
          }
        }
      },
      "DocumentItem": {
        "type": "object",
        "description": "Computed line item. quantity and amounts are strings.",
        "properties": {
          "position": {
            "type": "integer",
            "description": "1-based line position."
          },
          "group_index": {
            "type": "integer",
            "nullable": true,
            "description": "Item group (section) ordinal, 1..N; null when the line is not grouped."
          },
          "group_title": {
            "type": "string",
            "nullable": true,
            "description": "Item group label, repeated on every line of the group; null when unnamed or ungrouped."
          },
          "product_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid"
          },
          "modifier_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Product modifier used on this line; null when none. Traceability only — the snapshot fields (code, description, unit_price) are the truth."
          },
          "extras": {
            "type": "array",
            "description": "Extras charged on this line, frozen at sale time (id, name, per-unit amount; \"0.00\" = was free). `unit_price` already includes them and `description` already names them. Empty when none.",
            "items": {
              "type": "object",
              "required": [
                "id",
                "name",
                "amount"
              ],
              "properties": {
                "id": {
                  "type": "string",
                  "format": "uuid"
                },
                "name": {
                  "type": "string"
                },
                "amount": {
                  "type": "string",
                  "description": "Per-unit amount, 2 decimals"
                }
              }
            }
          },
          "code": {
            "type": [
              "string",
              "null"
            ]
          },
          "description": {
            "type": "string"
          },
          "unit_code": {
            "type": "string"
          },
          "quantity": {
            "type": "string",
            "description": "Up to 3 decimals, trailing zeros trimmed."
          },
          "unit_price": {
            "type": "string",
            "description": "Final unit price, IGV included (2 decimals)."
          },
          "unit_value": {
            "type": "string",
            "description": "Unit value without IGV (10 decimals)."
          },
          "line_base": {
            "type": "string",
            "description": "Line value without IGV, net of any line discount (2 decimals)."
          },
          "line_discount": {
            "type": "string",
            "description": "Line discount amount without IGV (catálogo 53 código 00, 2 decimals). \"0.00\" when none."
          },
          "disc_rate": {
            "type": [
              "string",
              "null"
            ],
            "description": "Discount rate as a fraction (e.g. \"0.1000\"); null when no discount."
          },
          "line_igv": {
            "type": "string",
            "description": "Line IGV (2 decimals)."
          },
          "line_isc": {
            "type": "string",
            "description": "Line ISC al valor (2 decimals). \"0.00\" when no ISC."
          },
          "isc_rate": {
            "type": [
              "string",
              "null"
            ],
            "description": "ISC rate as a fraction (e.g. \"0.1000\"); null when no ISC."
          },
          "line_icbper": {
            "type": "string",
            "description": "Line ICBPER (2 decimals). \"0.00\" when not afecto."
          },
          "unit_reference_value": {
            "type": "string",
            "description": "Transferencia gratuita: reference unit value, net of IGV (10 decimals). \"0\" on onerous lines."
          },
          "line_free_value": {
            "type": "string",
            "description": "Transferencia gratuita: reference value of the line (2 decimals) — what feeds totals.gratuitas. \"0.00\" on onerous lines."
          },
          "line_free_igv": {
            "type": "string",
            "description": "Transferencia gratuita: informative IGV of a gravada gratuita; never charged. \"0.00\" otherwise."
          },
          "line_total": {
            "type": "string",
            "description": "Line total charged including IGV (2 decimals); net of any line discount."
          },
          "affectation": {
            "type": "string",
            "enum": [
              "10",
              "20",
              "30"
            ]
          },
          "attributed_staff_id": {
            "type": "string",
            "format": "uuid",
            "description": "Optional staff attribution (module `personal`): id of the staff member who served this line. Validated against the company's staff; silently dropped when the module is disabled. Never sent to SUNAT."
          }
        }
      },
      "PaymentResponse": {
        "type": "object",
        "description": "Payment terms as stored. NOTE: capitalized type and camelCase dueDate.",
        "properties": {
          "type": {
            "type": "string",
            "enum": [
              "Contado",
              "Credito"
            ]
          },
          "amount": {
            "type": "string",
            "description": "Monto neto pendiente de pago (credito only): the document total, minus the detraction when the factura carries one."
          },
          "installments": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "amount": {
                  "type": "string"
                },
                "dueDate": {
                  "type": "string",
                  "format": "date"
                }
              }
            }
          }
        }
      },
      "Document": {
        "type": "object",
        "description": "An emitted electronic document. `items` is present on creation responses and single-document GETs, absent in lists. `email_error` (string) may appear on creation when the customer email failed.",
        "required": [
          "id",
          "type",
          "doc_type",
          "series",
          "number",
          "full_number",
          "file_name",
          "status",
          "issue_date",
          "currency",
          "customer",
          "totals",
          "files",
          "created_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "type": {
            "type": "string",
            "enum": [
              "factura",
              "boleta"
            ]
          },
          "doc_type": {
            "type": "string",
            "enum": [
              "01",
              "03"
            ],
            "description": "SUNAT catalog 01 code."
          },
          "series": {
            "type": "string",
            "examples": [
              "F001",
              "B001"
            ]
          },
          "number": {
            "type": "integer",
            "description": "Correlative as integer."
          },
          "full_number": {
            "type": "string",
            "description": "SERIES-CORRELATIVE, correlative without leading zeros.",
            "examples": [
              "F001-42"
            ]
          },
          "file_name": {
            "type": "string",
            "description": "SUNAT file name: {ruc}-{doc_type}-{series}-{number}.",
            "examples": [
              "20123456789-01-F001-42"
            ]
          },
          "status": {
            "type": "string",
            "enum": [
              "processing",
              "accepted",
              "rejected",
              "error"
            ]
          },
          "issue_date": {
            "type": "string",
            "format": "date"
          },
          "issue_time": {
            "type": [
              "string",
              "null"
            ],
            "description": "HH:mm:ss; null on backdated documents."
          },
          "due_date": {
            "type": [
              "string",
              "null"
            ],
            "format": "date",
            "description": "Last installment due date (credito only)."
          },
          "currency": {
            "type": "string",
            "enum": [
              "PEN",
              "USD"
            ]
          },
          "customer": {
            "$ref": "#/components/schemas/CustomerSnapshot"
          },
          "branch_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Branch (sucursal) the document was emitted from; null when emitted without a cash register."
          },
          "cash_register_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Cash register (caja) the document was emitted from; null when emitted without one."
          },
          "totals": {
            "$ref": "#/components/schemas/DocumentTotals"
          },
          "amount_in_words": {
            "type": [
              "string",
              "null"
            ],
            "description": "Total in Spanish words (e.g. \"CIENTO DIECIOCHO CON 00/100 SOLES\")."
          },
          "payment": {
            "$ref": "#/components/schemas/PaymentResponse"
          },
          "notes": {
            "type": [
              "string",
              "null"
            ]
          },
          "detraction": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/Detraction"
              },
              {
                "type": "null"
              }
            ],
            "description": "Detracción (SPOT) applied to this document; null when not subject."
          },
          "sunat": {
            "$ref": "#/components/schemas/DocumentSunat"
          },
          "files": {
            "$ref": "#/components/schemas/DocumentFiles"
          },
          "items": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/DocumentItem"
            }
          },
          "source": {
            "type": "string",
            "enum": [
              "ui",
              "api",
              "mcp"
            ]
          },
          "emailed_to": {
            "type": [
              "string",
              "null"
            ]
          },
          "emailed_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "email_error": {
            "type": "string",
            "description": "Present on creation responses only, when sending the customer email failed."
          },
          "nfc_window": {
            "type": "object",
            "description": "Present on creation/resend responses only, when the document was emitted from a caja that has an NFC link and SUNAT accepted it: the window in which a customer can tap the caja's NFC tag to download the receipt (PDF, signed XML and CDR). Absent otherwise.",
            "required": [
              "cash_register_id",
              "expires_at",
              "seconds"
            ],
            "properties": {
              "cash_register_id": {
                "type": "string",
                "format": "uuid"
              },
              "expires_at": {
                "type": "string",
                "format": "date-time",
                "description": "Server-clock instant at which the window closes (about 60 s after emission)."
              },
              "seconds": {
                "type": "integer",
                "description": "Length of the window in seconds."
              }
            }
          }
        }
      },
      "DocumentList": {
        "type": "object",
        "required": [
          "data",
          "page",
          "per_page",
          "total"
        ],
        "properties": {
          "data": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Document"
            },
            "description": "Documents without `items`."
          },
          "page": {
            "type": "integer"
          },
          "per_page": {
            "type": "integer"
          },
          "total": {
            "type": "integer"
          }
        }
      },
      "CreateQuoteRequest": {
        "type": "object",
        "description": "Body for creating or replacing a quote. Reuses the document customer/item/payment shapes but drops `type`, `series` and `send_email`, and adds `title` and `valid_until`.",
        "required": [
          "items"
        ],
        "properties": {
          "title": {
            "type": "string",
            "maxLength": 120,
            "description": "Optional subject line for the proposal ('Cambio de luminarias — Sede Surco'). Heads the PDF and the detail screen, and is matched by the list's `q` search. Omitting it on PUT clears the stored title.",
            "examples": [
              "Cambio de luminarias — Sede Surco"
            ]
          },
          "issue_date": {
            "type": "string",
            "format": "date",
            "description": "Defaults to today in America/Lima. May be future- or back-dated (no SUNAT clamp)."
          },
          "valid_until": {
            "type": "string",
            "format": "date",
            "description": "Validity deadline ('válido hasta'), printed on the PDF. Drives the derived `expired` status."
          },
          "currency": {
            "type": "string",
            "enum": [
              "PEN",
              "USD"
            ],
            "default": "PEN"
          },
          "customer": {
            "$ref": "#/components/schemas/CustomerInput"
          },
          "items": {
            "type": "array",
            "minItems": 1,
            "maxItems": 100,
            "items": {
              "$ref": "#/components/schemas/ItemInput"
            }
          },
          "payment": {
            "$ref": "#/components/schemas/PaymentInput"
          },
          "notes": {
            "type": "string",
            "maxLength": 1000
          },
          "recargo_consumo": {
            "type": "object",
            "properties": {
              "apply": {
                "type": "boolean"
              },
              "rate": {
                "type": "number",
                "minimum": 0,
                "maximum": 0.13
              }
            }
          }
        }
      },
      "Quote": {
        "type": "object",
        "description": "A quote (cotización) — never sent to SUNAT. `items` is present on create/update/single GET, absent in lists.",
        "required": [
          "id",
          "code",
          "number",
          "status",
          "issue_date",
          "currency",
          "customer",
          "totals",
          "files",
          "created_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "code": {
            "type": "string",
            "description": "Sequential per-company reference.",
            "examples": [
              "COT-0001"
            ]
          },
          "number": {
            "type": "integer"
          },
          "title": {
            "type": [
              "string",
              "null"
            ],
            "description": "Optional subject line; null when the quote has none."
          },
          "status": {
            "type": "string",
            "enum": [
              "open",
              "converted",
              "expired"
            ],
            "description": "Derived: converted (has a document) → expired (valid_until past, Lima) → open."
          },
          "issue_date": {
            "type": "string",
            "format": "date"
          },
          "issue_time": {
            "type": [
              "string",
              "null"
            ]
          },
          "valid_until": {
            "type": [
              "string",
              "null"
            ],
            "format": "date"
          },
          "due_date": {
            "type": [
              "string",
              "null"
            ],
            "format": "date"
          },
          "currency": {
            "type": "string",
            "enum": [
              "PEN",
              "USD"
            ]
          },
          "customer": {
            "$ref": "#/components/schemas/CustomerSnapshot"
          },
          "totals": {
            "$ref": "#/components/schemas/DocumentTotals"
          },
          "amount_in_words": {
            "type": "string"
          },
          "payment": {
            "$ref": "#/components/schemas/PaymentResponse"
          },
          "notes": {
            "type": [
              "string",
              "null"
            ]
          },
          "converted": {
            "type": "object",
            "properties": {
              "document_id": {
                "type": [
                  "string",
                  "null"
                ],
                "format": "uuid",
                "description": "The emitted document, once converted."
              },
              "at": {
                "type": [
                  "string",
                  "null"
                ],
                "format": "date-time"
              }
            }
          },
          "files": {
            "type": "object",
            "properties": {
              "pdf": {
                "type": "string",
                "description": "Relative PDF path; GET with the same Bearer token.",
                "nullable": true
              }
            },
            "description": "Download paths. **`pdf` is available from the moment the document is created**; `xml`, `cdr` and `zip` are null while `status` is `processing` and those endpoints return 409 `document_processing` until SUNAT answers."
          },
          "items": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/DocumentItem"
            }
          },
          "source": {
            "type": "string",
            "enum": [
              "ui",
              "api",
              "mcp"
            ]
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "QuoteList": {
        "type": "object",
        "required": [
          "data",
          "page",
          "per_page",
          "total"
        ],
        "properties": {
          "data": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Quote"
            },
            "description": "Quotes without `items`."
          },
          "page": {
            "type": "integer"
          },
          "per_page": {
            "type": "integer"
          },
          "total": {
            "type": "integer"
          }
        }
      },
      "ProductCreateRequest": {
        "type": "object",
        "required": [
          "code",
          "name",
          "unit_price"
        ],
        "properties": {
          "code": {
            "type": "string",
            "minLength": 1,
            "maxLength": 50,
            "description": "Your unique code per company."
          },
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 300
          },
          "description": {
            "type": "string",
            "maxLength": 1000
          },
          "unit_code": {
            "type": "string",
            "enum": [
              "NIU",
              "ZZ",
              "KGM",
              "GRM",
              "LTR",
              "MTR",
              "MTK",
              "MTQ",
              "CEN",
              "DZN",
              "BX",
              "PK",
              "BG",
              "BO",
              "GLL",
              "HUR",
              "DAY",
              "TNE",
              "SET",
              "PR"
            ],
            "default": "NIU"
          },
          "unit_price": {
            "type": "number",
            "exclusiveMinimum": 0,
            "maximum": 999999999,
            "description": "FINAL price, IGV included when gravado — what the customer pays."
          },
          "currency": {
            "type": "string",
            "enum": [
              "PEN",
              "USD"
            ],
            "default": "PEN"
          },
          "affectation": {
            "type": "string",
            "enum": [
              "10",
              "20",
              "30"
            ],
            "default": "10"
          },
          "isc_rate": {
            "type": "number",
            "minimum": 0,
            "maximum": 1,
            "description": "Default ISC al valor rate as a fraction (e.g. 0.10); gravado products only."
          },
          "icbper": {
            "type": "boolean",
            "default": false,
            "description": "Default ICBPER flag (bolsa plástica) for lines using this product."
          },
          "track_stock": {
            "type": "boolean",
            "default": true,
            "description": "Whether the inventory module (when enabled) tracks stock for this product."
          },
          "min_stock": {
            "type": [
              "number",
              "null"
            ],
            "minimum": 0,
            "description": "Low-stock alert threshold (sum across warehouses); null/omitted = no alert."
          },
          "barcode": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 64,
            "description": "Barcode (manufacturer EAN or internal). Unique within the company."
          },
          "cost": {
            "type": [
              "number",
              "null"
            ],
            "exclusiveMinimum": 0,
            "description": "Last purchase cost, FINAL (IGV included). Informative. Stored rounded to 3 decimals — unlike unit_price, which is 2."
          },
          "is_public": {
            "type": "boolean",
            "default": false,
            "description": "Whether the product is shown on the company's public profile page (perfil-publico module)."
          },
          "active": {
            "type": "boolean",
            "default": true
          },
          "pool_tiers": {
            "type": "boolean",
            "default": false,
            "description": "Global tier count. false (default): a line qualifies for `price_tiers` with its own `quantity` — the classic behaviour. true: every line in the document whose product ALSO has `pool_tiers` on adds up into ONE shared quantity (7 shorts + 5 polos = 12 garments), and each product is still priced from ITS OWN `price_tiers`. There is a single count per company: the flag does not say WITH WHOM a product sums, it sums with every flagged product. Replaces the former tier groups."
          },
          "price_tiers": {
            "type": "array",
            "maxItems": 20,
            "description": "Volume pricing: from `min_quantity` units the FINAL unit price is `unit_price`. ABSOLUTE prices, not discounts. The highest threshold the line's QUALIFYING quantity reaches wins; below the lowest one the plain `unit_price` applies. The qualifying quantity is the line's own `quantity`, unless the product has `pool_tiers` — then it is the global tier count of the document (see `pool_tiers`). Only used when an item omits `unit_price`. Order is irrelevant; two tiers cannot share a `min_quantity`. Omitting the field on update touches nothing; [] clears the tiers.",
            "items": {
              "$ref": "#/components/schemas/PriceTierInput"
            }
          },
          "images": {
            "type": "array",
            "maxItems": 3,
            "items": {
              "$ref": "#/components/schemas/ProductImage"
            },
            "description": "Product images, up to 3, IN ORDER — the first one is the cover (the thumbnail shown in the app's catalog list). Send the REFERENCE, not the bytes: images are uploaded first through `POST /api/products/images` (multipart, browser-session only — the app compresses them to WebP client-side before sending) and that response is what goes here verbatim. A `key` that does not live under this company's own prefix is rejected with 422 `invalid_image_key`: the key is the only tenant isolation R2 has, so it is checked on write and not just when serving the bytes. This is a full REPLACEMENT, never a merge — the array you send is the array the product ends up with. Omitting it (or []) creates the product with no images."
          },
          "modifiers": {
            "type": "array",
            "maxItems": 50,
            "description": "Product modifiers, synced id-preserving in one atomic request (there are no per-modifier routes): an entry WITH id updates it, WITHOUT id creates it, and an active modifier ABSENT from the array is deactivated (soft — the row survives for traceability). Omitting the field touches nothing; [] deactivates all. `position` is not sent: the array order IS the order.",
            "items": {
              "$ref": "#/components/schemas/ProductModifierInput"
            }
          },
          "extras": {
            "type": "array",
            "maxItems": 30,
            "description": "Extras of the product, synced id-preserving like `modifiers`: with `id` updates, without creates, an active extra missing from the array is deactivated. Omit to touch nothing; [] deactivates all. An extra with aligned `prices` requires `modifiers` in the same request.",
            "items": {
              "$ref": "#/components/schemas/ProductExtraInput"
            }
          }
        }
      },
      "ProductUpdateRequest": {
        "type": "object",
        "description": "Partial update — all fields optional; only sent fields change.",
        "properties": {
          "code": {
            "type": "string",
            "minLength": 1,
            "maxLength": 50
          },
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 300
          },
          "description": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 1000,
            "description": "null clears the description."
          },
          "unit_code": {
            "type": "string",
            "enum": [
              "NIU",
              "ZZ",
              "KGM",
              "GRM",
              "LTR",
              "MTR",
              "MTK",
              "MTQ",
              "CEN",
              "DZN",
              "BX",
              "PK",
              "BG",
              "BO",
              "GLL",
              "HUR",
              "DAY",
              "TNE",
              "SET",
              "PR"
            ]
          },
          "unit_price": {
            "type": "number",
            "exclusiveMinimum": 0,
            "maximum": 999999999
          },
          "currency": {
            "type": "string",
            "enum": [
              "PEN",
              "USD"
            ]
          },
          "affectation": {
            "type": "string",
            "enum": [
              "10",
              "20",
              "30"
            ]
          },
          "isc_rate": {
            "type": [
              "number",
              "null"
            ],
            "minimum": 0,
            "maximum": 1,
            "description": "ISC al valor rate as a fraction; null clears it."
          },
          "icbper": {
            "type": "boolean"
          },
          "track_stock": {
            "type": "boolean"
          },
          "min_stock": {
            "type": [
              "number",
              "null"
            ],
            "minimum": 0,
            "description": "null clears the low-stock threshold."
          },
          "barcode": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 64,
            "description": "null clears the barcode."
          },
          "cost": {
            "type": [
              "number",
              "null"
            ],
            "exclusiveMinimum": 0,
            "description": "Stored rounded to 3 decimals. null clears the last cost."
          },
          "is_public": {
            "type": "boolean",
            "default": false,
            "description": "Whether the product is shown on the company's public profile page (perfil-publico module)."
          },
          "active": {
            "type": "boolean"
          },
          "pool_tiers": {
            "type": "boolean",
            "description": "Global tier count (see ProductCreateRequest). Two states on this partial update: omitting the field touches nothing, a boolean switches it — so a client that saves the whole product must send the field explicitly."
          },
          "price_tiers": {
            "type": "array",
            "maxItems": 20,
            "description": "Volume pricing: from `min_quantity` units the FINAL unit price is `unit_price`. ABSOLUTE prices, not discounts. The highest threshold the line's QUALIFYING quantity reaches wins; below the lowest one the plain `unit_price` applies. The qualifying quantity is the line's own `quantity`, unless the product has `pool_tiers` — then it is the global tier count of the document (see `pool_tiers`). Only used when an item omits `unit_price`. Order is irrelevant; two tiers cannot share a `min_quantity`. Omitting the field on update touches nothing; [] clears the tiers.",
            "items": {
              "$ref": "#/components/schemas/PriceTierInput"
            }
          },
          "images": {
            "type": "array",
            "maxItems": 3,
            "items": {
              "$ref": "#/components/schemas/ProductImage"
            },
            "description": "Product images, up to 3, IN ORDER — the first one is the cover (the thumbnail shown in the app's catalog list). Send the REFERENCE, not the bytes: images are uploaded first through `POST /api/products/images` (multipart, browser-session only — the app compresses them to WebP client-side before sending) and that response is what goes here verbatim. A `key` that does not live under this company's own prefix is rejected with 422 `invalid_image_key`: the key is the only tenant isolation R2 has, so it is checked on write and not just when serving the bytes. This is a full REPLACEMENT, never a merge — the array you send is the array the product ends up with. Two states: omitting the field touches nothing, [] removes every image. Removing an image here does not delete the R2 object."
          },
          "modifiers": {
            "type": "array",
            "maxItems": 50,
            "description": "Product modifiers, synced id-preserving in one atomic request (there are no per-modifier routes): an entry WITH id updates it, WITHOUT id creates it, and an active modifier ABSENT from the array is deactivated (soft — the row survives for traceability). Omitting the field touches nothing; [] deactivates all. `position` is not sent: the array order IS the order.",
            "items": {
              "$ref": "#/components/schemas/ProductModifierInput"
            }
          },
          "extras": {
            "type": "array",
            "maxItems": 30,
            "description": "Extras of the product, synced id-preserving like `modifiers`: with `id` updates, without creates, an active extra missing from the array is deactivated. Omit to touch nothing; [] deactivates all. An extra with aligned `prices` requires `modifiers` in the same request.",
            "items": {
              "$ref": "#/components/schemas/ProductExtraInput"
            }
          }
        }
      },
      "Product": {
        "type": "object",
        "required": [
          "id",
          "code",
          "name",
          "unit_code",
          "unit_price",
          "currency",
          "affectation",
          "active",
          "created_at",
          "updated_at",
          "images"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "code": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "description": {
            "type": [
              "string",
              "null"
            ]
          },
          "unit_code": {
            "type": "string"
          },
          "unit_price": {
            "type": "string",
            "description": "Final price (IGV included when gravado), string with 2 decimals."
          },
          "currency": {
            "type": "string",
            "enum": [
              "PEN",
              "USD"
            ]
          },
          "affectation": {
            "type": "string",
            "enum": [
              "10",
              "20",
              "30"
            ]
          },
          "isc_rate": {
            "type": [
              "string",
              "null"
            ],
            "description": "ISC al valor rate as a fraction (e.g. \"0.1000\"); null when no ISC."
          },
          "icbper": {
            "type": "boolean",
            "description": "Default ICBPER flag for this product."
          },
          "track_stock": {
            "type": "boolean",
            "description": "Inventory module (when enabled) tracks stock for this product."
          },
          "min_stock": {
            "type": [
              "string",
              "null"
            ],
            "description": "Low-stock threshold as a numeric string (3 decimals); null = no alert."
          },
          "barcode": {
            "type": [
              "string",
              "null"
            ],
            "description": "Barcode (unique within the company); null when unset."
          },
          "cost": {
            "type": [
              "string",
              "null"
            ],
            "description": "Last purchase cost, FINAL IGV included. Numeric string with 2 decimals, or 3 when the cost actually has a third (a unit cost often comes from a division: a thousand bags, a box of 144). null when unknown."
          },
          "is_public": {
            "type": "boolean",
            "default": false,
            "description": "Whether the product is shown on the company's public profile page (perfil-publico module)."
          },
          "active": {
            "type": "boolean"
          },
          "price_tiers": {
            "type": "array",
            "description": "Volume pricing tiers. Always present; [] when there are none. They are read with the line's QUALIFYING quantity — its own, or the global tier count when the product has `pool_tiers`.",
            "items": {
              "$ref": "#/components/schemas/PriceTier"
            }
          },
          "pool_tiers": {
            "type": "boolean",
            "description": "Global tier count: false = its `price_tiers` qualify with each line's own quantity; true = every line in a document whose product also has it on adds up to one qualifying quantity, and each product is still priced from ITS OWN tiers. Modifiers have no `pool_tiers` field: they inherit this one."
          },
          "images": {
            "type": "array",
            "maxItems": 3,
            "items": {
              "$ref": "#/components/schemas/ProductImage"
            },
            "description": "Product images, up to 3, in order — the first is the cover. The reference only: there is no URL, because the bytes are served by `GET /api/products/images?key=…`, which is browser-session only. An API token can see WHICH images a product has but cannot download them; promising a `url` your token cannot follow would be a lie in the contract. Always present (an empty array when the product has no images)."
          },
          "modifiers": {
            "type": "array",
            "description": "Active modifiers ordered by position. Always present on product responses; [] when the product has none.",
            "items": {
              "$ref": "#/components/schemas/ProductModifier"
            }
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          },
          "extras": {
            "type": "array",
            "description": "Active extras ordered by position. Always present on product responses; [] when the product has none.",
            "items": {
              "$ref": "#/components/schemas/ProductExtra"
            }
          }
        }
      },
      "ProductModifier": {
        "type": "object",
        "description": "A priced variation of a product (size, finish, audience). Inherits affectation, ISC, ICBPER, unit and currency from its parent product — only the price and the label differ.",
        "required": [
          "id",
          "name",
          "code",
          "unit_price",
          "position",
          "active"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "Pass this as `modifier_id` on an emission/quote item."
          },
          "name": {
            "type": "string",
            "maxLength": 150,
            "examples": [
              "Cuello camisero · Adulto"
            ]
          },
          "code": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 50,
            "description": "Optional per-modifier code, unique within the product. Display/snapshot only — NOT a lookup key for emission."
          },
          "unit_price": {
            "type": "string",
            "description": "FINAL absolute price (IGV included when the parent is gravado), in the parent's currency. String with 2 decimals, like the product's. Never a delta."
          },
          "price_tiers": {
            "type": "array",
            "description": "Volume pricing tiers OF THIS MODIFIER — never inherited from the parent. Always present; [] when there are none. The parent's `pool_tiers` IS inherited, though: the modifier reaches its thresholds with the global tier count, read from this table.",
            "items": {
              "$ref": "#/components/schemas/PriceTier"
            }
          },
          "position": {
            "type": "integer",
            "description": "0-based display order."
          },
          "active": {
            "type": "boolean"
          }
        }
      },
      "ProductModifierInput": {
        "type": "object",
        "required": [
          "name",
          "unit_price"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "Present = update that modifier (the id survives name/price changes, so already-emitted lines keep referencing it). Absent = create. A modifier id that belongs to another product or company fails the whole request with 400 validation."
          },
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 150,
            "description": "Unique per product among ACTIVE modifiers."
          },
          "code": {
            "type": [
              "string",
              "null"
            ],
            "minLength": 1,
            "maxLength": 50,
            "description": "Optional. Unique per product among active modifiers when not null."
          },
          "unit_price": {
            "type": "number",
            "exclusiveMinimum": 0,
            "maximum": 999999999,
            "description": "FINAL ABSOLUTE price, IGV included when the parent is gravado, in the parent's currency. Never a delta."
          },
          "price_tiers": {
            "type": "array",
            "maxItems": 20,
            "description": "Volume pricing: from `min_quantity` units the FINAL unit price is `unit_price`. ABSOLUTE prices, not discounts. The highest threshold the line's QUALIFYING quantity reaches wins; below the lowest one the plain `unit_price` applies. Applies to THIS modifier only — a modifier never inherits the parent's tiers, but it does inherit the parent's `pool_tiers`, which decides the qualifying quantity (its own line, or the global tier count of the document). Order is irrelevant; two tiers cannot share a `min_quantity`. Omitting the field on update touches nothing; [] clears the tiers.",
            "items": {
              "$ref": "#/components/schemas/PriceTierInput"
            }
          },
          "active": {
            "type": "boolean",
            "default": true
          }
        }
      },
      "PriceTier": {
        "type": "object",
        "description": "A volume price step: from `min_quantity` units the final unit price is `unit_price`. Values are strings, like every other amount in this API.",
        "required": [
          "min_quantity",
          "unit_price"
        ],
        "properties": {
          "min_quantity": {
            "type": "string",
            "description": "Quantity threshold, numeric string with up to 3 decimals and no trailing zeros (\"12\", \"2.5\")."
          },
          "unit_price": {
            "type": "string",
            "description": "FINAL absolute unit price at this tier (IGV included when gravado), string with 2 decimals. Not a discount."
          }
        }
      },
      "PriceTierInput": {
        "type": "object",
        "required": [
          "min_quantity",
          "unit_price"
        ],
        "properties": {
          "min_quantity": {
            "type": "number",
            "exclusiveMinimum": 0,
            "maximum": 999999999,
            "description": "Quantity threshold, up to 3 decimals."
          },
          "unit_price": {
            "type": "number",
            "exclusiveMinimum": 0,
            "maximum": 999999999,
            "description": "FINAL absolute unit price at this tier. Not a discount, not a delta."
          }
        }
      },
      "ProductList": {
        "type": "object",
        "required": [
          "data",
          "page",
          "per_page",
          "total"
        ],
        "properties": {
          "data": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Product"
            }
          },
          "page": {
            "type": "integer"
          },
          "per_page": {
            "type": "integer"
          },
          "total": {
            "type": "integer"
          }
        }
      },
      "CustomerCreateRequest": {
        "type": "object",
        "required": [
          "name"
        ],
        "properties": {
          "doc_type": {
            "type": "string",
            "enum": [
              "0",
              "1",
              "4",
              "6",
              "7"
            ],
            "description": "1 DNI, 4 carnet de extranjería, 6 RUC, 7 passport, 0 none. Omit it (with no number) to catalogue a nameless customer. A type 1/4/6/7 without its number is a 400, not a nameless row."
          },
          "doc_number": {
            "type": [
              "string",
              "null"
            ],
            "minLength": 1,
            "maxLength": 15,
            "description": "Validated per type: RUC 11 digits + check digit; DNI 8 digits; CE/passport ≤15 alphanumeric. Omit it (with doc_type \"0\" or omitted) for a name-only customer; doc_type \"0\" WITH a number is a 400."
          },
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 500
          },
          "nickname": {
            "type": "string",
            "minLength": 1,
            "maxLength": 120,
            "description": "Everyday nickname for the customer (\"el Chino\", \"the corner shop\"). Directory-only: it is never copied into a document's customer_snapshot and never reaches the UBL XML or the PDF. Searchable from GET /customers and from the sales and quotes lists in the web app."
          },
          "email": {
            "type": "string",
            "format": "email",
            "maxLength": 320
          },
          "phone": {
            "type": "string",
            "pattern": "^[0-9+()\\s-]{6,20}$",
            "description": "Digits, +, (), spaces and dashes; 6–20 chars."
          },
          "address": {
            "type": "string",
            "maxLength": 500
          },
          "custom_fields": {
            "$ref": "#/components/schemas/CustomFieldValues"
          },
          "agreement_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Price agreement used to quote this customer (catalog module). Omit to leave untouched; null = «Público — sin acuerdo». A foreign, unknown or ARCHIVED agreement id all return the same 404. The agreement is live customer data: sale lines store the resolved price, never which agreement produced it."
          }
        }
      },
      "CustomerUpdateRequest": {
        "type": "object",
        "description": "Partial update. Absent = untouched; null clears nickname/email/phone/address and agreement_id. doc_type/doc_number may only be ADDED to a customer that has no document.",
        "properties": {
          "doc_type": {
            "type": "string",
            "enum": [
              "0",
              "1",
              "4",
              "6",
              "7"
            ],
            "description": "Only to give a document to a name-only customer, together with doc_number. 1 DNI, 4 carnet de extranjería, 6 RUC, 7 passport. \"0\" is not a document and is rejected. Sending either field for a customer that already has a document is a 409."
          },
          "doc_number": {
            "type": "string",
            "minLength": 1,
            "maxLength": 15,
            "description": "Validated per type, exactly as on create: RUC 11 digits + check digit; DNI 8 digits; CE/passport ≤15 alphanumeric. Half an identity (one field without the other) is a 400."
          },
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 500
          },
          "nickname": {
            "type": [
              "string",
              "null"
            ],
            "minLength": 1,
            "maxLength": 120,
            "description": "Everyday nickname for the customer (\"el Chino\", \"the corner shop\"). Directory-only: it is never copied into a document's customer_snapshot and never reaches the UBL XML or the PDF. Searchable from GET /customers and from the sales and quotes lists in the web app."
          },
          "email": {
            "type": [
              "string",
              "null"
            ],
            "format": "email",
            "maxLength": 320
          },
          "phone": {
            "type": [
              "string",
              "null"
            ],
            "pattern": "^[0-9+()\\s-]{6,20}$"
          },
          "address": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 500
          },
          "custom_fields": {
            "$ref": "#/components/schemas/CustomFieldValues"
          },
          "agreement_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Price agreement used to quote this customer (catalog module). Omit to leave untouched; null = «Público — sin acuerdo». A foreign, unknown or ARCHIVED agreement id all return the same 404. The agreement is live customer data: sale lines store the resolved price, never which agreement produced it."
          }
        }
      },
      "CustomFieldValues": {
        "type": "object",
        "maxProperties": 40,
        "additionalProperties": {
          "type": [
            "string",
            "number",
            "boolean",
            "null"
          ]
        },
        "description": "Company-defined custom fields, keyed by the definition key (configured in Bloques → Configuración → Campos; requires the campos-personalizados module). Values are JSON scalars — dates travel as \"YYYY-MM-DD\" strings; null (or empty string) deletes the key. ABSENT object → stored values preserved; PRESENT object → validated against active definitions and replaced wholesale (unknown keys, type mismatches or missing required fields → 400 custom_fields_invalid with per-key details)."
      },
      "Customer": {
        "type": "object",
        "required": [
          "id",
          "doc_type",
          "doc_number",
          "name",
          "custom_fields",
          "created_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "doc_type": {
            "type": "string",
            "enum": [
              "0",
              "1",
              "4",
              "6",
              "7"
            ]
          },
          "doc_number": {
            "type": [
              "string",
              "null"
            ],
            "description": "Null when the customer has no identity document (doc_type \"0\")."
          },
          "name": {
            "type": "string"
          },
          "nickname": {
            "type": [
              "string",
              "null"
            ],
            "description": "Everyday nickname for the customer (\"el Chino\", \"the corner shop\"). Directory-only: it is never copied into a document's customer_snapshot and never reaches the UBL XML or the PDF. Searchable from GET /customers and from the sales and quotes lists in the web app."
          },
          "email": {
            "type": [
              "string",
              "null"
            ]
          },
          "phone": {
            "type": [
              "string",
              "null"
            ]
          },
          "address": {
            "type": [
              "string",
              "null"
            ]
          },
          "custom_fields": {
            "type": "object",
            "additionalProperties": {
              "type": [
                "string",
                "number",
                "boolean"
              ]
            },
            "description": "Always present ({} when none). Still readable if the module is later disabled — only writes are gated."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "agreement_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Price agreement used to quote this customer (catalog module); null = no agreement. Live customer data — it is never copied into a document's customer_snapshot."
          }
        }
      },
      "CustomerList": {
        "type": "object",
        "required": [
          "data",
          "page",
          "per_page",
          "total"
        ],
        "properties": {
          "data": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Customer"
            }
          },
          "page": {
            "type": "integer"
          },
          "per_page": {
            "type": "integer"
          },
          "total": {
            "type": "integer"
          }
        }
      },
      "Series": {
        "type": "object",
        "required": [
          "type",
          "doc_type",
          "code",
          "next_number",
          "active"
        ],
        "properties": {
          "type": {
            "type": "string",
            "enum": [
              "factura",
              "boleta",
              "nota_credito",
              "nota_venta"
            ],
            "description": "Readable alias of doc_type. Credit-note series (nota_credito) inherit the prefix of the document they modify; nota_venta series are internal (not SUNAT)."
          },
          "doc_type": {
            "type": "string",
            "enum": [
              "01",
              "03",
              "07",
              "nv"
            ],
            "description": "SUNAT catalog 01 code, plus the internal \"nv\" used by nota de venta series."
          },
          "code": {
            "type": "string",
            "description": "4-char series: F*** facturas, B*** boletas, F/B*** notas de crédito (prefix of the affected document), NV** notas de venta.",
            "examples": [
              "F001",
              "B001",
              "FC01",
              "NV01"
            ]
          },
          "next_number": {
            "type": "integer",
            "description": "Next correlative to be allocated."
          },
          "active": {
            "type": "boolean"
          },
          "owner": {
            "type": "string",
            "enum": [
              "company",
              "branch",
              "register"
            ],
            "description": "Who the series belongs to (P6): company (no owner), a branch, or a cash register. Drives which series is auto-selected when emitting from a caja."
          },
          "branch_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Owning branch (sucursal), when owner=branch."
          },
          "cash_register_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Owning cash register (caja), when owner=register."
          }
        }
      },
      "SeriesList": {
        "type": "object",
        "required": [
          "data"
        ],
        "properties": {
          "data": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Series"
            }
          }
        }
      },
      "Usage": {
        "type": "object",
        "description": "Monthly consumption (America/Lima calendar). NOTE: camelCase planStatus and periodStart.",
        "required": [
          "plan",
          "planStatus",
          "used",
          "limit",
          "remaining",
          "periodStart"
        ],
        "properties": {
          "plan": {
            "type": "string",
            "enum": [
              "free",
              "pro"
            ],
            "description": "free: 10 docs/month (S/ 0); pro: 3000 docs/month (S/ 40)."
          },
          "planStatus": {
            "type": "string",
            "description": "Subscription status (e.g. active, past_due, canceled). A non-active pro falls back to free limits."
          },
          "used": {
            "type": "integer",
            "description": "Documents consumed this month (accepted + rejected + processing)."
          },
          "limit": {
            "type": "integer",
            "description": "Effective monthly limit."
          },
          "remaining": {
            "type": "integer"
          },
          "periodStart": {
            "type": "string",
            "format": "date-time",
            "description": "Start of the current Lima month, UTC instant."
          }
        }
      },
      "Company": {
        "type": "object",
        "required": [
          "id",
          "ruc",
          "razon_social",
          "environment",
          "plan",
          "plan_status",
          "created_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "ruc": {
            "type": "string",
            "description": "11-digit tax ID."
          },
          "razon_social": {
            "type": "string"
          },
          "email": {
            "type": [
              "string",
              "null"
            ]
          },
          "direccion": {
            "type": [
              "string",
              "null"
            ]
          },
          "ubigeo": {
            "type": [
              "string",
              "null"
            ]
          },
          "distrito": {
            "type": [
              "string",
              "null"
            ]
          },
          "provincia": {
            "type": [
              "string",
              "null"
            ]
          },
          "departamento": {
            "type": [
              "string",
              "null"
            ]
          },
          "environment": {
            "type": "string",
            "description": "PSE environment (production for live tenants).",
            "examples": [
              "produccion"
            ]
          },
          "plan": {
            "type": "string",
            "enum": [
              "free",
              "pro"
            ]
          },
          "plan_status": {
            "type": "string"
          },
          "pdf_template": {
            "type": "string",
            "enum": [
              "clasica",
              "moderna",
              "minimal",
              "lino",
              "oscura",
              "ticket80"
            ],
            "description": "Default PDF template."
          },
          "email_enabled": {
            "type": "boolean",
            "description": "Whether customer emails are sent by default."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "igv_rate": {
            "type": "string",
            "description": "Effective IGV+IPM rate for THIS company (company override -> global parameter -> fallback), as a decimal fraction. Use it to preview totals; never hardcode 0.18.",
            "examples": [
              "0.180000"
            ]
          },
          "series_mode": {
            "type": "string",
            "enum": [
              "company",
              "per_branch",
              "per_register"
            ],
            "description": "At which level the company's series live."
          },
          "modules": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Active module slugs (plan + admin overrides), sorted.",
            "examples": [
              [
                "catalogo",
                "clientes",
                "cotizaciones",
                "finanzas"
              ]
            ]
          },
          "membership": {
            "type": [
              "object",
              "null"
            ],
            "description": "Role and resolved capabilities of the caller. Only for session calls; null for sk_live_ tokens (a token carries scopes, not a role).",
            "properties": {
              "role": {
                "type": "string",
                "examples": [
                  "owner"
                ]
              },
              "capabilities": {
                "type": "array",
                "items": {
                  "type": "string"
                }
              }
            }
          }
        }
      },
      "ProductImage": {
        "type": "object",
        "required": [
          "key",
          "name",
          "content_type",
          "bytes"
        ],
        "properties": {
          "key": {
            "type": "string",
            "description": "R2 object key, exactly as returned by the upload. It is also the image's identity: there is no separate id."
          },
          "name": {
            "type": "string",
            "description": "Original file name — the only part of this the user ever sees."
          },
          "content_type": {
            "type": "string",
            "description": "Detected from the file's MAGIC NUMBER on upload, never from the browser's declared Content-Type. One of image/png, image/jpeg, image/webp."
          },
          "bytes": {
            "type": "integer",
            "description": "Stored size in bytes."
          }
        }
      },
      "ProductExtra": {
        "type": "object",
        "description": "An add-on of the product (\"Manga larga +4.00\"). It is not another product: it is the same one with something on top. Its amount is SUMMED on top of the resolved unit price of a line — it never competes with volume tiers or agreements and is never discounted by them. Per modifier it is in one of three states: an amount, free (\"0.00\": offered, charges nothing) or not offered (null: selling it with that modifier is a validation error).",
        "required": [
          "id",
          "name",
          "default_amount",
          "prices",
          "position",
          "active"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string"
          },
          "default_amount": {
            "type": "string",
            "description": "Per-unit amount, FINAL (IGV included when gravado), string with 2 decimals. The only amount when the product has no modifiers; the inherited default for a modifier without its own entry in `prices`."
          },
          "prices": {
            "type": "object",
            "additionalProperties": {
              "type": [
                "string",
                "null"
              ]
            },
            "description": "Amount per modifier id: a string amount, \"0.00\" = free, null = not offered for that modifier. A modifier missing here inherits `default_amount`."
          },
          "position": {
            "type": "integer"
          },
          "active": {
            "type": "boolean"
          }
        }
      },
      "ProductExtraInput": {
        "type": "object",
        "required": [
          "name",
          "default_amount"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "Present = update that extra (the id survives renames). Absent = create. An id of another product or company fails the whole request with 400 validation."
          },
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 100,
            "description": "Unique per product among ACTIVE extras."
          },
          "default_amount": {
            "type": "number",
            "minimum": 0,
            "description": "Per-unit amount, FINAL. 0 = free."
          },
          "prices": {
            "description": "Per-modifier amounts, in one of two forms: an ARRAY aligned with the `modifiers` array of the same request (prices[i] applies to modifiers[i]; requires `modifiers` in the request and the same length), or an OBJECT keyed by modifier id. In both, 0 = free and null = not offered. Omit on an update to keep the stored per-modifier prices; a modifier with no entry inherits `default_amount`.",
            "oneOf": [
              {
                "type": "array",
                "maxItems": 50,
                "items": {
                  "type": [
                    "number",
                    "null"
                  ],
                  "minimum": 0
                }
              },
              {
                "type": "object",
                "additionalProperties": {
                  "type": [
                    "number",
                    "null"
                  ],
                  "minimum": 0
                }
              }
            ]
          },
          "active": {
            "type": "boolean",
            "default": true
          }
        }
      }
    }
  }
}
