openapi: 3.0.3

info:
  title: Finova for Developers API
  version: "1.0.05"
  description: |
    Public REST API for Finova workspaces. Manage **Products**, **Customers**,
    **Leads**, **Automations**, **Calendar Events**, **Booking Links**,
    **Bookings**, **Users** (team), and **Company** (tenant) from your own
    site, ERP, or backend.

    All requests are authenticated with a Bearer API key issued from
    Settings → Developers inside Finova. Keys are scoped per resource and
    action.

    Base URL: `https://developers.fi-nova.com/api/v1`

    See the full guides at <https://developers.fi-nova.com/>.
  contact:
    name: Finova
    url: https://developers.fi-nova.com/
  license:
    name: Proprietary
    url: https://fi-nova.com/

servers:
  - url: https://developers.fi-nova.com/api/v1
    description: Production
  - url: http://developers.app.localhost:3000/api/v1
    description: Local development

security:
  - BearerAuth: []

tags:
  - name: Products
    description: Catalog of products and services sold by the company.
  - name: Product Categories
    description: Catalog taxonomy products belong to. Distinct from the financial income/expense category.
  - name: Brands
    description: Brands you can attach to products, with their own content and media.
  - name: Customers
    description: People or companies that have done business with you.
  - name: Leads
    description: Prospects captured from your funnel, forms, or imports.
  - name: Automations
    description: Trigger-based workflows defined inside Finova.
  - name: Calendar Events
    description: Native calendar events on a host user's calendar.
  - name: Booking Links
    description: Public, shareable scheduling links (Calendly-style).
  - name: Bookings
    description: Reservations created against a booking link.
  - name: Users
    description: Team members of the company. Subject to the company's `max_team_size`.
  - name: Company
    description: Singular resource for the tenant's own company info and logo.
  - name: Accounts
    description: Money accounts (bank, cash, Stripe, PayPal…) and their balances.
  - name: Incomes
    description: Sales / income movements. Same side effects as the in-app flow.
  - name: Expenses
    description: Expense movements. Same side effects as the in-app flow.
  - name: Reports
    description: Read-only accounting reports (balance sheet, P&L, cash flow).

paths:
  /products:
    get:
      tags: [Products]
      summary: List products
      operationId: listProducts
      security:
        - BearerAuth: [read-products]
      parameters:
        - $ref: "#/components/parameters/Search"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/StartingAfter"
        - $ref: "#/components/parameters/EndingBefore"
        - $ref: "#/components/parameters/IncludeTotal"
        - $ref: "#/components/parameters/IncludeFilteredTotal"
        - $ref: "#/components/parameters/Page"
        - name: brand
          in: query
          required: false
          schema: { type: string }
          description: |
            Filter by brand. Accepts a single value or a comma-separated
            list mixing Brand ObjectIds and slugs, e.g.
            `brand=acme,globex` or `brand=5f...,acme`. Matches products
            belonging to ANY of the listed brands. Slugs are matched
            against the denormalized snapshot on each product (no extra
            lookup). Unknown ids/slugs simply contribute no rows — the
            filter is never silently ignored. `brand_id` is an alias.
        - name: brand_id
          in: query
          required: false
          schema: { type: string }
          description: |
            Alias of `brand` (also accepts a comma-separated list of
            ids/slugs). Ignored when `brand` is also present.
        - name: category
          in: query
          required: false
          schema: { type: string }
          description: |
            Filter by catalog category. Accepts a single value or a
            comma-separated list mixing ProductCategory ObjectIds and
            slugs, e.g. `category=comida,bebidas`. Matches products in ANY
            of the listed categories. Slugs are matched against the
            denormalized snapshot on each product. Unknown ids/slugs
            contribute no rows. `product_category_id` is an alias.
        - name: product_category_id
          in: query
          required: false
          schema: { type: string }
          description: |
            Alias of `category` (also accepts a comma-separated list of
            ids/slugs). Ignored when `category` is also present.
      responses:
        "200":
          description: Paginated list of products.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ProductList"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
    post:
      tags: [Products]
      summary: Create a product
      operationId: createProduct
      security:
        - BearerAuth: [create-products]
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [data]
              properties:
                data:
                  $ref: "#/components/schemas/ProductInput"
      responses:
        "201":
          description: Product created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ProductEnvelope" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
  /products/{id}:
    parameters:
      - $ref: "#/components/parameters/ObjectId"
    get:
      tags: [Products]
      summary: Retrieve a product
      operationId: getProduct
      security:
        - BearerAuth: [read-products]
      responses:
        "200":
          description: Product found.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ProductEnvelope" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Products]
      summary: Update a product
      operationId: updateProduct
      security:
        - BearerAuth: [update-products]
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [data]
              properties:
                data:
                  $ref: "#/components/schemas/ProductInput"
      responses:
        "200":
          description: Product updated.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ProductEnvelope" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Unprocessable" }
    delete:
      tags: [Products]
      summary: Delete a product
      operationId: deleteProduct
      security:
        - BearerAuth: [delete-products]
      responses:
        "204": { description: Deleted. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /products/{id}/image:
    parameters:
      - $ref: "#/components/parameters/ObjectId"
    post:
      tags: [Products]
      summary: Upload the product's main image
      description: Multipart upload. Send the binary under the `file` (or `image`) field.
      operationId: uploadProductImage
      security:
        - BearerAuth: [update-products]
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                file: { type: string, format: binary }
      responses:
        "200":
          description: Image stored.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id:        { type: string }
                      image_url: { type: string, nullable: true }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Unprocessable" }
    delete:
      tags: [Products]
      summary: Remove the product's main image
      operationId: deleteProductImage
      security:
        - BearerAuth: [update-products]
      responses:
        "200":
          description: Image removed.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id:        { type: string }
                      image_url: { type: string, nullable: true }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /products/{id}/variants/{variant_id}/image:
    parameters:
      - $ref: "#/components/parameters/ObjectId"
      - name: variant_id
        in: path
        required: true
        schema: { type: string }
    post:
      tags: [Products]
      summary: Upload a variant image
      description: Multipart upload. Send the binary under the `file` (or `image`) field.
      operationId: uploadVariantImage
      security:
        - BearerAuth: [update-products]
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                file: { type: string, format: binary }
      responses:
        "200":
          description: Variant image stored.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id:         { type: string }
                      variant_id: { type: string }
                      image_url:  { type: string, nullable: true }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Unprocessable" }
    delete:
      tags: [Products]
      summary: Remove a variant image
      operationId: deleteVariantImage
      security:
        - BearerAuth: [update-products]
      responses:
        "200":
          description: Variant image removed.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id:         { type: string }
                      variant_id: { type: string }
                      image_url:  { type: string, nullable: true }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /product-categories:
    get:
      tags: [Product Categories]
      summary: List product categories
      operationId: listProductCategories
      security:
        - BearerAuth: [read-product_categories]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/StartingAfter"
        - $ref: "#/components/parameters/EndingBefore"
        - $ref: "#/components/parameters/IncludeTotal"
        - $ref: "#/components/parameters/Page"
        - name: has_products
          in: query
          required: false
          schema: { type: boolean }
          description: >
            Si es true, devuelve solo las categorías con al menos un producto
            ACTIVO en su subárbol (product_count_including_descendants > 0), lo
            que incluye padres cuyos productos viven en categorías hijas.
      responses:
        "200":
          description: Paginated list of product categories.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ProductCategoryList" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
    post:
      tags: [Product Categories]
      summary: Create a product category
      operationId: createProductCategory
      security:
        - BearerAuth: [create-product_categories]
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [data]
              properties:
                data: { $ref: "#/components/schemas/ProductCategoryInput" }
      responses:
        "201":
          description: Product category created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ProductCategoryEnvelope" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
  /product-categories/{id}:
    parameters:
      - $ref: "#/components/parameters/ObjectId"
    get:
      tags: [Product Categories]
      summary: Retrieve a product category
      description: The `{id}` segment accepts the ObjectId or the category `slug`.
      operationId: getProductCategory
      security:
        - BearerAuth: [read-product_categories]
      responses:
        "200":
          description: Product category found.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ProductCategoryEnvelope" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Product Categories]
      summary: Update a product category
      operationId: updateProductCategory
      security:
        - BearerAuth: [update-product_categories]
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [data]
              properties:
                data: { $ref: "#/components/schemas/ProductCategoryInput" }
      responses:
        "200":
          description: Product category updated.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ProductCategoryEnvelope" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Unprocessable" }
    delete:
      tags: [Product Categories]
      summary: Delete a product category
      operationId: deleteProductCategory
      security:
        - BearerAuth: [delete-product_categories]
      responses:
        "204": { description: Deleted. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /customers:
    get:
      tags: [Customers]
      summary: List customers
      operationId: listCustomers
      security:
        - BearerAuth: [read-customers]
      parameters:
        - $ref: "#/components/parameters/Search"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/StartingAfter"
        - $ref: "#/components/parameters/EndingBefore"
        - $ref: "#/components/parameters/IncludeTotal"
        - $ref: "#/components/parameters/Page"
      responses:
        "200":
          description: Paginated list of customers.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CustomerList" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
    post:
      tags: [Customers]
      summary: Create a customer
      operationId: createCustomer
      security:
        - BearerAuth: [create-customers]
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [data]
              properties:
                data: { $ref: "#/components/schemas/CustomerInput" }
      responses:
        "201":
          description: Customer created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CustomerEnvelope" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
  /customers/{id}:
    parameters:
      - $ref: "#/components/parameters/ObjectId"
    get:
      tags: [Customers]
      summary: Retrieve a customer
      operationId: getCustomer
      security:
        - BearerAuth: [read-customers]
      responses:
        "200":
          description: Customer found.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CustomerEnvelope" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Customers]
      summary: Update a customer
      operationId: updateCustomer
      security:
        - BearerAuth: [update-customers]
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [data]
              properties:
                data: { $ref: "#/components/schemas/CustomerInput" }
      responses:
        "200":
          description: Customer updated.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CustomerEnvelope" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Unprocessable" }
    delete:
      tags: [Customers]
      summary: Delete a customer
      operationId: deleteCustomer
      security:
        - BearerAuth: [delete-customers]
      responses:
        "204": { description: Deleted. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /leads:
    get:
      tags: [Leads]
      summary: List leads
      operationId: listLeads
      security:
        - BearerAuth: [read-leads]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/StartingAfter"
        - $ref: "#/components/parameters/EndingBefore"
        - $ref: "#/components/parameters/IncludeTotal"
        - $ref: "#/components/parameters/Page"
      responses:
        "200":
          description: Paginated list of leads.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/LeadList" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
    post:
      tags: [Leads]
      summary: Create a lead
      operationId: createLead
      security:
        - BearerAuth: [create-leads]
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [data]
              properties:
                data: { $ref: "#/components/schemas/LeadInput" }
      responses:
        "201":
          description: Lead created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/LeadEnvelope" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
  /leads/{id}:
    parameters:
      - $ref: "#/components/parameters/ObjectId"
    get:
      tags: [Leads]
      summary: Retrieve a lead
      operationId: getLead
      security:
        - BearerAuth: [read-leads]
      responses:
        "200":
          description: Lead found.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/LeadEnvelope" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Leads]
      summary: Update a lead
      operationId: updateLead
      security:
        - BearerAuth: [update-leads]
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [data]
              properties:
                data: { $ref: "#/components/schemas/LeadInput" }
      responses:
        "200":
          description: Lead updated.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/LeadEnvelope" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Unprocessable" }
    delete:
      tags: [Leads]
      summary: Delete a lead
      operationId: deleteLead
      security:
        - BearerAuth: [delete-leads]
      responses:
        "204": { description: Deleted. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /automations:
    get:
      tags: [Automations]
      summary: List automations
      operationId: listAutomations
      security:
        - BearerAuth: [read-automations]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/StartingAfter"
        - $ref: "#/components/parameters/EndingBefore"
        - $ref: "#/components/parameters/IncludeTotal"
        - $ref: "#/components/parameters/Page"
      responses:
        "200":
          description: Paginated list of automations.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AutomationList" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
    post:
      tags: [Automations]
      summary: Create an automation (not yet supported)
      description: |
        Authoring automations via API is not supported in v1 — the
        `trigger_config` and `steps` schemas are still evolving. Create
        automations from the Finova UI; use this API to toggle, rename, or
        delete them.
      operationId: createAutomation
      security:
        - BearerAuth: [create-automations]
      responses:
        "422":
          description: Not yet supported.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /automations/{id}:
    parameters:
      - $ref: "#/components/parameters/ObjectId"
    get:
      tags: [Automations]
      summary: Retrieve an automation
      operationId: getAutomation
      security:
        - BearerAuth: [read-automations]
      responses:
        "200":
          description: Automation found.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AutomationEnvelope" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Automations]
      summary: Update an automation
      description: |
        Phase 1 only allows toggling, renaming, and editing the description.
        `trigger_type`, `trigger_config`, and `steps` cannot be modified via
        API.
      operationId: updateAutomation
      security:
        - BearerAuth: [update-automations]
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [data]
              properties:
                data: { $ref: "#/components/schemas/AutomationInput" }
      responses:
        "200":
          description: Automation updated.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AutomationEnvelope" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Unprocessable" }
    delete:
      tags: [Automations]
      summary: Delete an automation
      operationId: deleteAutomation
      security:
        - BearerAuth: [delete-automations]
      responses:
        "204": { description: Deleted. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /calendar/events:
    get:
      tags: [Calendar Events]
      summary: List calendar events
      operationId: listCalendarEvents
      security: [ { BearerAuth: [read-calendar_events] } ]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/StartingAfter"
        - $ref: "#/components/parameters/EndingBefore"
        - $ref: "#/components/parameters/IncludeTotal"
        - $ref: "#/components/parameters/Page"
      responses:
        "200": { description: Paginated list., content: { application/json: { schema: { $ref: "#/components/schemas/CalendarEventList" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      tags: [Calendar Events]
      summary: Create a calendar event
      operationId: createCalendarEvent
      security: [ { BearerAuth: [create-calendar_events] } ]
      parameters: [ { $ref: "#/components/parameters/IdempotencyKey" } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [data]
              properties: { data: { $ref: "#/components/schemas/CalendarEventInput" } }
      responses:
        "201": { description: Created., content: { application/json: { schema: { $ref: "#/components/schemas/CalendarEventEnvelope" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/Unprocessable" }
  /calendar/events/{id}:
    parameters: [ { $ref: "#/components/parameters/ObjectId" } ]
    get:
      tags: [Calendar Events]
      summary: Get a calendar event
      operationId: getCalendarEvent
      security: [ { BearerAuth: [read-calendar_events] } ]
      responses:
        "200": { description: OK., content: { application/json: { schema: { $ref: "#/components/schemas/CalendarEventEnvelope" } } } }
        "404": { $ref: "#/components/responses/NotFound" }
    put:
      tags: [Calendar Events]
      summary: Update a calendar event
      operationId: updateCalendarEvent
      security: [ { BearerAuth: [update-calendar_events] } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [data]
              properties: { data: { $ref: "#/components/schemas/CalendarEventInput" } }
      responses:
        "200": { description: OK., content: { application/json: { schema: { $ref: "#/components/schemas/CalendarEventEnvelope" } } } }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Unprocessable" }
    delete:
      tags: [Calendar Events]
      summary: Delete a calendar event
      operationId: deleteCalendarEvent
      security: [ { BearerAuth: [delete-calendar_events] } ]
      responses:
        "204": { description: Deleted. }
        "404": { $ref: "#/components/responses/NotFound" }

  /calendar/booking-links:
    get:
      tags: [Booking Links]
      summary: List booking links
      operationId: listBookingLinks
      security: [ { BearerAuth: [read-booking_links] } ]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/StartingAfter"
        - $ref: "#/components/parameters/EndingBefore"
        - $ref: "#/components/parameters/IncludeTotal"
        - $ref: "#/components/parameters/Page"
      responses:
        "200": { description: Paginated list., content: { application/json: { schema: { $ref: "#/components/schemas/BookingLinkList" } } } }
    post:
      tags: [Booking Links]
      summary: Create a booking link
      operationId: createBookingLink
      security: [ { BearerAuth: [create-booking_links] } ]
      parameters: [ { $ref: "#/components/parameters/IdempotencyKey" } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [data]
              properties: { data: { $ref: "#/components/schemas/BookingLinkInput" } }
      responses:
        "201": { description: Created., content: { application/json: { schema: { $ref: "#/components/schemas/BookingLinkEnvelope" } } } }
        "422": { $ref: "#/components/responses/Unprocessable" }
  /calendar/booking-links/{id}:
    parameters: [ { $ref: "#/components/parameters/ObjectId" } ]
    get:
      tags: [Booking Links]
      summary: Get a booking link
      operationId: getBookingLink
      security: [ { BearerAuth: [read-booking_links] } ]
      responses:
        "200": { description: OK., content: { application/json: { schema: { $ref: "#/components/schemas/BookingLinkEnvelope" } } } }
        "404": { $ref: "#/components/responses/NotFound" }
    put:
      tags: [Booking Links]
      summary: Update a booking link
      operationId: updateBookingLink
      security: [ { BearerAuth: [update-booking_links] } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [data]
              properties: { data: { $ref: "#/components/schemas/BookingLinkInput" } }
      responses:
        "200": { description: OK., content: { application/json: { schema: { $ref: "#/components/schemas/BookingLinkEnvelope" } } } }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Unprocessable" }
    delete:
      tags: [Booking Links]
      summary: Delete a booking link
      operationId: deleteBookingLink
      security: [ { BearerAuth: [delete-booking_links] } ]
      responses:
        "204": { description: Deleted. }
        "404": { $ref: "#/components/responses/NotFound" }
  /calendar/booking-links/{id}/availability:
    parameters: [ { $ref: "#/components/parameters/ObjectId" } ]
    get:
      tags: [Booking Links]
      summary: List available slots
      description: |
        Returns the bookable slots for a booking link between two dates.
        Times are in the link's timezone and respect availability windows,
        buffers, min-notice, max-advance, blocked dates, and (when
        enabled) Google FreeBusy of the host.
      operationId: bookingLinkAvailability
      security: [ { BearerAuth: [read-booking_links] } ]
      parameters:
        - name: from
          in: query
          required: false
          schema: { type: string, format: date, example: 2026-05-18 }
        - name: to
          in: query
          required: false
          schema: { type: string, format: date, example: 2026-05-31 }
      responses:
        "200":
          description: Map of date → array of `HH:MM` slot starts.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    additionalProperties:
                      type: array
                      items: { type: string, example: "09:30" }
                  timezone: { type: string, example: "America/Mexico_City" }
        "422": { $ref: "#/components/responses/Unprocessable" }

  /calendar/bookings:
    get:
      tags: [Bookings]
      summary: List bookings
      operationId: listBookings
      security: [ { BearerAuth: [read-bookings] } ]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/StartingAfter"
        - $ref: "#/components/parameters/EndingBefore"
        - $ref: "#/components/parameters/IncludeTotal"
        - $ref: "#/components/parameters/Page"
      responses:
        "200": { description: Paginated list., content: { application/json: { schema: { $ref: "#/components/schemas/BookingList" } } } }
    post:
      tags: [Bookings]
      summary: Create a booking
      description: |
        Programmatic booking creation. Validates slot availability
        against the link's rules just like the public landing.
      operationId: createBooking
      security: [ { BearerAuth: [create-bookings] } ]
      parameters: [ { $ref: "#/components/parameters/IdempotencyKey" } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [data]
              properties: { data: { $ref: "#/components/schemas/BookingInput" } }
      responses:
        "201": { description: Created., content: { application/json: { schema: { $ref: "#/components/schemas/BookingEnvelope" } } } }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/Unprocessable" }
  /calendar/bookings/{id}:
    parameters: [ { $ref: "#/components/parameters/ObjectId" } ]
    get:
      tags: [Bookings]
      summary: Get a booking
      operationId: getBooking
      security: [ { BearerAuth: [read-bookings] } ]
      responses:
        "200": { description: OK., content: { application/json: { schema: { $ref: "#/components/schemas/BookingEnvelope" } } } }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Bookings]
      summary: Cancel a booking
      operationId: deleteBooking
      security: [ { BearerAuth: [delete-bookings] } ]
      responses:
        "204": { description: Cancelled. }
        "404": { $ref: "#/components/responses/NotFound" }

  /users:
    get:
      tags: [Users]
      summary: List team members
      operationId: listUsers
      security: [ { BearerAuth: [read-users] } ]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/StartingAfter"
        - $ref: "#/components/parameters/EndingBefore"
        - $ref: "#/components/parameters/IncludeTotal"
        - $ref: "#/components/parameters/Page"
      responses:
        "200": { description: Paginated list., content: { application/json: { schema: { $ref: "#/components/schemas/UserList" } } } }
    post:
      tags: [Users]
      summary: Create a team member
      description: |
        Limited by the company's `max_team_size`. When the cap is
        reached the response is 422 with `error: "team_limit_reached"`.
        Duplicate emails yield 409 `email_taken`.
      operationId: createUser
      security: [ { BearerAuth: [create-users] } ]
      parameters: [ { $ref: "#/components/parameters/IdempotencyKey" } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [data]
              properties: { data: { $ref: "#/components/schemas/UserInput" } }
      responses:
        "201": { description: Created., content: { application/json: { schema: { $ref: "#/components/schemas/UserEnvelope" } } } }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/Unprocessable" }
  /users/{id}:
    parameters: [ { $ref: "#/components/parameters/ObjectId" } ]
    get:
      tags: [Users]
      summary: Get a team member
      operationId: getUser
      security: [ { BearerAuth: [read-users] } ]
      responses:
        "200": { description: OK., content: { application/json: { schema: { $ref: "#/components/schemas/UserEnvelope" } } } }
        "404": { $ref: "#/components/responses/NotFound" }
    put:
      tags: [Users]
      summary: Update a team member
      operationId: updateUser
      security: [ { BearerAuth: [update-users] } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [data]
              properties: { data: { $ref: "#/components/schemas/UserInput" } }
      responses:
        "200": { description: OK., content: { application/json: { schema: { $ref: "#/components/schemas/UserEnvelope" } } } }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Unprocessable" }
    delete:
      tags: [Users]
      summary: Remove a team member
      description: |
        Removes the user from THIS company. Soft-deletes the user
        record only if it was the last company they belonged to.
        The owner of the API key cannot remove themselves
        (403 `self_delete_forbidden`).
      operationId: deleteUser
      security: [ { BearerAuth: [delete-users] } ]
      responses:
        "204": { description: Removed. }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /users/{id}/avatar:
    parameters: [ { $ref: "#/components/parameters/ObjectId" } ]
    post:
      tags: [Users]
      summary: Upload profile photo
      operationId: uploadUserAvatar
      security: [ { BearerAuth: [update-users] } ]
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [avatar]
              properties:
                avatar: { type: string, format: binary, description: JPG/PNG/WEBP file. }
      responses:
        "200": { description: OK., content: { application/json: { schema: { $ref: "#/components/schemas/UserEnvelope" } } } }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Unprocessable" }

  /company:
    get:
      tags: [Company]
      summary: Get the tenant company
      operationId: getCompany
      security: [ { BearerAuth: [read-company] } ]
      responses:
        "200": { description: OK., content: { application/json: { schema: { $ref: "#/components/schemas/CompanyEnvelope" } } } }
    put:
      tags: [Company]
      summary: Update the tenant company
      operationId: updateCompany
      security: [ { BearerAuth: [update-company] } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [data]
              properties: { data: { $ref: "#/components/schemas/CompanyInput" } }
      responses:
        "200": { description: OK., content: { application/json: { schema: { $ref: "#/components/schemas/CompanyEnvelope" } } } }
        "422": { $ref: "#/components/responses/Unprocessable" }
  /company/logo:
    post:
      tags: [Company]
      summary: Upload company logo
      description: |
        The uploaded logo is rendered as the brand on the public
        booking landing for every booking link of every user in the
        company.
      operationId: uploadCompanyLogo
      security: [ { BearerAuth: [update-company] } ]
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [logo]
              properties:
                logo: { type: string, format: binary, description: JPG/PNG/SVG file. }
      responses:
        "200": { description: OK., content: { application/json: { schema: { $ref: "#/components/schemas/CompanyEnvelope" } } } }
        "422": { $ref: "#/components/responses/Unprocessable" }

  /brands:
    get:
      tags: [Brands]
      summary: List brands
      operationId: listBrands
      security: [ { BearerAuth: [read-brands] } ]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/StartingAfter"
        - $ref: "#/components/parameters/EndingBefore"
        - $ref: "#/components/parameters/IncludeTotal"
        - $ref: "#/components/parameters/Page"
        - name: has_products
          in: query
          required: false
          schema: { type: boolean }
          description: "Si es true, devuelve solo las marcas con al menos un producto ACTIVO asignado (product_count > 0)."
      responses:
        "200": { description: Paginated list of brands., content: { application/json: { schema: { $ref: "#/components/schemas/BrandList" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
    post:
      tags: [Brands]
      summary: Create a brand
      operationId: createBrand
      security: [ { BearerAuth: [create-brands] } ]
      parameters: [ { $ref: "#/components/parameters/IdempotencyKey" } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [data]
              properties: { data: { $ref: "#/components/schemas/BrandInput" } }
      responses:
        "201": { description: Brand created., content: { application/json: { schema: { $ref: "#/components/schemas/BrandEnvelope" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/Unprocessable" }
  /brands/{id}:
    parameters: [ { $ref: "#/components/parameters/ObjectId" } ]
    get:
      tags: [Brands]
      summary: Retrieve a brand
      description: The `{id}` segment accepts the ObjectId or the brand `slug`.
      operationId: getBrand
      security: [ { BearerAuth: [read-brands] } ]
      responses:
        "200": { description: Brand found., content: { application/json: { schema: { $ref: "#/components/schemas/BrandEnvelope" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Brands]
      summary: Update a brand
      operationId: updateBrand
      security: [ { BearerAuth: [update-brands] } ]
      parameters: [ { $ref: "#/components/parameters/IdempotencyKey" } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [data]
              properties: { data: { $ref: "#/components/schemas/BrandInput" } }
      responses:
        "200": { description: Brand updated., content: { application/json: { schema: { $ref: "#/components/schemas/BrandEnvelope" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Unprocessable" }
    delete:
      tags: [Brands]
      summary: Delete a brand
      operationId: deleteBrand
      security: [ { BearerAuth: [delete-brands] } ]
      responses:
        "204": { description: Deleted. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /accounts:
    get:
      tags: [Accounts]
      summary: List accounts
      operationId: listAccounts
      security: [ { BearerAuth: [read-accounts] } ]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/StartingAfter"
        - $ref: "#/components/parameters/EndingBefore"
        - $ref: "#/components/parameters/IncludeTotal"
        - $ref: "#/components/parameters/Page"
      responses:
        "200": { description: Paginated list of accounts., content: { application/json: { schema: { $ref: "#/components/schemas/AccountList" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
    post:
      tags: [Accounts]
      summary: Create an account
      operationId: createAccount
      security: [ { BearerAuth: [create-accounts] } ]
      parameters: [ { $ref: "#/components/parameters/IdempotencyKey" } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [data]
              properties: { data: { $ref: "#/components/schemas/AccountInput" } }
      responses:
        "201": { description: Account created., content: { application/json: { schema: { $ref: "#/components/schemas/AccountEnvelope" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/Unprocessable" }
  /accounts/{id}:
    parameters: [ { $ref: "#/components/parameters/ObjectId" } ]
    get:
      tags: [Accounts]
      summary: Retrieve an account
      operationId: getAccount
      security: [ { BearerAuth: [read-accounts] } ]
      responses:
        "200": { description: Account found., content: { application/json: { schema: { $ref: "#/components/schemas/AccountEnvelope" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Accounts]
      summary: Update an account
      description: System accounts managed by Finova return `403 system_account_protected`. `account_type` is ignored on update.
      operationId: updateAccount
      security: [ { BearerAuth: [update-accounts] } ]
      parameters: [ { $ref: "#/components/parameters/IdempotencyKey" } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [data]
              properties: { data: { $ref: "#/components/schemas/AccountInput" } }
      responses:
        "200": { description: Account updated., content: { application/json: { schema: { $ref: "#/components/schemas/AccountEnvelope" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Unprocessable" }
    delete:
      tags: [Accounts]
      summary: Delete an account
      operationId: deleteAccount
      security: [ { BearerAuth: [delete-accounts] } ]
      responses:
        "204": { description: Deleted. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /incomes:
    get:
      tags: [Incomes]
      summary: List incomes
      operationId: listIncomes
      security: [ { BearerAuth: [read-incomes] } ]
      parameters:
        - $ref: "#/components/parameters/Search"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/StartingAfter"
        - $ref: "#/components/parameters/EndingBefore"
        - $ref: "#/components/parameters/IncludeTotal"
        - $ref: "#/components/parameters/Page"
      responses:
        "200": { description: Paginated list of incomes., content: { application/json: { schema: { $ref: "#/components/schemas/IncomeList" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
    post:
      tags: [Incomes]
      summary: Create an income
      description: Fires the same side effects as the in-app flow (account balance, product sales counters, inventory reservation, `sale_created` automations). Writes in a locked period return `422 period_locked`.
      operationId: createIncome
      security: [ { BearerAuth: [create-incomes] } ]
      parameters: [ { $ref: "#/components/parameters/IdempotencyKey" } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [data]
              properties: { data: { $ref: "#/components/schemas/IncomeInput" } }
      responses:
        "201": { description: Income created (includes items)., content: { application/json: { schema: { $ref: "#/components/schemas/IncomeEnvelope" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/Unprocessable" }
  /incomes/{id}:
    parameters: [ { $ref: "#/components/parameters/ObjectId" } ]
    get:
      tags: [Incomes]
      summary: Retrieve an income
      description: The detail response includes the embedded `items`.
      operationId: getIncome
      security: [ { BearerAuth: [read-incomes] } ]
      responses:
        "200": { description: Income found., content: { application/json: { schema: { $ref: "#/components/schemas/IncomeEnvelope" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Incomes]
      summary: Update an income
      operationId: updateIncome
      security: [ { BearerAuth: [update-incomes] } ]
      parameters: [ { $ref: "#/components/parameters/IdempotencyKey" } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [data]
              properties: { data: { $ref: "#/components/schemas/IncomeInput" } }
      responses:
        "200": { description: Income updated., content: { application/json: { schema: { $ref: "#/components/schemas/IncomeEnvelope" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Unprocessable" }
    delete:
      tags: [Incomes]
      summary: Delete an income
      operationId: deleteIncome
      security: [ { BearerAuth: [delete-incomes] } ]
      responses:
        "204": { description: Deleted. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /expenses:
    get:
      tags: [Expenses]
      summary: List expenses
      operationId: listExpenses
      security: [ { BearerAuth: [read-expenses] } ]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/StartingAfter"
        - $ref: "#/components/parameters/EndingBefore"
        - $ref: "#/components/parameters/IncludeTotal"
        - $ref: "#/components/parameters/Page"
      responses:
        "200": { description: Paginated list of expenses., content: { application/json: { schema: { $ref: "#/components/schemas/ExpenseList" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
    post:
      tags: [Expenses]
      summary: Create an expense
      description: Fires the same side effects as the in-app flow (account balance, automations). Writes in a locked period return `422 period_locked`.
      operationId: createExpense
      security: [ { BearerAuth: [create-expenses] } ]
      parameters: [ { $ref: "#/components/parameters/IdempotencyKey" } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [data]
              properties: { data: { $ref: "#/components/schemas/ExpenseInput" } }
      responses:
        "201": { description: Expense created., content: { application/json: { schema: { $ref: "#/components/schemas/ExpenseEnvelope" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/Unprocessable" }
  /expenses/{id}:
    parameters: [ { $ref: "#/components/parameters/ObjectId" } ]
    get:
      tags: [Expenses]
      summary: Retrieve an expense
      operationId: getExpense
      security: [ { BearerAuth: [read-expenses] } ]
      responses:
        "200": { description: Expense found., content: { application/json: { schema: { $ref: "#/components/schemas/ExpenseEnvelope" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Expenses]
      summary: Update an expense
      operationId: updateExpense
      security: [ { BearerAuth: [update-expenses] } ]
      parameters: [ { $ref: "#/components/parameters/IdempotencyKey" } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [data]
              properties: { data: { $ref: "#/components/schemas/ExpenseInput" } }
      responses:
        "200": { description: Expense updated., content: { application/json: { schema: { $ref: "#/components/schemas/ExpenseEnvelope" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Unprocessable" }
    delete:
      tags: [Expenses]
      summary: Delete an expense
      operationId: deleteExpense
      security: [ { BearerAuth: [delete-expenses] } ]
      responses:
        "204": { description: Deleted. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /reports/balance-sheet:
    get:
      tags: [Reports]
      summary: Balance sheet
      description: Point-in-time snapshot of assets, liabilities and equity (by currency). `start_on` optional; `end_on` defaults to today.
      operationId: reportBalanceSheet
      security: [ { BearerAuth: [read-reports] } ]
      parameters:
        - { name: start_on, in: query, required: false, schema: { type: string, format: date } }
        - { name: end_on,   in: query, required: false, schema: { type: string, format: date } }
        - { name: timezone, in: query, required: false, schema: { type: string, default: America/Mexico_City } }
      responses:
        "200": { description: Report computed., content: { application/json: { schema: { $ref: "#/components/schemas/ReportEnvelope" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/Unprocessable" }
  /reports/income-statement:
    get:
      tags: [Reports]
      summary: Income statement (P&L)
      description: Revenue, costs, expenses and net income for the range. `start_on` and `end_on` are required.
      operationId: reportIncomeStatement
      security: [ { BearerAuth: [read-reports] } ]
      parameters:
        - { name: start_on, in: query, required: true,  schema: { type: string, format: date } }
        - { name: end_on,   in: query, required: true,  schema: { type: string, format: date } }
        - { name: timezone, in: query, required: false, schema: { type: string, default: America/Mexico_City } }
      responses:
        "200": { description: Report computed., content: { application/json: { schema: { $ref: "#/components/schemas/ReportEnvelope" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/Unprocessable" }
  /reports/cash-flow:
    get:
      tags: [Reports]
      summary: Cash flow
      description: Cash inflows and outflows for the range. `start_on` and `end_on` are required.
      operationId: reportCashFlow
      security: [ { BearerAuth: [read-reports] } ]
      parameters:
        - { name: start_on, in: query, required: true,  schema: { type: string, format: date } }
        - { name: end_on,   in: query, required: true,  schema: { type: string, format: date } }
        - { name: timezone, in: query, required: false, schema: { type: string, default: America/Mexico_City } }
      responses:
        "200": { description: Report computed., content: { application/json: { schema: { $ref: "#/components/schemas/ReportEnvelope" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/Unprocessable" }

components:
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      bearerFormat: finova_sk_<secret>
      description: |
        Send your API key as `Authorization: Bearer finova_sk_<secret>`.
        Generate keys from Settings → Developers in the Finova UI.

  parameters:
    Page:
      name: page
      in: query
      required: false
      schema: { type: integer, minimum: 1, maximum: 200, default: 1 }
      description: |
        Legacy offset pagination. 1-indexed. Capped at 200 — beyond that
        the server returns 400 `offset_too_deep` because `skip()` cost
        grows linearly in MongoDB. Use `starting_after` / `ending_before`
        for traversal beyond ~5000 records.
    Limit:
      name: limit
      in: query
      required: false
      schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
      description: Items per page. Hard cap is 100.
    StartingAfter:
      name: starting_after
      in: query
      required: false
      schema: { type: string, pattern: "^[a-f0-9]{24}$" }
      description: |
        Cursor — id of the last record on the previous page. Server
        returns the next page of older records (ordered `created_at`
        DESC, then `_id` DESC for tie-breaking). Index-only; cost is
        O(limit) regardless of dataset size. Mutually exclusive with
        `ending_before` and `page`.
    EndingBefore:
      name: ending_before
      in: query
      required: false
      schema: { type: string, pattern: "^[a-f0-9]{24}$" }
      description: |
        Cursor — id of the first record on the current page. Server
        returns the previous page (newer records). Mutually exclusive
        with `starting_after` and `page`.
    IncludeTotal:
      name: include_total
      in: query
      required: false
      schema: { type: boolean, default: false }
      description: |
        Opt-in. When `true`, the response `meta` includes `total` and
        `total_pages`. The count is memoized for 60 s per tenant to
        absorb bursty polling. Off by default because computing the
        total scans the whole tenant slice of the collection (~250 ms
        at 1M records) — most clients only need `has_more`.
    IncludeFilteredTotal:
      name: include_filtered_total
      in: query
      required: false
      schema: { type: boolean, default: false }
      description: |
        Opt-in. When `true`, the response `meta` includes
        `filtered_total` and `filtered_total_pages` — the count of the
        result set AFTER the request's filters are applied (e.g. `search`
        / `q`, `brand`, `category`). This is what you want for "showing X
        of Y results" when filtering; contrast with `include_total`,
        which always counts the whole tenant catalog and ignores filters.
        Not cached (it's keyed to your filters). The count is bounded at
        10000 to keep latency flat on huge tenants — if the real total is
        larger, `filtered_total` saturates at 10000 and
        `filtered_total_capped` is `true` (treat it as "10000+"). When a
        free-text `search`/`q` is active the candidate set is itself capped
        at 100, so the count saturates there instead.
    Search:
      name: search
      in: query
      required: false
      schema: { type: string }
      description: |
        Full-text / autocomplete filter (alias: `q`). Backed by an Atlas
        Search index over the resource's text fields. Returns up to the
        100 most relevant matches (relevance-ranked by Atlas), which are
        then paginated newest-first like any other list. Tenant-scoped:
        results never cross companies. Blank/whitespace-only is ignored.
    ObjectId:
      name: id
      in: path
      required: true
      schema: { type: string, pattern: "^[a-f0-9]{24}$" }
      description: Identificador del recurso (string hexadecimal de 24 caracteres).
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      schema: { type: string, maxLength: 255 }
      description: |
        Optional opaque token. Replays of a POST/PATCH with the same key
        within 24 hours return the original response instead of creating a
        duplicate resource. See `/guides/idempotency`.

  responses:
    BadRequest:
      description: Malformed request — typically `offset_too_deep` (see Pagination guide).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: offset_too_deep, message: "Offset pagination is capped at page 200. Use cursor pagination (?starting_after=) for deeper traversal." }
    Unauthorized:
      description: Missing, invalid, or revoked API key.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: invalid_key, message: Authentication failed. }
    Forbidden:
      description: Key is valid but missing the required scope or origin.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: insufficient_scope, message: "This API key does not have the required scope.", required: read-products }
    NotFound:
      description: Resource does not exist or belongs to another company.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: not_found, message: Resource not found. }
    Unprocessable:
      description: Validation failed or bad input.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: validation_failed, message: "Name can't be blank", errors: { name: ["can't be blank"] } }
    TooManyRequests:
      description: Rate limit exceeded (60/min/IP, 600/min/key).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: rate_limited, message: Too many requests. }
    Conflict:
      description: |
        Conflicting state — e.g. `email_taken` when creating a user
        whose email already exists, or `slot_unavailable` when the
        chosen booking time is no longer free.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: email_taken, message: "Ya existe un usuario con ese correo." }

  schemas:
    Meta:
      type: object
      description: |
        Pagination envelope. `limit`, `has_more`, `next_cursor`, and
        `previous_cursor` are always present. `page` appears only when
        the caller used offset pagination (`?page=`). `total` and
        `total_pages` appear only when the caller opted in with
        `?include_total=true` (whole-catalog count, filter-agnostic).
        `filtered_total` and `filtered_total_pages` appear only with
        `?include_filtered_total=true` and reflect the active filters
        (`search`/`q`, `brand`, `category`).
      required: [limit, has_more, next_cursor, previous_cursor]
      properties:
        limit:           { type: integer, example: 25 }
        has_more:
          type: boolean
          description: |
            True if there is at least one more record beyond this page.
            Computed by peeking `limit + 1` rows, so it is free.
        next_cursor:
          type: string
          nullable: true
          description: |
            Pass as `?starting_after=` to fetch the next page. The id of
            the last record in `data`; `null` on an empty page.
        previous_cursor:
          type: string
          nullable: true
          description: |
            Pass as `?ending_before=` to fetch the previous page. The id
            of the first record in `data`; `null` on an empty page.
        page:        { type: integer, example: 1, description: "Present only when the caller used `?page=`." }
        total:       { type: integer, example: 137, description: "Present only when `?include_total=true`. Whole-catalog count, ignores filters." }
        total_pages: { type: integer, example: 6, description: "Present only when `?include_total=true`." }
        filtered_total:       { type: integer, example: 12, description: "Present only when `?include_filtered_total=true`. Count after `search`/`brand`/`category` filters. Bounded at 10000 (saturates there; at 100 when `search`/`q` is active)." }
        filtered_total_pages: { type: integer, example: 1, description: "Present only when `?include_filtered_total=true`." }
        filtered_total_capped: { type: boolean, example: false, description: "Present only when `?include_filtered_total=true`. `true` when the real total exceeded the 10000 bound, so `filtered_total` is a floor (\"10000+\"), not exact." }

    Error:
      type: object
      required: [error, message]
      properties:
        error:    { type: string, example: validation_failed }
        message:  { type: string, example: "Name can't be blank" }
        errors:
          type: object
          additionalProperties:
            type: array
            items: { type: string }
        required:
          type: string
          description: Present on `insufficient_scope` only.

    Product:
      type: object
      description: >
        Los campos personalizados (specs) se devuelven aplanados como claves
        de primer nivel adicionales (ej. `voltaje_maximo: 110`), por eso
        additionalProperties está habilitado. Las claves reservadas (las
        listadas abajo) nunca se sobrescriben.
      additionalProperties: true
      properties:
        id:                { type: string, example: 65a0c0f1234567890abcdef0 }
        name:              { type: string, example: "Servicio de página web" }
        slug:              { type: string, nullable: true, example: servicio-pagina-web }
        sku:               { type: string, nullable: true, example: WEB-001 }
        description:       { type: string, nullable: true }
        product_type:      { type: string, enum: [simple, service, variable, composite] }
        is_active:         { type: boolean }
        tags:              { type: array, items: { type: string } }
        category_name:     { type: string, nullable: true, description: "Deprecado pero presente. Usa product_category." }
        product_category:
          type: object
          nullable: true
          description: "Referencia denormalizada a la categoría de catálogo. Distinta de la categoría financiera de incomes/expenses."
          properties:
            id:   { type: string, nullable: true }
            name: { type: string, nullable: true }
            slug: { type: string, nullable: true }
        brand:
          type: object
          nullable: true
          description: "Referencia denormalizada a la marca. Edita la marca completa en /brands."
          properties:
            id:   { type: string, nullable: true }
            name: { type: string, nullable: true }
            slug: { type: string, nullable: true }
        gtin:              { type: string, nullable: true, description: "GTIN / EAN / UPC / ISBN." }
        mpn:               { type: string, nullable: true, description: "Manufacturer Part Number." }
        condition:         { type: string, enum: [new, used, refurbished, damaged] }
        prices_cents:
          type: object
          additionalProperties: { type: integer }
          description: "Mapa multi-moneda { <iso4217_lowercase>: <centavos> }. Reemplaza currency/price_cents."
          example: { mxn: 1500000, usd: 25000 }
        inventory_enabled: { type: boolean }
        inventory_mode:    { type: string, enum: [materials, self, hybrid] }
        taxable:           { type: boolean }
        tax_rate_percent:  { type: number, format: float, example: 16.0 }
        image_url:         { type: string, nullable: true, description: "URL firmada de la imagen principal." }
        thumbnail_url:     { type: string, nullable: true, description: "Igual que image_url (no se genera derivado)." }
        images:
          type: array
          description: "Galería plana (imagen principal en position 0, luego la galería SEO)."
          items: { $ref: "#/components/schemas/ProductImage" }
        seo: { $ref: "#/components/schemas/Seo" }
        variants:
          type: array
          description: "Solo en el detalle (GET /products/{id})."
          items: { $ref: "#/components/schemas/ProductVariant" }
        option_types:
          type: array
          description: "Solo en el detalle."
          items: { $ref: "#/components/schemas/ProductOptionType" }
        components:
          type: array
          description: "Solo en el detalle (productos composite)."
          items: { $ref: "#/components/schemas/ProductComponent" }
        inventory:
          allOf: [ { $ref: "#/components/schemas/ProductInventory" } ]
          nullable: true
          description: "Solo en el detalle. null si el producto no lleva inventario."
        created_at:        { type: string, format: date-time }
        updated_at:        { type: string, format: date-time }
    ProductInput:
      type: object
      properties:
        name:              { type: string }
        slug:              { type: string }
        sku:               { type: string }
        description:       { type: string, description: "Alias público; mapea internamente a finova_description." }
        product_type:      { type: string, enum: [simple, service, variable, composite] }
        is_active:         { type: boolean }
        category_name:     { type: string }
        product_category_id: { type: string, description: "ObjectId de la ProductCategory (catálogo)." }
        brand_id:          { type: string, description: "ObjectId de la Brand." }
        gtin:              { type: string }
        mpn:               { type: string }
        condition:         { type: string, enum: [new, used, refurbished, damaged] }
        prices_cents:
          type: object
          additionalProperties: { type: integer }
          description: "Mapa multi-moneda { <iso4217>: <centavos> }. Reemplaza currency/price_cents."
          example: { mxn: 1500000, usd: 25000 }
        inventory_enabled: { type: boolean }
        inventory_mode:    { type: string, enum: [materials, self, hybrid] }
        taxable:           { type: boolean }
        tax_rate_percent:  { type: number, format: float }
        tags:              { type: array, items: { type: string } }
        custom_properties:
          type: array
          items: { $ref: "#/components/schemas/CustomProperty" }
        seo: { $ref: "#/components/schemas/Seo" }
        variants:
          type: array
          description: "Nested attributes: sin id crea, con id actualiza, _destroy elimina."
          items: { $ref: "#/components/schemas/ProductVariantInput" }
        option_types:
          type: array
          items: { $ref: "#/components/schemas/ProductOptionTypeInput" }
        components:
          type: array
          items: { $ref: "#/components/schemas/ProductComponentInput" }
      required: [name]
    ProductEnvelope:
      type: object
      properties:
        data: { $ref: "#/components/schemas/Product" }
    ProductList:
      type: object
      properties:
        data: { type: array, items: { $ref: "#/components/schemas/Product" } }
        meta: { $ref: "#/components/schemas/Meta" }

    MediaItem:
      type: object
      description: "Media item compartido por SEO y custom_properties de tipo file."
      properties:
        asset_id:         { type: string, nullable: true }
        url:              { type: string }
        name:             { type: string, nullable: true }
        content_type:     { type: string, nullable: true }
        size_bytes:       { type: integer, nullable: true }
        file_filename:    { type: string, nullable: true }
        alt_translations: { type: object, additionalProperties: { type: string } }
    Seo:
      type: object
      description: >
        Contenido localizado + multimedia para tu sitio público. Los campos
        localizados se escriben vía `*_translations` ({ locale: valor }).
      additionalProperties: true
      properties:
        seo_title:         { type: string, nullable: true }
        meta_description:  { type: string, nullable: true }
        meta_keywords:     { type: string, nullable: true }
        short_description: { type: string, nullable: true }
        description:       { type: string, nullable: true }
        og_title:          { type: string, nullable: true }
        og_description:    { type: string, nullable: true }
        indexable:         { type: boolean }
        og_image: { $ref: "#/components/schemas/MediaItem" }
        banner:   { $ref: "#/components/schemas/MediaItem" }
        video:    { $ref: "#/components/schemas/MediaItem" }
        gallery:  { type: array, items: { $ref: "#/components/schemas/MediaItem" } }
    ProductImage:
      type: object
      properties:
        url:      { type: string }
        alt:      { type: string, nullable: true }
        position: { type: integer }
    ProductVariant:
      type: object
      properties:
        id:          { type: string }
        name:        { type: string }
        sku:         { type: string, nullable: true }
        prices_cents:
          type: object
          additionalProperties: { type: integer }
          description: "Mapa multi-moneda; vacío si hereda el precio del producto."
        is_active:   { type: boolean }
        options:     { type: object, additionalProperties: { type: string }, description: "Ej. { color: red, size: M }." }
        image_url:   { type: string, nullable: true }
    ProductVariantInput:
      type: object
      properties:
        id:          { type: string, description: "Omitir para crear." }
        _destroy:    { type: boolean }
        name:        { type: string }
        sku:         { type: string }
        prices_cents:
          type: object
          additionalProperties: { type: integer }
          description: "Mapa multi-moneda; si se omite hereda el precio del producto."
        is_active:   { type: boolean }
        options:     { type: object, additionalProperties: { type: string } }
    ProductOptionValue:
      type: object
      properties:
        name:              { type: string }
        value:             { type: string }
        presentation:      { type: string, nullable: true }
        position:          { type: integer }
        extra_price_cents: { type: integer }
    ProductOptionType:
      type: object
      properties:
        name:         { type: string }
        presentation: { type: string, nullable: true }
        position:     { type: integer }
        values:       { type: array, items: { $ref: "#/components/schemas/ProductOptionValue" } }
    ProductOptionTypeInput:
      type: object
      properties:
        id:           { type: string }
        _destroy:     { type: boolean }
        name:         { type: string }
        presentation: { type: string }
        position:     { type: integer }
        values:
          type: array
          items:
            type: object
            properties:
              id:                { type: string }
              _destroy:          { type: boolean }
              name:              { type: string }
              value:             { type: string }
              presentation:      { type: string }
              position:          { type: integer }
              extra_price_cents: { type: integer }
    ProductComponent:
      type: object
      properties:
        id:                   { type: string }
        name:                 { type: string }
        component_product_id: { type: string, nullable: true }
        quantity:             { type: number }
        is_required:          { type: boolean }
        extra_prices_cents:
          type: object
          additionalProperties: { type: integer }
          description: "Mapa multi-moneda del ajuste de precio del componente."
    ProductComponentInput:
      type: object
      properties:
        id:                   { type: string }
        _destroy:             { type: boolean }
        name:                 { type: string }
        component_product_id: { type: string }
        quantity:             { type: number }
        is_required:          { type: boolean }
        extra_prices_cents:
          type: object
          additionalProperties: { type: integer }
          description: "Mapa multi-moneda del ajuste de precio del componente."
    ProductInventoryLocation:
      type: object
      properties:
        warehouse_id:   { type: string, nullable: true }
        warehouse_name: { type: string, nullable: true }
        rack_id:        { type: string, nullable: true }
        rack_name:      { type: string, nullable: true }
        on_hand_qty:    { type: string, nullable: true }
        reserved_qty:   { type: string, nullable: true }
        available_qty:  { type: string, nullable: true }
    ProductInventory:
      type: object
      description: "Solo disponibilidad (cantidades como string decimal); nunca costos."
      properties:
        enabled:       { type: boolean }
        unit:          { type: string, nullable: true }
        on_hand_qty:   { type: string, nullable: true }
        reserved_qty:  { type: string, nullable: true }
        available_qty: { type: string, nullable: true }
        by_location:   { type: array, items: { $ref: "#/components/schemas/ProductInventoryLocation" } }

    CustomProperty:
      type: object
      description: >
        Atributo estructurado definido por el usuario (specs), usado SOLO en
        la escritura (dentro del array `custom_properties` de la entrada). En
        la lectura los campos personalizados se devuelven aplanados como
        claves de primer nivel del recurso. `key` es un identificador estilo
        variable (minúsculas, números y guion bajo); las claves que coincidan
        con un campo reservado del recurso se descartan. La forma de `value`
        depende de `type`. Para `file`/`file_array`, `value` es un media item
        (o lista) con `{ asset_id, url, name, content_type, size_bytes,
        file_filename, alt_translations }`.
      required: [key, value]
      properties:
        key:   { type: string, example: voltaje_maximo }
        type:
          type: string
          description: "Si se omite, se infiere del `value`."
          enum: [string, number, boolean, array, object, object_array, file, file_array]
        value:
          description: "Tipo dependiente de `type` (escalar, lista, objeto o media item)."
          nullable: true

    ProductCategory:
      type: object
      properties:
        id:            { type: string, example: 65c2b2c234567890abcdef22 }
        name:          { type: string, example: Lentes }
        slug:          { type: string, nullable: true, example: lentes }
        parent_id:     { type: string, nullable: true }
        icon:          { type: string, nullable: true, description: "Material Symbol.", example: visibility }
        image_url:     { type: string, nullable: true }
        product_count: { type: integer, example: 8, description: "Productos ACTIVOS (is_active: true) asignados DIRECTAMENTE a esta categoría (cacheado; misma visibilidad que el default de GET /products)." }
        product_count_including_descendants: { type: integer, example: 42, description: "Productos ACTIVOS del subárbol: directos + los de todas las categorías descendientes (cacheado)." }
        is_active:     { type: boolean }
        position:      { type: integer, example: 1 }
        created_at:    { type: string, format: date-time }
        updated_at:    { type: string, format: date-time }
    ProductCategoryInput:
      type: object
      properties:
        name:      { type: string }
        slug:      { type: string }
        description: { type: string }
        parent_id: { type: string }
        icon:      { type: string }
        is_active: { type: boolean }
        position:  { type: integer }
        tags:      { type: array, items: { type: string } }
        image_url: { type: string, description: "URL de la imagen; equivalente a enviar `image` como media item. Cadena vacía la borra." }
      required: [name]
    ProductCategoryEnvelope:
      type: object
      properties:
        data: { $ref: "#/components/schemas/ProductCategory" }
    ProductCategoryList:
      type: object
      properties:
        data: { type: array, items: { $ref: "#/components/schemas/ProductCategory" } }
        meta: { $ref: "#/components/schemas/Meta" }

    Customer:
      type: object
      properties:
        id:         { type: string }
        name:       { type: string }
        short_name: { type: string, nullable: true }
        email:      { type: string, format: email, nullable: true }
        phone:      { type: string, nullable: true }
        birthdate:  { type: string, format: date, nullable: true }
        source:     { type: string, nullable: true }
        notes:      { type: string, nullable: true }
        is_active:  { type: boolean }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    CustomerInput:
      type: object
      properties:
        name:       { type: string }
        short_name: { type: string }
        email:      { type: string, format: email }
        phone:      { type: string }
        birthdate:  { type: string, format: date }
        source:     { type: string }
        notes:      { type: string }
        is_active:  { type: boolean }
      required: [name]
    CustomerEnvelope:
      type: object
      properties:
        data: { $ref: "#/components/schemas/Customer" }
    CustomerList:
      type: object
      properties:
        data: { type: array, items: { $ref: "#/components/schemas/Customer" } }
        meta: { $ref: "#/components/schemas/Meta" }

    Lead:
      type: object
      properties:
        id:                       { type: string }
        name:                     { type: string }
        email:                    { type: string, format: email, nullable: true }
        phone:                    { type: string, nullable: true }
        company_name:             { type: string, nullable: true }
        job_title:                { type: string, nullable: true }
        source:                   { type: string, nullable: true }
        preferred_contact_method: { type: string, nullable: true, example: email }
        status:                   { type: string, example: new }
        notes:                    { type: string, nullable: true }
        metadata:
          type: object
          additionalProperties: { type: string }
          description: Free-form string→string map. Total serialized size capped at 4 KB.
        converted:                { type: boolean, description: True if the lead has a customer_id. }
        created_at:               { type: string, format: date-time }
        updated_at:               { type: string, format: date-time }
    LeadInput:
      type: object
      properties:
        name:                     { type: string }
        email:                    { type: string, format: email }
        phone:                    { type: string }
        company_name:             { type: string }
        job_title:                { type: string }
        source:                   { type: string }
        preferred_contact_method: { type: string }
        status:                   { type: string }
        notes:                    { type: string }
        metadata:
          type: object
          additionalProperties: { type: string }
      required: [name]
    LeadEnvelope:
      type: object
      properties:
        data: { $ref: "#/components/schemas/Lead" }
    LeadList:
      type: object
      properties:
        data: { type: array, items: { $ref: "#/components/schemas/Lead" } }
        meta: { $ref: "#/components/schemas/Meta" }

    Automation:
      type: object
      properties:
        id:           { type: string }
        name:         { type: string }
        description:  { type: string, nullable: true }
        active:       { type: boolean }
        trigger_type: { type: string, example: lead_created }
        steps_count:  { type: integer, example: 3 }
        created_at:   { type: string, format: date-time }
        updated_at:   { type: string, format: date-time }
    AutomationInput:
      type: object
      properties:
        name:        { type: string }
        description: { type: string }
        active:      { type: boolean }
    AutomationEnvelope:
      type: object
      properties:
        data: { $ref: "#/components/schemas/Automation" }
    AutomationList:
      type: object
      properties:
        data: { type: array, items: { $ref: "#/components/schemas/Automation" } }
        meta: { $ref: "#/components/schemas/Meta" }

    # ── Calendar ──────────────────────────────────────────────────────
    CalendarEvent:
      type: object
      properties:
        id:                { type: string }
        user_id:           { type: string, description: Host user. }
        title:             { type: string }
        description:       { type: string, nullable: true }
        location:          { type: string, nullable: true }
        starts_at:         { type: string, format: date-time }
        ends_at:           { type: string, format: date-time }
        timezone:          { type: string, example: America/Mexico_City }
        kind:              { type: string, enum: [meeting, event] }
        source:            { type: string, nullable: true, example: booking }
        booking_link_id:   { type: string, nullable: true }
        booking_id:        { type: string, nullable: true }
        google_event_id:   { type: string, nullable: true }
        meet_url:          { type: string, nullable: true }
        attendees:
          type: array
          items:
            type: object
            properties:
              name:  { type: string }
              email: { type: string, format: email }
        reminders_minutes: { type: array, items: { type: integer } }
        created_at:        { type: string, format: date-time }
        updated_at:        { type: string, format: date-time }
    CalendarEventInput:
      type: object
      properties:
        user_id:           { type: string }
        title:             { type: string }
        description:       { type: string }
        location:          { type: string }
        starts_at:         { type: string, format: date-time }
        ends_at:           { type: string, format: date-time }
        timezone:          { type: string }
        kind:              { type: string, enum: [meeting, event] }
        create_meet:       { type: boolean, description: When true and a Google account is linked, generates a Meet URL. }
        attendees:
          type: array
          items:
            type: object
            properties:
              name:  { type: string }
              email: { type: string, format: email }
        reminders_minutes: { type: array, items: { type: integer } }
      required: [title, starts_at, ends_at]
    CalendarEventEnvelope:
      type: object
      properties: { data: { $ref: "#/components/schemas/CalendarEvent" } }
    CalendarEventList:
      type: object
      properties:
        data: { type: array, items: { $ref: "#/components/schemas/CalendarEvent" } }
        meta: { $ref: "#/components/schemas/Meta" }

    BookingLink:
      type: object
      properties:
        id:                    { type: string }
        user_id:               { type: string }
        slug:                  { type: string, description: "URL slug — unique per user.", example: "intro-call" }
        title:                 { type: string }
        description:           { type: string, nullable: true }
        kind:                  { type: string, enum: [meeting, event] }
        duration_minutes:      { type: integer, example: 30 }
        buffer_before_minutes: { type: integer }
        buffer_after_minutes:  { type: integer }
        min_notice_minutes:    { type: integer }
        max_advance_days:      { type: integer }
        slot_interval_minutes: { type: integer, example: 30 }
        timezone:              { type: string }
        availability:
          type: object
          description: |
            Weekly windows keyed by `0..6` (Sunday..Saturday) as strings.
            Each value is an array of `{ from: "HH:MM", to: "HH:MM" }`.
          additionalProperties:
            type: array
            items:
              type: object
              properties:
                from: { type: string, example: "09:00" }
                to:   { type: string, example: "13:00" }
        blocked_dates: { type: array, items: { type: string, format: date } }
        active:        { type: boolean }
        custom_fields:
          type: array
          items: { $ref: "#/components/schemas/BookingCustomField" }
        create_lead:   { type: boolean }
        lead_source:   { type: string, nullable: true, example: "Finova Calendar" }
        location_type: { type: string, enum: [online, in_person, phone, none] }
        location_value: { type: string, nullable: true }
        auto_meet:     { type: boolean }
        confirmation_message: { type: string, nullable: true }
        public_url:    { type: string, nullable: true, description: Resolves to null until the host user has set `booking_slug`. }
        created_at:    { type: string, format: date-time }
        updated_at:    { type: string, format: date-time }
    BookingLinkInput:
      type: object
      properties:
        user_id:               { type: string }
        slug:                  { type: string }
        title:                 { type: string }
        description:           { type: string }
        kind:                  { type: string, enum: [meeting, event] }
        duration_minutes:      { type: integer }
        buffer_before_minutes: { type: integer }
        buffer_after_minutes:  { type: integer }
        min_notice_minutes:    { type: integer }
        max_advance_days:      { type: integer }
        slot_interval_minutes: { type: integer }
        timezone:              { type: string }
        availability: { type: object, additionalProperties: { type: array } }
        blocked_dates: { type: array, items: { type: string, format: date } }
        active:        { type: boolean }
        custom_fields:
          type: array
          items: { $ref: "#/components/schemas/BookingCustomField" }
        create_lead:   { type: boolean }
        lead_source:   { type: string }
        location_type: { type: string, enum: [online, in_person, phone, none] }
        location_value: { type: string }
        auto_meet:     { type: boolean }
        confirmation_message: { type: string }
      required: [user_id, title, duration_minutes]
    BookingLinkEnvelope:
      type: object
      properties: { data: { $ref: "#/components/schemas/BookingLink" } }
    BookingLinkList:
      type: object
      properties:
        data: { type: array, items: { $ref: "#/components/schemas/BookingLink" } }
        meta: { $ref: "#/components/schemas/Meta" }
    BookingCustomField:
      type: object
      properties:
        key:         { type: string, example: company_size }
        label:       { type: string, example: "Tamaño de equipo" }
        field_type:  { type: string, enum: [text, textarea, email, checkbox] }
        required:    { type: boolean }
        placeholder: { type: string }
        options:     { type: array, items: { type: string } }

    Booking:
      type: object
      properties:
        id:              { type: string }
        booking_link_id: { type: string }
        user_id:         { type: string }
        event_id:        { type: string, nullable: true }
        lead_id:         { type: string, nullable: true }
        name:            { type: string }
        email:           { type: string, format: email }
        starts_at:       { type: string, format: date-time }
        ends_at:         { type: string, format: date-time }
        timezone:        { type: string }
        status:          { type: string, enum: [confirmed, canceled], example: confirmed }
        responses:
          type: object
          additionalProperties: true
          description: Map of custom field key → submitted value.
        cancel_token:    { type: string, nullable: true }
        created_at:      { type: string, format: date-time }
        updated_at:      { type: string, format: date-time }
    BookingInput:
      type: object
      properties:
        booking_link_id: { type: string }
        name:            { type: string }
        email:           { type: string, format: email }
        starts_at:       { type: string, format: date-time }
        responses:       { type: object, additionalProperties: true }
        utm:
          type: object
          description: Optional UTM attribution. `utm_source` flows into the lead's `source`.
          properties:
            utm_source:   { type: string }
            utm_medium:   { type: string }
            utm_campaign: { type: string }
            utm_term:     { type: string }
            utm_content:  { type: string }
      required: [booking_link_id, name, email, starts_at]
    BookingEnvelope:
      type: object
      properties: { data: { $ref: "#/components/schemas/Booking" } }
    BookingList:
      type: object
      properties:
        data: { type: array, items: { $ref: "#/components/schemas/Booking" } }
        meta: { $ref: "#/components/schemas/Meta" }

    # ── Users (team) ──────────────────────────────────────────────────
    BookingBrandingPalette:
      type: object
      description: |
        Background palette for one theme (light or dark). Defines mode +
        colors used to render the bg of the public booking landing.
      properties:
        bg_mode:
          type: string
          enum: [solid, linear, radial]
          example: linear
        bg_color_from: { type: string, example: "#0080ff", description: Hex color. Sólo el bg en `solid`, color inicial del gradiente en lineal/radial. }
        bg_color_to:   { type: string, example: "#001a33", description: Hex color final del gradiente (sólo `linear`/`radial`). }
        bg_angle:      { type: integer, minimum: 0, maximum: 360, example: 135, description: Ángulo del gradiente lineal en grados. }
    BookingBranding:
      type: object
      description: |
        Per-user branding for the public booking landing
        (`book.fi-nova.com/<booking_slug>/<link_slug>`). Each theme
        keeps its own bg palette so a host can tune light and dark
        independently.

        Theme selection on the landing is automatic: dark from ~7pm to
        7am local time of the visitor, light otherwise. The visitor
        can override the auto choice with a toggle in the page. The
        stored `theme` value is **only** a hint for the dashboard UI
        (which palette is currently being edited) — it does NOT
        control the public landing.
      properties:
        theme:
          type: string
          enum: [light, dark]
          description: Which palette the host last edited in the dashboard. Does not control the public landing.
          example: dark
        light: { $ref: "#/components/schemas/BookingBrandingPalette" }
        dark:  { $ref: "#/components/schemas/BookingBrandingPalette" }
    UserSettingsPreferences:
      type: object
      properties:
        theme:                       { type: string, enum: [light, dark, schedule], description: Dashboard theme (independiente del de la landing). }
        block_overlapping_bookings:  { type: boolean }
        booking_branding:            { $ref: "#/components/schemas/BookingBranding" }
    User:
      type: object
      properties:
        id:           { type: string }
        first_name:   { type: string, nullable: true }
        last_name:    { type: string, nullable: true }
        full_name:    { type: string, nullable: true }
        email:        { type: string, format: email }
        phone:        { type: string, nullable: true }
        username:     { type: string, nullable: true }
        booking_slug: { type: string, nullable: true, description: "Slug público del usuario en book.fi-nova.com/<slug>/<liga>." }
        booking_url:  { type: string, nullable: true }
        avatar_url:   { type: string, nullable: true }
        is_owner:     { type: boolean }
        settings_preferences: { $ref: "#/components/schemas/UserSettingsPreferences" }
        created_at:   { type: string, format: date-time }
        updated_at:   { type: string, format: date-time }
    UserInput:
      type: object
      properties:
        first_name:   { type: string }
        last_name:    { type: string }
        email:        { type: string, format: email }
        phone:        { type: string }
        username:     { type: string }
        birthdate:    { type: string, format: date }
        booking_slug:
          type: string
          pattern: "^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$"
          description: 3-64 caracteres, sólo `[a-z0-9-]`, sin guion al inicio/fin. Único globalmente.
        is_owner:     { type: boolean }
        settings_preferences: { $ref: "#/components/schemas/UserSettingsPreferences" }
      required: [first_name, email]
    UserEnvelope:
      type: object
      properties: { data: { $ref: "#/components/schemas/User" } }
    UserList:
      type: object
      properties:
        data: { type: array, items: { $ref: "#/components/schemas/User" } }
        meta: { $ref: "#/components/schemas/Meta" }

    # ── Company ───────────────────────────────────────────────────────
    CompanyTeam:
      type: object
      description: Read-only summary of the tenant's seat usage.
      properties:
        max_team_size:     { type: integer, example: 5 }
        current_team_size: { type: integer, example: 3 }
        seats_available:   { type: integer, example: 2 }
    Company:
      type: object
      properties:
        id:               { type: string }
        name:             { type: string }
        slug:             { type: string }
        website:          { type: string, nullable: true }
        legal_name:       { type: string, nullable: true }
        logo_url:         { type: string, nullable: true }
        business_type:    { type: string, nullable: true }
        business_context: { type: string, nullable: true }
        business_goals:   { type: array, items: { type: string } }
        team:             { $ref: "#/components/schemas/CompanyTeam" }
        enabled_modules:  { type: array, items: { type: string } }
        created_at:       { type: string, format: date-time }
        updated_at:       { type: string, format: date-time }
    CompanyInput:
      type: object
      properties:
        name:             { type: string }
        website:          { type: string }
        legal_name:       { type: string }
        business_type:    { type: string }
        business_context: { type: string }
        business_goals:   { type: array, items: { type: string } }
    CompanyEnvelope:
      type: object
      properties: { data: { $ref: "#/components/schemas/Company" } }

    Brand:
      type: object
      description: >
        Los campos personalizados (specs) se devuelven aplanados como claves
        de primer nivel adicionales (ej. `fundada: 1937`), por eso
        additionalProperties está habilitado. Las claves reservadas (las
        listadas abajo) nunca se sobrescriben.
      additionalProperties: true
      properties:
        id:         { type: string }
        name:       { type: string }
        slug:       { type: string, nullable: true }
        website:    { type: string, nullable: true }
        is_active:  { type: boolean }
        product_count:
          type: integer
          example: 42
          description: >
            Número de productos ACTIVOS (is_active: true, no eliminados) que
            apuntan a esta marca en tu company — misma visibilidad que el
            default de GET /products (solo activos). Es un valor cacheado (se
            mantiene al día al asignar/quitar la marca de un producto o al
            activarlo/desactivarlo), así que el listado no dispara consultas
            de conteo.
        logo_url:   { type: string, nullable: true }
        logo:
          allOf: [ { $ref: "#/components/schemas/MediaItem" } ]
          nullable: true
        seo: { $ref: "#/components/schemas/Seo" }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    BrandInput:
      type: object
      properties:
        name:      { type: string }
        slug:      { type: string }
        website:   { type: string }
        is_active: { type: boolean }
        logo_url:  { type: string, description: "URL del logo; equivalente a enviar `logo` como media item. Cadena vacía lo borra." }
        custom_properties:
          type: array
          items: { $ref: "#/components/schemas/CustomProperty" }
        seo: { $ref: "#/components/schemas/Seo" }
      required: [name]
    BrandEnvelope:
      type: object
      properties: { data: { $ref: "#/components/schemas/Brand" } }
    BrandList:
      type: object
      properties:
        data: { type: array, items: { $ref: "#/components/schemas/Brand" } }
        meta: { $ref: "#/components/schemas/Meta" }

    Account:
      type: object
      properties:
        id:            { type: string }
        name:          { type: string }
        description:   { type: string, nullable: true }
        account_type:  { type: string, enum: [bank, cash, paypal, stripe, petty_cash] }
        currency:      { type: string, example: mxn }
        balance:       { type: number, format: float, description: "balance_cents / 100." }
        balance_cents: { type: integer }
        created_at:    { type: string, format: date-time }
        updated_at:    { type: string, format: date-time }
    AccountInput:
      type: object
      properties:
        name:          { type: string }
        description:   { type: string }
        account_type:  { type: string, enum: [bank, cash, paypal, stripe, petty_cash] }
        currency:      { type: string }
        balance_cents: { type: integer }
      required: [name]
    AccountEnvelope:
      type: object
      properties: { data: { $ref: "#/components/schemas/Account" } }
    AccountList:
      type: object
      properties:
        data: { type: array, items: { $ref: "#/components/schemas/Account" } }
        meta: { $ref: "#/components/schemas/Meta" }

    IncomeItem:
      type: object
      properties:
        id:               { type: string }
        name:             { type: string, nullable: true }
        quantity:         { type: number, nullable: true }
        unit_price_cents: { type: integer, nullable: true }
        unit_price:       { type: number, format: float, nullable: true }
        product_id:       { type: string, nullable: true }
        variant_id:       { type: string, nullable: true }
    IncomeItemInput:
      type: object
      properties:
        name:             { type: string }
        quantity:         { type: number }
        unit_price_cents: { type: integer }
        product_id:       { type: string }
        variant_id:       { type: string }
        tax_percent:      { type: number, format: float }
        discount_cents:   { type: integer }
    Income:
      type: object
      properties:
        id:                      { type: string }
        occurred_at:             { type: string, format: date-time }
        description:             { type: string, nullable: true }
        amount_cents:            { type: integer }
        amount:                  { type: number, format: float }
        paid_amount_cents:       { type: integer }
        paid_amount:             { type: number, format: float }
        currency:                { type: string }
        paid:                    { type: boolean }
        refunded:                { type: boolean }
        from_account_receivable: { type: boolean }
        account_id:              { type: string, nullable: true }
        account_name:            { type: string, nullable: true }
        customer_id:             { type: string, nullable: true }
        customer_name:           { type: string, nullable: true }
        category_id:             { type: string, nullable: true }
        category_name:           { type: string, nullable: true, description: "Categoría FINANCIERA (no la de catálogo de productos)." }
        project_id:              { type: string, nullable: true }
        project_name:            { type: string, nullable: true }
        employee_id:             { type: string, nullable: true }
        employee_name:           { type: string, nullable: true }
        items:
          type: array
          description: "Solo en el detalle (GET /incomes/{id})."
          items: { $ref: "#/components/schemas/IncomeItem" }
        created_at:              { type: string, format: date-time }
        updated_at:              { type: string, format: date-time }
    IncomeInput:
      type: object
      properties:
        account_id:        { type: string }
        customer_id:       { type: string }
        category_id:       { type: string }
        amount_cents:      { type: integer }
        paid_amount_cents: { type: integer }
        currency:          { type: string }
        occurred_at:       { type: string, format: date-time }
        description:       { type: string }
        paid:              { type: boolean }
        project_id:        { type: string }
        employee_id:       { type: string }
        items:
          type: array
          items: { $ref: "#/components/schemas/IncomeItemInput" }
      required: [account_id, amount_cents]
    IncomeEnvelope:
      type: object
      properties: { data: { $ref: "#/components/schemas/Income" } }
    IncomeList:
      type: object
      properties:
        data: { type: array, items: { $ref: "#/components/schemas/Income" } }
        meta: { $ref: "#/components/schemas/Meta" }

    Expense:
      type: object
      properties:
        id:             { type: string }
        occurred_at:    { type: string, format: date-time }
        description:    { type: string, nullable: true }
        amount_cents:   { type: integer }
        amount:         { type: number, format: float }
        paid_cents:     { type: integer }
        paid_amount:    { type: number, format: float }
        total_cents:    { type: integer }
        total:          { type: number, format: float }
        currency:       { type: string }
        paid:           { type: boolean }
        refunded:       { type: boolean }
        classification: { type: string, nullable: true }
        account_id:     { type: string, nullable: true }
        account_name:   { type: string, nullable: true }
        category_id:    { type: string, nullable: true }
        category_name:  { type: string, nullable: true, description: "Categoría FINANCIERA (no la de catálogo de productos)." }
        vendor_id:      { type: string, nullable: true }
        project_id:     { type: string, nullable: true }
        project_name:   { type: string, nullable: true }
        created_at:     { type: string, format: date-time }
        updated_at:     { type: string, format: date-time }
    ExpenseInput:
      type: object
      properties:
        account_id:     { type: string }
        vendor_id:      { type: string }
        category_id:    { type: string }
        amount_cents:   { type: integer }
        paid_cents:     { type: integer }
        total_cents:    { type: integer }
        currency:       { type: string }
        occurred_at:    { type: string, format: date-time }
        description:    { type: string }
        paid:           { type: boolean }
        classification: { type: string }
        project_id:     { type: string }
      required: [account_id, amount_cents]
    ExpenseEnvelope:
      type: object
      properties: { data: { $ref: "#/components/schemas/Expense" } }
    ExpenseList:
      type: object
      properties:
        data: { type: array, items: { $ref: "#/components/schemas/Expense" } }
        meta: { $ref: "#/components/schemas/Meta" }

    Report:
      type: object
      description: >
        Reporte contable agregado. La forma exacta depende del reporte:
        balance-sheet expone secciones por moneda (assets, liabilities,
        equity); income-statement expone revenue/costs/expenses/net;
        cash-flow expone entradas y salidas. Consulta la guía de Reportes.
      additionalProperties: true
    ReportEnvelope:
      type: object
      properties: { data: { $ref: "#/components/schemas/Report" } }
