openapi: 3.0.0
info:
  title: Fatoora Partner API
  version: "1.0"
  description: >
    REST API for Fatoora Partner API integrators (partner-starter / partner-business / partner-max plans).
    Authenticate with OAuth2 client_credentials, discover authorized client organizations, then submit,
    sign and track TEIF invoices on their behalf. Not authored from a single generated source file —
    fatooraBusiness's live spec at GET /open-api currently documents only the general platform API and
    does not yet include these partner routes; this file was written to close that gap and is a candidate
    to merge into fatooraBusiness/src/openapi.ts. Endpoint paths, field names and response shapes below
    were verified against a live dev instance (fatooraLive :3600 / fatooraBusiness :4100) on 2026-07-11 —
    see inline notes for corrections found during that pass.
  contact:
    name: Fatoora Support
    email: support@fatoora.tn
servers:
  - url: https://business.fatoora.tn
    description: Production
  - url: https://staging-business.fatoora.tn
    description: Staging (if provisioned for your account)

security:
  - partnerOAuth: []

paths:
  /oauth/token:
    post:
      summary: Exchange client credentials for an access token
      description: >
        Omit org_id for an app-only token (org discovery only). Include org_id for an org-scoped token
        (invoice/signing endpoints), valid only if the target organization currently authorizes this app.
      security: []
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [grant_type, client_id, client_secret]
              properties:
                grant_type:
                  type: string
                  enum: [client_credentials]
                client_id:
                  type: string
                client_secret:
                  type: string
                org_id:
                  type: string
                  format: uuid
                  description: Optional — omit for an app-only token
      responses:
        "200":
          description: Token issued
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TokenResponse"
        "400":
          description: unsupported_grant_type / invalid_request
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OAuthError"
        "401":
          description: invalid_client
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OAuthError"
        "403":
          description: access_denied — org not authorized for this app
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OAuthError"
        "429":
          description: Rate limit exceeded (30 req / 15 min per IP)

  /api/partner/authorized-orgs:
    get:
      summary: List organizations that authorize this partner app
      description: Requires an app-only bearer token (no org_id).
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  authorizedOrgs:
                    type: array
                    items:
                      $ref: "#/components/schemas/AuthorizedOrg"
                  total:
                    type: integer
        "401":
          $ref: "#/components/responses/Unauthorized"

  /api/partner/resolve-org:
    get:
      summary: Resolve a Tunisian tax ID to an org_id
      description: Requires an app-only bearer token (no org_id).
      parameters:
        - name: taxId
          in: query
          required: true
          schema:
            type: string
          description: 8-char (NNNNNNNL) or 13-char (NNNNNNNLCCCNNN) matricule fiscal
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AuthorizedOrg"
        "400":
          description: INVALID_TAX_ID
        "404":
          description: ORG_NOT_FOUND

  /api/core-proxy/invoices/submit:
    post:
      summary: Submit a TEIF invoice (JSON)
      description: Requires scope invoice:write and an org-scoped bearer token.
      security: [{ partnerOAuth: [invoice:write] }]
      parameters:
        - name: X-Allow-Partner-Upsert
          in: header
          required: false
          schema:
            type: string
            enum: ["true", "false"]
          description: Auto-create an unknown buyer/seller partner record from the TEIF data (requires a full address in the TEIF block)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/TeifInvoiceJson"
      responses:
        "201":
          description: >
            Invoice created. Status is "VALIDATED" immediately (synchronous validation runs as
            part of submit) — verified live; NOT "DRAFT" as an earlier draft of this spec claimed.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/InvoiceSummary"
        "400":
          description: TEIF validation error
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"

  /api/core-proxy/invoices/submit-xml:
    post:
      summary: Submit a TEIF invoice (raw XML)
      security: [{ partnerOAuth: [invoice:write] }]
      requestBody:
        required: true
        content:
          application/xml:
            schema:
              type: string
      responses:
        "201":
          description: Invoice created. Same status behavior as JSON submit — see above.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/InvoiceSummary"

  /api/core-proxy/teif/validate:
    post:
      summary: Validate a TEIF XML document against the XSD (no invoice created)
      description: >
        Corrected path — verified live. This is mounted at /api/core-proxy/teif/validate, NOT
        /api/core-proxy/invoices/teif/validate-xml-xsd (that 404s; it's Core's internal path,
        /api/v1/invoices/teif/validate-xml-xsd, mistakenly exposed at the same path in an earlier
        version of the partner-facing Postman collection — since fixed).
      security: [{ partnerOAuth: [invoice:read] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [xml]
              properties:
                xml: { type: string, description: "Field name is 'xml', not 'xmlContent' — verified live." }
      responses:
        "200":
          description: Validation result
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TeifValidationResult"

  /api/core-proxy/invoices:
    get:
      summary: List invoices for the org-scoped token
      security: [{ partnerOAuth: [invoice:read] }]
      parameters:
        - name: direction
          in: query
          schema: { type: string, enum: [SENT, RECEIVED] }
        - name: status
          in: query
          schema: { type: string }
        - name: startDate
          in: query
          schema: { type: string, format: date }
        - name: endDate
          in: query
          schema: { type: string, format: date }
        - name: senderMatricule
          in: query
          schema: { type: string }
        - name: receiverMatricule
          in: query
          schema: { type: string }
        - name: page
          in: query
          schema: { type: integer, default: 0 }
        - name: pageSize
          in: query
          schema: { type: integer, default: 20, minimum: 1, maximum: 100 }
      responses:
        "200":
          description: Paginated invoice list
          content:
            application/json:
              schema:
                type: object
                properties:
                  content:
                    type: array
                    items: { $ref: "#/components/schemas/InvoiceSummary" }
                  page: { type: integer }
                  pageSize: { type: integer }
                  totalElements: { type: integer }
                  totalPages: { type: integer }

  /api/core-proxy/invoices/{id}:
    get:
      summary: Get full invoice detail
      description: >
        Response is WRAPPED — verified live — not a flat InvoiceSummary like list items.
        Read invoice fields from the `invoice` sub-object.
      security: [{ partnerOAuth: [invoice:read] }]
      parameters:
        - $ref: "#/components/parameters/InvoiceId"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/InvoiceDetailResponse"
        "404":
          description: Not found

  # Note: /api/core-proxy/invoices/{id}/status has been removed — it never had a real
  # implementation (no matching backend mapping), so it always returned 500 INTERNAL_ERROR.
  # Use GET /api/core-proxy/invoices/{id} and read `invoice.status` instead; the path now
  # returns a clean 404.

  /api/core-proxy/invoices/{id}/sign-and-send:
    post:
      summary: Sign an invoice (using the client's own configured method) and submit it to TTN
      security: [{ partnerOAuth: [invoice:write, seal:sign, digigo:sign] }]
      parameters:
        - $ref: "#/components/parameters/InvoiceId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [signatureType]
              properties:
                signatureType:
                  type: string
                  enum: [SEAL, DIGIGO]
      responses:
        "200":
          description: >
            Async job created. jobType is PROVIDER-SPECIFIC — verified live as
            SIGN_AND_SUBMIT_SEAL / SIGN_AND_SUBMIT_DIGIGO, not a generic "SIGN_AND_SEND".
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/JobCreated"
        "402":
          description: NO_SIGNATURE_CREDITS — client org's signing quota/packs exhausted
        "409":
          description: Invoice not in an allowed source status for sign-and-send

  /api/core-proxy/invoices/batch/sign-and-send:
    post:
      summary: Sign and send multiple invoices at once
      security: [{ partnerOAuth: [invoice:write, seal:sign, digigo:sign] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                invoiceIds:
                  type: array
                  items: { type: string }
                signatureType:
                  type: string
                  enum: [SEAL, DIGIGO]
      responses:
        "200":
          description: Batch job results

  /api/core-proxy/jobs/{jobId}:
    get:
      summary: Poll an async job's status (e.g. sign-and-send)
      description: >
        Response is richer than the initial sign-and-send POST response — verified live, includes
        progress/currentStep/outputData with per-step failure detail.
      security: [{ partnerOAuth: [invoice:read] }]
      parameters:
        - name: jobId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/JobStatus"

  /api/core-proxy/invoices/{id}/pdf:
    get:
      summary: Download the invoice PDF
      description: >
        Binary response, works regardless of the invoice's status (drafts included).
      security: [{ partnerOAuth: [invoice:read] }]
      parameters:
        - $ref: "#/components/parameters/InvoiceId"
      responses:
        "200":
          description: application/pdf binary
          content:
            application/pdf:
              schema: { type: string, format: binary }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /api/core-proxy/invoices/{id}/generated-xml:
    get:
      summary: Get the generated TEIF XML
      description: >
        ⚠️ Past the SIGNED status, this regenerates a fresh UNSIGNED XML on the fly (drops the signature
        and the TTN QR code). For the actual final, signed/TTN-timestamped document, read the teifXml
        field from GET /api/core-proxy/invoices/{id} instead — see §7 in the guide body.
      security: [{ partnerOAuth: [invoice:read] }]
      parameters:
        - $ref: "#/components/parameters/InvoiceId"
      responses:
        "200":
          description: application/xml body
          content:
            application/xml:
              schema: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /api/core-proxy/organization:
    get:
      summary: Get the client organization's general identity
      description: >
        Returns only the public identity of the CLIENT organization bound to the
        organization-scoped partner token. Plan, subscription, credits, analytics,
        API-key statistics, account flags, and other internal data are never exposed.
        taxIdentifier must match MessageSenderIdentifier in submitted TEIF invoices.
      security: [{ partnerOAuth: [invoice:read] }]
      responses:
        "200":
          description: General identity of the authorized client organization
          content:
            application/json:
              schema:
                type: object
                required: [orgId, orgName, taxIdentifier]
                additionalProperties: false
                properties:
                  orgId: { type: string, format: uuid }
                  orgName: { type: string }
                  taxIdentifier: { type: string, nullable: true }
        "400":
          description: Organization context is missing from the token
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: Missing invoice:read scope or organization not authorized
        "404":
          description: Organization or service application not found

  /api/core-proxy/webhooks:
    post:
      summary: Create a webhook subscription
      description: >
        Requires your partner app to be authorized for the target organization (same authorization as
        invoices, resolved from the org-scoped access_token). The secret is returned only once, at
        creation time.
      security: [{ partnerOAuth: [invoice:write] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url: { type: string, format: uri }
                events:
                  type: array
                  items: { type: string }
                  description: Defaults to the 7 standard event types when omitted (see §9 in the guide body).
                customData: { type: object }
                ttlSeconds: { type: integer, description: "Defaults to 30 days" }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookSubscription"
        "401": { $ref: "#/components/responses/Unauthorized" }
    get:
      summary: List webhook subscriptions created by your partner app for the target organization
      security: [{ partnerOAuth: [invoice:read] }]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/WebhookSubscription"

  /api/core-proxy/webhooks/{webhookId}:
    delete:
      summary: Delete a webhook subscription
      security: [{ partnerOAuth: [invoice:write] }]
      parameters:
        - name: webhookId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: OK

  /api/core-proxy/webhooks/{webhookId}/test:
    post:
      summary: Send a test event (webhook.test) to one subscription
      security: [{ partnerOAuth: [invoice:write] }]
      parameters:
        - name: webhookId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: OK

components:
  parameters:
    InvoiceId:
      name: id
      in: path
      required: true
      schema:
        type: string

  responses:
    Unauthorized:
      description: Missing/invalid token, or insufficient scope
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
    Forbidden:
      description: Not authorized for this organization or endpoint
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"

  securitySchemes:
    partnerOAuth:
      type: oauth2
      flows:
        clientCredentials:
          tokenUrl: /oauth/token
          scopes:
            invoice:write: Create, submit, sign, and delete invoices
            invoice:read: List and read invoices, jobs, statuses
            ttn:submit: Manually (re)submit to TTN (org-owned keys only)
            seal:sign: Trigger SEAL signing
            digigo:sign: Trigger DigiGO signing

  schemas:
    TokenResponse:
      type: object
      properties:
        access_token: { type: string }
        token_type: { type: string, example: Bearer }
        expires_in: { type: integer, example: 3600 }
        scope: { type: string }

    OAuthError:
      type: object
      properties:
        error: { type: string }
        error_description: { type: string }

    ErrorResponse:
      type: object
      properties:
        error: { type: string }
        message: { type: string }
        code: { type: string }

    AuthorizedOrg:
      type: object
      properties:
        orgId: { type: string, format: uuid }
        orgName: { type: string }
        taxIdentifier: { type: string }
        allowedScopes:
          type: array
          items: { type: string }
        authorizedAt:
          type: string
          description: >
            Raw timestamp, NOT strict ISO-8601 — verified live as "2026-05-11 20:59:43.542231"
            (space-separated, microsecond precision, no T/Z). Do not validate against a strict
            ISO-8601 regex.
          example: "2026-05-11 20:59:43.542231"

    TeifInvoiceJson:
      type: object
      required: [version, controlingAgency, header, body]
      properties:
        version: { type: string, example: "1.8.8" }
        controlingAgency: { type: string, example: TTN }
        header: { type: object }
        body: { type: object }

    InvoiceSummary:
      type: object
      description: >
        Returned by list items AND by submit/submit-xml (full object, not a minimal envelope —
        verified live). Status starts at VALIDATED after a successful submit, not DRAFT.
      properties:
        id: { type: string, format: uuid }
        orgId: { type: string, format: uuid }
        invoiceNumber: { type: string }
        invoiceTypeCode: { type: string }
        invoiceDate: { type: string, format: date }
        status:
          type: string
          enum: [DRAFT, SUBMITTED, VALIDATED, VALIDATION_FAILED, SIGNED, SIGNATURE_FAILED, SENT_TTN, ACCEPTED_TTN, REJECTED_TTN, ERROR_TTN, TIMEOUT_TTN, CANCELLED, ARCHIVED]
        senderName: { type: string }
        receiverName: { type: string }
        receiverIdentifierType: { type: string }
        receiverIdentifierValue: { type: string }
        totalAmountExclTax: { type: number }
        totalTaxAmount: { type: number, nullable: true, description: "May be null immediately after a JSON submit — verified live" }
        totalAmountInclTax: { type: number, nullable: true }
        currencyCode: { type: string, example: TND }
        ttnGeneratedRef: { type: string, nullable: true }
        ttnIdSaveEfact: { type: integer, nullable: true }
        createdAt: { type: string, format: date-time }
        submittedAt: { type: string, format: date-time, nullable: true }
        signedAt: { type: string, format: date-time, nullable: true }
        createdBy: { type: string, description: "The clientId (ak_partner_...) that created it, for partner-submitted invoices" }
        isQuotation: { type: boolean }
        quotationStatus: { type: string, nullable: true }
        duplicateCopy: { type: boolean }
        importSource: { type: string, nullable: true }
        importBlockedResign: { type: boolean }
        direction: { type: string, enum: [SENT, RECEIVED] }
        partnerAppId: { type: string, nullable: true }

    InvoiceDetailResponse:
      type: object
      description: The wrapped shape returned by GET /api/core-proxy/invoices/{id} — verified live.
      properties:
        invoice: { $ref: "#/components/schemas/InvoiceSummary" }
        teifJson: { type: string, description: "Generated TEIF document as a JSON string" }
        teifXml: { type: string, description: "Generated TEIF document as raw XML" }
        ttnQrCodeBase64: { type: string, nullable: true }
        localSignedXml: { type: string, nullable: true }
        validationErrors: { type: array, items: { type: object }, nullable: true }
        ttnAcknowledgments: { type: array, items: { type: object }, nullable: true }

    TeifValidationResult:
      type: object
      properties:
        valid: { type: boolean }
        issues:
          type: array
          items:
            type: object
            properties:
              type: { type: string, example: XML_SCHEMA_VIOLATION }
              message: { type: string }
              severity: { type: string, example: ERROR }
              xpath: { type: string, example: "line:1" }

    WebhookSubscription:
      type: object
      description: >
        Response shape for webhook endpoints. `secret` is only present in the POST /webhooks
        creation response (returned once, never again on subsequent GETs).
      properties:
        id: { type: string }
        appId: { type: string, description: "Your partner app's client_id" }
        url: { type: string, format: uri }
        events:
          type: array
          items: { type: string }
        customData: { type: object }
        status: { type: string, example: ACTIVE }
        secret: { type: string, description: "Only present once, on the creation response" }
        expiresAt: { type: string }
        createdAt: { type: string }
        updatedAt: { type: string }

    JobCreated:
      type: object
      description: Initial response from POST sign-and-send.
      properties:
        jobId: { type: string, format: uuid }
        invoiceId: { type: string, format: uuid }
        jobType:
          type: string
          enum: [SIGN_AND_SUBMIT_SEAL, SIGN_AND_SUBMIT_DIGIGO]
          description: Provider-specific — verified live. Not a generic "SIGN_AND_SEND".
        status: { type: string, example: PENDING }
        success: { type: boolean }
        message: { type: string, example: "Invoice queued for signature and TTN submission" }

    JobStatus:
      type: object
      description: Richer shape returned by GET /jobs/{jobId} — verified live.
      properties:
        jobId: { type: string, format: uuid }
        invoiceId: { type: string, format: uuid }
        jobType: { type: string, enum: [SIGN_AND_SUBMIT_SEAL, SIGN_AND_SUBMIT_DIGIGO] }
        status: { type: string, enum: [PENDING, COMPLETED, FAILED] }
        progress: { type: integer, description: "0-100" }
        currentStep: { type: string, example: CERT_VERIFICATION }
        errorMessage: { type: string, nullable: true }
        errorData: { type: object, nullable: true }
        outputData:
          type: object
          nullable: true
          description: "Per-step results/failures, e.g. { step0CertVerification: { valid, success, errorCode, error } }"
        signatureResult: { type: object, nullable: true }
        ttnSubmissionResult: { type: object, nullable: true }
        ttnFinalResult: { type: object, nullable: true }
        createdAt: { type: string, format: date-time }
        startedAt: { type: string, format: date-time, nullable: true }
        completedAt: { type: string, format: date-time, nullable: true }
        retryCount: { type: integer }
