{
  "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, synchronously returning the SUNAT verdict, 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://bloques.example.com/docs" }
  },
  "servers": [{ "url": "https://bloques.example.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 and emit a document (sign + send to SUNAT, synchronous)",
        "description": "Creates a factura or boleta, signs the UBL 2.1 XML via the PSE and transmits it to SUNAT, returning the verdict in the same call (typically 2–8 s). A `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": "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; retry documents that ended in status \"error\" with a NEW key.",
            "schema": { "type": "string", "maxLength": 100 }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/CreateDocumentRequest" }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Document emitted. `status` is `accepted` or `rejected` (a SUNAT rejection still creates the document and consumes the correlative). If the customer email failed, a top-level `email_error` string is included.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Document" } } }
          },
          "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) or `product_not_found` (item references a missing/inactive product).",
            "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" } } }
          },
          "502": {
            "description": "Either an Error envelope with code `provider_error` (PSE failed before the document existed) or — more commonly — a persisted Document with `status: \"error\"` (PSE/transport failed before a SUNAT verdict; does not count against the plan; retry with a NEW idempotency key).",
            "content": {
              "application/json": {
                "schema": { "oneOf": [{ "$ref": "#/components/schemas/Document" }, { "$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.",
        "security": [{ "bearerAuth": ["documents:write"] }],
        "parameters": [{ "$ref": "#/components/parameters/DocumentId" }],
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CreateDocumentRequest" } } }
        },
        "responses": {
          "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.",
            "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) or `product_not_found` (item references a missing/inactive product).",
            "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" } } }
          },
          "502": {
            "description": "Either an Error envelope with code `provider_error` (PSE failed) or — more commonly — a persisted Document with `status: \"error\"` (PSE/transport failed before a SUNAT verdict; does not count against the plan; resend again).",
            "content": { "application/json": { "schema": { "oneOf": [{ "$ref": "#/components/schemas/Document" }, { "$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.",
        "security": [{ "bearerAuth": ["documents:read"] }],
        "parameters": [
          { "$ref": "#/components/parameters/DocumentId" },
          {
            "name": "template",
            "in": "query",
            "description": "PDF template override.",
            "schema": { "type": "string", "enum": ["clasica", "moderna", "minimal", "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.",
        "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" } } }
          },
          "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.",
        "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" } } }
          },
          "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.",
        "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" } } }
          },
          "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) or `minimal`.",
        "security": [{ "bearerAuth": ["quotes:read"] }],
        "parameters": [
          { "$ref": "#/components/parameters/QuoteId" },
          {
            "name": "template",
            "in": "query",
            "description": "PDF template.",
            "schema": { "type": "string", "enum": ["clasica", "minimal"], "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" } } }
          },
          "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" } } }
          },
          "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": "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).",
        "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 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 (identity is immutable)",
        "description": "Partial update: absent fields are untouched; `null` clears email/phone/address. `doc_type`/`doc_number` cannot be changed — they key the directory upsert (a different document is a different customer). `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" },
          "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", "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 the inline trio doc_type + doc_number + name. 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). Inline customers (except doc_type \"0\") are upserted into the directory.",
        "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. Type \"0\": must be \"0\"." },
          "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." },
          "description": { "type": "string", "minLength": 1, "maxLength": 500, "description": "Free-form line description (requires unit_price) or override of the product name." },
          "quantity": { "type": "number", "exclusiveMinimum": 0, "default": 1, "description": "Up to 3 decimals." },
          "unit_price": { "type": "number", "exclusiveMinimum": 0, "description": "FINAL unit price the customer pays, IGV (18%) INCLUDED. Overrides the product price. Required for free-form lines and when the product currency differs from the document currency. NEVER send a pre-tax price." },
          "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"], "description": "IGV affectation: 10 gravado (default), 20 exonerado, 30 inafecto." },
          "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." }
        }
      },
      "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 document total (tolerance ±0.01).",
            "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." },
          "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." }
            }
          }
        }
      },
      "CustomerSnapshot": {
        "type": "object",
        "description": "Customer data frozen at emission time. NOTE: camelCase keys.",
        "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." },
          "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)." },
          "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": "Relative API paths; GET them with the same Bearer token.",
        "properties": {
          "pdf": { "type": "string" },
          "xml": { "type": "string" },
          "cdr": { "type": ["string", "null"], "description": "null while no CDR exists." },
          "zip": { "type": ["string", "null"], "description": "Signed XML + CDR in one archive; null while neither exists." }
        }
      },
      "DocumentItem": {
        "type": "object",
        "description": "Computed line item. quantity and amounts are strings.",
        "properties": {
          "position": { "type": "integer", "description": "1-based line position." },
          "product_id": { "type": ["string", "null"], "format": "uuid" },
          "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." },
          "line_total": { "type": "string", "description": "Line total charged including IGV (2 decimals); net of any line discount." },
          "affectation": { "type": "string", "enum": ["10", "20", "30"] }
        }
      },
      "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": "Total amount (credito only)." },
          "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"] },
          "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." }
        }
      },
      "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 `valid_until`.",
        "required": ["items"],
        "properties": {
          "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" },
          "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." } }
          },
          "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." },
          "active": { "type": "boolean", "default": true }
        }
      },
      "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": "null clears the last cost." },
          "active": { "type": "boolean" }
        }
      },
      "Product": {
        "type": "object",
        "required": ["id", "code", "name", "unit_code", "unit_price", "currency", "affectation", "active", "created_at", "updated_at"],
        "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 (2 decimals); null when unknown." },
          "active": { "type": "boolean" },
          "created_at": { "type": "string", "format": "date-time" },
          "updated_at": { "type": "string", "format": "date-time" }
        }
      },
      "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": ["doc_type", "doc_number", "name"],
        "properties": {
          "doc_type": { "type": "string", "enum": ["1", "4", "6", "7"], "description": "1 DNI, 4 carnet de extranjería, 6 RUC, 7 passport. (\"0\" is not catalogued.)" },
          "doc_number": { "type": "string", "minLength": 1, "maxLength": 15, "description": "Validated per type: RUC 11 digits + check digit; DNI 8 digits; CE/passport ≤15 alphanumeric." },
          "name": { "type": "string", "minLength": 1, "maxLength": 500 },
          "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" }
        }
      },
      "CustomerUpdateRequest": {
        "type": "object",
        "description": "Partial update. Absent = untouched; null clears email/phone/address. doc_type/doc_number are NOT patchable.",
        "properties": {
          "name": { "type": "string", "minLength": 1, "maxLength": 500 },
          "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" }
        }
      },
      "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": ["1", "4", "6", "7"] },
          "doc_number": { "type": "string" },
          "name": { "type": "string" },
          "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" }
        }
      },
      "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", "ticket80"], "description": "Default PDF template." },
          "email_enabled": { "type": "boolean", "description": "Whether customer emails are sent by default." },
          "created_at": { "type": "string", "format": "date-time" }
        }
      }
    }
  }
}
