openapi: 3.0.3
info:
  title: gstly External API
  version: "1.0.0"
  summary: GST-compliant invoicing and product management for gstly businesses.
  description: |
    Server-to-server endpoints for integrators, authenticated with a per-business API key
    instead of a user login. Create and manage keys from **Settings → API keys** in the
    [gstly](https://gstly.in) app (owner only).

    Every response follows the same envelope: `{ "success": boolean, "data"?: object,
    "message"?: string }`. `success: false` responses always carry a human-readable
    `message`; validation failures additionally carry an `errors` array.

    Human-readable reference: <https://gstly.in/api-docs>
  termsOfService: https://gstly.in/terms-of-service
  contact:
    name: gstly API support
    url: https://gstly.in/support
    email: support@gstly.in
  x-logo:
    url: https://gstly.in/assets/img/gstly.png
    altText: gstly
    backgroundColor: "#FFFFFF"
servers:
  - url: https://gstly-backend.onrender.com/api/v1/external
    description: Production

security:
  - ApiKeyAuth: []

tags:
  - name: Products
    description: Create, list, and get products; attach photos.
  - name: Invoices
    description: Create invoices and drive them through their status lifecycle.

paths:
  /products:
    get:
      tags: [Products]
      summary: List products
      operationId: listProducts
      description: Paginated, filterable list of products for the calling business.
      security: [{ ApiKeyAuth: [products:read] }]
      parameters:
        - name: page
          in: query
          schema: { type: integer, default: 1, minimum: 1 }
        - name: limit
          in: query
          schema: { type: integer, default: 20 }
        - name: search
          in: query
          schema: { type: string }
          description: Matches description, HSN code, category, tags, label keys/values, and barcode.
        - name: status
          in: query
          schema: { type: string, enum: [active, inactive, all], default: active }
        - name: productType
          in: query
          schema: { type: string, enum: [RAW_MATERIAL, FINISHED_GOOD, BOTH, FINISHED_GOOD_ONLY, ALL] }
        - name: sortBy
          in: query
          schema: { type: string, enum: [description, unitPrice, costPrice, createdAt, updatedAt, lastUsedDate], default: createdAt }
        - name: sortOrder
          in: query
          schema: { type: string, enum: [asc, desc], default: desc }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      count: { type: integer }
                      total: { type: integer }
                      page: { type: integer }
                      pages: { type: integer }
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Product" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      tags: [Products]
      summary: Create a product
      operationId: createProduct
      description: |
        Deduplicates by description (case-insensitive) scoped to your business: submitting
        the same name with the same price and labels returns the existing product
        (`200`, `alreadyExists: true`) instead of creating a duplicate. If the price or
        labels differ, you get a `409` with the conflict details — resend with
        `forceCreateSeparate: true` to create a genuinely separate product anyway.
        `barcode` is auto-assigned if omitted.
      security: [{ ApiKeyAuth: [products:write] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ProductCreateRequest" }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/Product" }
        "200":
          description: An identical product already existed and was returned (and blank fields backfilled) instead of creating a new one.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      alreadyExists: { type: boolean, example: true }
                      data: { $ref: "#/components/schemas/Product" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409":
          description: A product with this name already exists with a different price or labels.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: false }
                  duplicateNameConflict: { type: boolean, example: true }
                  existing: { $ref: "#/components/schemas/Product" }
                  incoming: { type: object }
                  differingFields: { type: array, items: { type: string } }
                  message: { type: string }

  /products/{ref}:
    get:
      tags: [Products]
      summary: Get a product by _id or barcode
      operationId: getProduct
      security: [{ ApiKeyAuth: [products:read] }]
      parameters:
        - $ref: "#/components/parameters/ProductRef"
        - $ref: "#/components/parameters/ByFallback"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/Product" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /products/{ref}/images:
    post:
      tags: [Products]
      summary: Add product images
      operationId: addProductImages
      description: |
        Max 5 files per request and 5 images total per product — files beyond the cap are
        reported in `skippedCapacity`, not rejected outright. Identical images (by content
        hash) are reported in `skippedDuplicates`. 5MB per file; JPEG, JPG, PNG, GIF, WEBP only.
      security: [{ ApiKeyAuth: [products:write] }]
      parameters:
        - $ref: "#/components/parameters/ProductRef"
        - $ref: "#/components/parameters/ByFallback"
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                images:
                  type: array
                  items: { type: string, format: binary }
                  maxItems: 5
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          productId: { type: string }
                          barcode: { type: string }
                          images:
                            type: array
                            items:
                              type: object
                              properties:
                                url: { type: string, format: uri }
                                thumbUrl: { type: string, format: uri }
                          added: { type: integer }
                          skippedDuplicates: { type: array, items: { type: string } }
                          skippedCapacity: { type: array, items: { type: string } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /invoices:
    get:
      tags: [Invoices]
      summary: List invoices
      operationId: listInvoices
      security: [{ ApiKeyAuth: [invoices:read] }]
      parameters:
        - name: page
          in: query
          schema: { type: integer, default: 1 }
        - name: limit
          in: query
          schema: { type: integer }
        - name: status
          in: query
          schema: { type: string, enum: [draft, published, paid, cancelled] }
        - name: billType
          in: query
          schema: { type: string, enum: [gst, simple] }
        - name: invoiceType
          in: query
          schema: { type: string, enum: [sales, proforma, credit_note] }
        - name: dateFrom
          in: query
          schema: { type: string, format: date }
        - name: dateTo
          in: query
          schema: { type: string, format: date }
        - name: sortBy
          in: query
          schema: { type: string, enum: [createdAt, "customerDetails.name", "totals.grandTotal"], default: createdAt }
        - name: sortOrder
          in: query
          schema: { type: string, enum: [asc, desc], default: desc }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      count: { type: integer }
                      total: { type: integer }
                      pagination:
                        type: object
                        properties:
                          page: { type: integer }
                          limit: { type: integer }
                          pages: { type: integer }
                          hasNext: { type: boolean }
                          hasPrev: { type: boolean }
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Invoice" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      tags: [Invoices]
      summary: Create an invoice
      operationId: createInvoice
      description: |
        Creates a `draft` invoice — nothing is legally final until you [publish](#/Invoices/publishInvoice)
        it. No pre-existing Customer record is required; one is resolved or created
        automatically from `customerDetails.name` at publish time.

        **Idempotency**: pass an `Idempotency-Key` header on this call. A retried request
        with the same key (within the same business) returns the original invoice
        (`200`, `idempotentReplay: true`) instead of creating a duplicate — strongly
        recommended for any automated caller, since a fresh invoice number is minted on
        every call that doesn't supply one.

        Subject to a stricter rate limit than the rest of this API: 10 requests/minute
        per key, shared across all invoice-mutating endpoints.
      security: [{ ApiKeyAuth: [invoices:write] }]
      parameters:
        - name: Idempotency-Key
          in: header
          required: false
          schema: { type: string }
          description: Recommended for safe retries. See description above.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/InvoiceCreateRequest" }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/Invoice" }
        "200":
          description: Idempotent replay — same Idempotency-Key as a prior successful call.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      idempotentReplay: { type: boolean, example: true }
                      data: { $ref: "#/components/schemas/Invoice" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409":
          description: The supplied invoiceNumber already exists for this business.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "429": { $ref: "#/components/responses/TooManyRequests" }

  /invoices/{id}:
    get:
      tags: [Invoices]
      summary: Get an invoice
      operationId: getInvoice
      security: [{ ApiKeyAuth: [invoices:read] }]
      parameters:
        - $ref: "#/components/parameters/InvoiceId"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/Invoice" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    put:
      tags: [Invoices]
      summary: Update an invoice
      operationId: updateInvoice
      description: |
        Editing a **published** invoice's items or other significant fields (business/
        customer details, dates, bill type) automatically reverts it to `draft` and
        reverses any stock it had deducted — it must be re-published. Paid and cancelled
        invoices cannot be updated.
      security: [{ ApiKeyAuth: [invoices:write] }]
      parameters:
        - $ref: "#/components/parameters/InvoiceId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/InvoiceCreateRequest" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/Invoice" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }

  /invoices/{id}/machine-readable:
    get:
      tags: [Invoices]
      summary: Get the machine-readable projection of an invoice
      operationId: getInvoiceMachineReadable
      description: |
        The same canonical JSON projection the PDF and QR code are built from — the
        recommended shape to parse programmatically, since it's guaranteed to agree with
        what a human sees on the document (statutory fields, GST summary, amount-in-words,
        QR payload).
      security: [{ ApiKeyAuth: [invoices:read] }]
      parameters:
        - $ref: "#/components/parameters/InvoiceId"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data: { type: object, description: "Canonical machine-readable invoice projection." }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /invoices/{id}/publish:
    put:
      tags: [Invoices]
      summary: Publish a draft invoice
      operationId: publishInvoice
      description: |
        `draft` → `published` — the "make it a legal invoice" step. Requires the
        business name/address/GSTIN to be resolvable (from the invoice itself or the
        key-owning account's profile) and at least one item with a description, quantity,
        and unit price; `400` with a field-by-field list if anything's missing. Deducts
        stock for line items with a `productId` (unless the invoice's `source` is `pos` or
        `credit_history`, which deduct elsewhere in their own flow).
      security: [{ ApiKeyAuth: [invoices:write] }]
      parameters:
        - $ref: "#/components/parameters/InvoiceId"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/Invoice" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: Forbidden — either the key lacks invoices:write, or the key-owning account's email is unverified.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }

  /invoices/{id}/move-to-draft:
    put:
      tags: [Invoices]
      summary: Move a published invoice back to draft
      operationId: moveInvoiceToDraft
      description: Reverses any stock deducted at publish. Only valid from `published`.
      security: [{ ApiKeyAuth: [invoices:write] }]
      parameters:
        - $ref: "#/components/parameters/InvoiceId"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/Invoice" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }

  /invoices/{id}/mark-paid:
    put:
      tags: [Invoices]
      summary: Mark a published invoice as paid
      operationId: markInvoicePaid
      description: Only valid from `published`. Paid invoices are immutable afterward.
      security: [{ ApiKeyAuth: [invoices:write] }]
      parameters:
        - $ref: "#/components/parameters/InvoiceId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [paymentMethod]
              properties:
                paymentMethod:
                  type: string
                  enum: [cash, bank_transfer, upi, cheque, card, razorpay]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/Invoice" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: Forbidden — either the key lacks invoices:write, or the key-owning account's email is unverified.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }

  /invoices/{id}/cancel:
    put:
      tags: [Invoices]
      summary: Cancel a published invoice
      operationId: cancelInvoice
      description: Only valid from `published` — paid invoices cannot be cancelled. Reverses any stock deducted at publish.
      security: [{ ApiKeyAuth: [invoices:write] }]
      parameters:
        - $ref: "#/components/parameters/InvoiceId"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/Invoice" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }

components:
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: X-API-Key
      description: |
        Create a key from Settings → API keys in the GSTLY app. The plaintext key
        (`gstly_sk_...`) is shown once, at creation time. Each key carries an explicit set
        of scopes (`products:read`, `products:write`, `invoices:read`, `invoices:write`)
        granted at creation time.

  parameters:
    ProductRef:
      name: ref
      in: path
      required: true
      schema: { type: string }
      description: A product's Mongo `_id` or its barcode.
    ByFallback:
      name: by
      in: query
      schema: { type: string, enum: ["false"] }
      description: Pass `by=false` to disable the barcode fallback and require an exact `_id` match.
    InvoiceId:
      name: id
      in: path
      required: true
      schema: { type: string }

  responses:
    BadRequest:
      description: Validation failure.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
    Unauthorized:
      description: Missing, invalid, or revoked API key.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
    Forbidden:
      description: The key doesn't carry the required scope.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
    NotFound:
      description: No matching resource for this business.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
    TooManyRequests:
      description: Rate limit exceeded.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }

  schemas:
    SuccessEnvelope:
      type: object
      properties:
        success: { type: boolean, example: true }

    ErrorResponse:
      type: object
      properties:
        success: { type: boolean, example: false }
        message: { type: string }
        errors:
          type: array
          items: { type: object }

    ProductImage:
      type: object
      properties:
        _id: { type: string }
        filename: { type: string, nullable: true }
        mimeType: { type: string, nullable: true }
        url: { type: string, format: uri, nullable: true }
        thumbUrl: { type: string, format: uri, nullable: true }

    Product:
      type: object
      properties:
        _id: { type: string }
        description: { type: string }
        unitPrice: { type: number }
        costPrice: { type: number, nullable: true }
        hsnCode: { type: string, nullable: true }
        unit: { type: string, enum: [PCS, KG, GRAM, LITRE, ML, BOX, DOZEN, PACK, METER] }
        gstRate: { type: number, enum: [0, 5, 12, 18, 28] }
        isInclusive: { type: boolean }
        productType: { type: string, enum: [RAW_MATERIAL, FINISHED_GOOD, BOTH] }
        category: { type: string, nullable: true }
        tags: { type: array, items: { type: string } }
        barcode: { type: string }
        barcodeSource: { type: string, enum: [AUTO, MANUAL, IMPORT, MIGRATION] }
        labelFields:
          type: array
          items:
            type: object
            properties:
              key: { type: string }
              value: { type: string }
        images:
          type: array
          items: { $ref: "#/components/schemas/ProductImage" }
        hasImage: { type: boolean }
        imageUrl: { type: string, format: uri, nullable: true }
        isActive: { type: boolean }
        visibleOnStorefront: { type: boolean }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
      additionalProperties: true

    ProductCreateRequest:
      type: object
      required: [description, unitPrice]
      properties:
        description: { type: string }
        unitPrice: { type: number, minimum: 0 }
        hsnCode: { type: string, description: "4, 6, or 8 digits." }
        costPrice: { type: number, minimum: 0 }
        gstRate: { type: number, enum: [0, 5, 12, 18, 28] }
        isInclusive: { type: boolean, default: false }
        unit: { type: string, enum: [PCS, KG, GRAM, LITRE, ML, BOX, DOZEN, PACK, METER], default: PCS }
        category: { type: string }
        tags: { type: array, items: { type: string } }
        notes: { type: string }
        barcode: { type: string, description: "Manual barcode. Auto-assigned if omitted." }
        parentProductId: { type: string, description: "Links this as a variation of another product." }
        pricingMode: { type: string, enum: [MANUAL, MARGIN_PERCENT, MARGIN_FIXED] }
        marginValue: { type: number }
        labelFields:
          type: array
          maxItems: 5
          items:
            type: object
            properties:
              key: { type: string, maxLength: 20 }
              value: { type: string, maxLength: 20 }
        visibleOnStorefront: { type: boolean, default: false }
        forceCreateSeparate:
          type: boolean
          description: Set true to create a genuinely separate product after a 409 duplicateNameConflict.

    InvoiceItem:
      type: object
      required: [description]
      properties:
        description: { type: string }
        quantity: { type: number, minimum: 0, default: 1 }
        unitPrice: { type: number, minimum: 0 }
        gstRate: { type: number, minimum: 0, maximum: 100 }
        isInclusive: { type: boolean, default: false }
        hsnCode: { type: string, description: "4, 6, or 8 digits." }
        productId: { type: string, description: "Optional link to a Product — folds its label details into the description." }
        baseAmount: { type: number, readOnly: true, description: "Taxable value for this line — server-computed, present on responses only." }
        gstAmount: { type: number, readOnly: true }
        cgst: { type: number, readOnly: true, description: "0 for inter-state supply (igst is used instead)." }
        sgst: { type: number, readOnly: true }
        igst: { type: number, readOnly: true, description: "0 for intra-state supply (cgst+sgst are used instead)." }
        totalAmount: { type: number, readOnly: true, description: "baseAmount + gstAmount for this line." }

    InvoiceCreateRequest:
      type: object
      properties:
        invoiceNumber: { type: string, description: "Auto-generated if omitted." }
        invoiceDate: { type: string, format: date-time }
        billType: { type: string, enum: [gst, simple], default: gst }
        country: { type: string, description: "2-letter code, default IN." }
        invoiceType: { type: string, enum: [sales, proforma], default: sales }
        businessDetails:
          type: object
          properties:
            name: { type: string }
            address: { type: string }
            gstin: { type: string }
            pincode: { type: string }
        customerDetails:
          type: object
          properties:
            name: { type: string }
            gstin: { type: string }
            email: { type: string }
            phone: { type: string }
            address: { type: string }
        items:
          type: array
          items: { $ref: "#/components/schemas/InvoiceItem" }
        discountType: { type: string, enum: [none, percent, amount], default: none }
        discountValue: { type: number, minimum: 0 }
        dueDate: { type: string, format: date-time, description: "Defaults to 30 days after invoiceDate." }

    Invoice:
      type: object
      properties:
        _id: { type: string }
        invoiceNumber: { type: string }
        invoiceDate: { type: string, format: date-time }
        dueDate: { type: string, format: date-time }
        status: { type: string, enum: [draft, published, paid, cancelled] }
        invoiceType: { type: string, enum: [sales, proforma, credit_note] }
        billType: { type: string, enum: [gst, simple] }
        country: { type: string }
        currency: { type: string }
        businessDetails: { type: object }
        customerDetails: { type: object }
        items:
          type: array
          items: { $ref: "#/components/schemas/InvoiceItem" }
        totals:
          type: object
          properties:
            taxableValue: { type: number }
            totalGST: { type: number }
            totalCGST: { type: number }
            totalSGST: { type: number }
            totalIGST: { type: number }
            grandTotal: { type: number }
            roundedTotal: { type: number }
            discountAmount: { type: number }
        paymentDetails:
          type: object
          properties:
            status: { type: string, enum: [pending, paid, failed] }
            method: { type: string }
        businessId: { type: string }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
        publishedAt: { type: string, format: date-time, nullable: true }
      additionalProperties: true
