openapi: 3.1.0
info:
  title: CFO OAuth Client Credentials Integration API
  version: 1.0.0-dev-pilot
  license:
    name: OneClimate proprietary
    identifier: LicenseRef-OneClimate-Proprietary
  description: |
    CFO is the OAuth issuer. This DEV pilot uses OAuth 2.0 client credentials
    with an opaque access token that expires in 15 minutes and has no refresh token.

    Store client_secret in the ERP secret store, never place credentials in a query string,
    and do not share screenshots of Authorization headers. Obtain and use access tokens only
    from the ERP or another approved server-side integration runtime. Do not enter client
    secrets or access tokens into browser-based API documentation; the customer documentation
    is read-only.

    Persistence is decided by the grant issued to the client, not by the authentication method.
    A client whose grant is dry-run only is rejected when it sends dry_run=false; a client whose
    grant allows persistence may send either value.
servers:
  - url: https://{host}/api/v2
    variables:
      host: { default: dev-carbonfootprint.one.th }
tags:
  - name: Token
    description: Exchange a customer ERP client credential for a short-lived access token.
  - name: Discovery
    description: Discover the authority, destinations, facilities, periods and capabilities granted to the client.
  - name: Activity imports
    description: Validate or persist granted activity rows and inspect asynchronous outcomes.
paths:
  /integrations/oauth/token:
    post:
      operationId: exchangeIntegrationToken
      tags: [Token]
      summary: Exchange OAuth client credentials for an opaque access token
      description: Submit application/x-www-form-urlencoded grant_type=client_credentials with HTTP Basic client authentication from the ERP or another approved server-side integration runtime. Never enter the client secret in browser documentation.
      security: []
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [grant_type]
              properties:
                grant_type: { type: string, const: client_credentials }
      responses:
        '200':
          description: Opaque short-lived bearer token.
          headers:
            Cache-Control: { schema: { type: string, const: no-store } }
          content:
            application/json:
              schema:
                type: object
                required: [access_token, token_type, expires_in, scope]
                properties:
                  access_token: { type: string, writeOnly: true }
                  token_type: { type: string, const: Bearer }
                  expires_in: { type: integer, const: 900 }
                  scope: { type: string, example: cfo.integration.read cfo.activity_import.dry_run }
        '400': { $ref: '#/components/responses/OAuthError' }
        '401': { $ref: '#/components/responses/OAuthError' }
        '429': { $ref: '#/components/responses/OAuthError' }
        '503': { $ref: '#/components/responses/OAuthError' }
  /integrations/me:
    get:
      operationId: getIntegrationAuthority
      tags: [Discovery]
      summary: Read the OAuth-client scoped context
      security: [{ CFOAccessToken: [] }]
      responses: { '200': { $ref: '#/components/responses/JSON' }, '401': { $ref: '#/components/responses/OAuthError' } }
  /integrations/facilities:
    get:
      operationId: listIntegrationFacilities
      tags: [Discovery]
      summary: List facilities available to this OAuth client now
      security: [{ CFOAccessToken: [] }]
      responses: { '200': { $ref: '#/components/responses/JSON' }, '401': { $ref: '#/components/responses/OAuthError' } }
  /integrations/retentions:
    get:
      operationId: listIntegrationRetentions
      tags: [Discovery]
      summary: List active reporting periods available to this OAuth client now
      security: [{ CFOAccessToken: [] }]
      responses: { '200': { $ref: '#/components/responses/JSON' }, '401': { $ref: '#/components/responses/OAuthError' } }
  /integrations/destinations:
    get:
      operationId: listIntegrationDestinations
      tags: [Discovery]
      summary: List authoritative facility-retention destinations available now
      description: Select only a returned facility-retention pair; never construct a cartesian product.
      security: [{ CFOAccessToken: [] }]
      responses: { '200': { $ref: '#/components/responses/JSON' }, '401': { $ref: '#/components/responses/OAuthError' } }
  /integrations/facilities/{facility_id}/retentions:
    get:
      operationId: listFacilityRetentions
      tags: [Discovery]
      summary: List active reporting periods for one available facility
      security: [{ CFOAccessToken: [] }]
      parameters:
        - { name: facility_id, in: path, required: true, schema: { type: string, format: uuid } }
      responses: { '200': { $ref: '#/components/responses/JSON' }, '401': { $ref: '#/components/responses/OAuthError' } }
  /integrations/catalog/activity-codes:
    get:
      operationId: listIntegrationActivityCodes
      tags: [Discovery]
      summary: List currently enabled capability codes for one destination and scope
      security: [{ CFOAccessToken: [] }]
      parameters:
        - { name: facility_id, in: query, required: true, schema: { type: string, format: uuid } }
        - { name: retention_id, in: query, required: true, schema: { type: string, format: uuid } }
        - { name: scope, in: query, required: true, schema: { type: integer, enum: [1, 2, 3] } }
      responses: { '200': { $ref: '#/components/responses/JSON' }, '401': { $ref: '#/components/responses/OAuthError' } }
  /integrations/facilities/{facility_id}/activity-imports:
    post:
      operationId: createActivityImport
      tags: [Activity imports]
      summary: Queue one idempotent activity import (dry-run or persisted, per grant)
      security: [{ CFOAccessToken: [] }]
      parameters:
        - { name: facility_id, in: path, required: true, schema: { type: string, format: uuid } }
        - { name: Idempotency-Key, in: header, required: true, schema: { type: string, maxLength: 128 } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [schema_version, external_batch_id, source_system, retention_id, scope, dry_run, rows]
              properties:
                schema_version: { type: string, const: cfo-activity-import-v1 }
                external_batch_id: { type: string }
                source_system: { type: string }
                retention_id: { type: string, format: uuid }
                scope: { type: integer, enum: [1, 2, 3] }
                dry_run: { type: boolean, description: true validates only; false persists and requires a grant with write_mode=persist }
                rows:
                  type: array
                  minItems: 1
                  maxItems: 1000
                  items:
                    type: object
                    required: [external_ref, document_date, activity_code, catalog_item_code, unit]
                    properties:
                      external_ref:
                        type: string
                        maxLength: 128
                        description: Your own document identifier. Sending it twice means the same document, and the second arrival is skipped rather than written again.
                      document_date:
                        type: string
                        format: date
                        description: Exactly YYYY-MM-DD, zero-padded, not in the future (Asia/Bangkok).
                      activity_code: { type: string }
                      catalog_item_code: { type: string }
                      quantity:
                        type: number
                        exclusiveMinimum: 0
                        exclusiveMaximum: 1e18
                        description: >-
                          The amount, in the unit below. Optional only for tonne-kilometre rows that
                          supply weight_kg and distance_km instead; everywhere else it is required,
                          and an explicit zero or negative is rejected with 400 INVALID_REQUEST.
                          Stored to six decimal places, so a value is rejected if it rounds away to
                          nothing there — 0.0000015 is accepted and stored as 0.000002, 0.0000004 is
                          not. Values at or above 1e18 are rejected; the column holds eighteen digits
                          ahead of the point.
                      unit:
                        type: string
                        description: Must equal the accepted unit for this catalog item, from /integrations/catalog/activity-codes.
                      weight_kg:
                        type: number
                        exclusiveMinimum: 0
                        exclusiveMaximum: 1e18
                        description: >-
                          Optional, tonne-kilometre rows only, and all-or-nothing with distance_km.
                          Same bounds as quantity: rejected if it rounds away at six decimal places,
                          or if it is at or above 1e18. When no quantity is sent the derived figure
                          must clear the same bar — a weight and a distance that are each acceptable
                          on their own can still multiply out to less than 0.000001, and that is
                          rejected too.
                          Supplied without quantity it derives one as (weight_kg / 1000) * distance_km.
                          Supplied alongside a quantity your figure is kept unchanged and these are
                          recorded as its source — CFO does not overrule you, because rounding, part
                          loads and multi-leg routes make the two disagree legitimately.
                      distance_km:
                        type: number
                        exclusiveMinimum: 0
                        exclusiveMaximum: 1e18
                        description: Optional, paired with weight_kg. See above.
                      evidence_ref: { type: string, maxLength: 128 }
                      attributes:
                        type: object
                        properties:
                          vehicle_ref: { type: string, maxLength: 128 }
      responses:
        '202':
          description: Accepted. The body acknowledges the job; it is not a progress report — its status is always queued and its counters always zero, including when an identical request is replayed. Poll the job to learn the outcome.
          headers:
            Idempotency-Replayed:
              description: >-
                Present and true when this exact Idempotency-Key and payload were already accepted.
                Nothing new was queued or written: the original job is returned. Read this header,
                or an unchanged re-run is indistinguishable from a fresh submission.
              schema: { type: string, enum: ['true'] }
            Location:
              description: Path of the job to poll.
              schema: { type: string }
            Retry-After:
              description: Suggested seconds to wait before the first poll.
              schema: { type: integer }
          content: { application/json: { schema: { $ref: '#/components/schemas/ImportJob' } } }
        '400':
          description: >-
            INVALID_REQUEST — the request is malformed. This covers a missing or unusable
            Idempotency-Key, a bad date, a zero or negative quantity, a measure too small to store
            or too large for the column, and weight_kg supplied without distance_km or the reverse.
            Fix the payload; retrying it unchanged will fail the same way.
          content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } }
        '401': { $ref: '#/components/responses/AuthError' }
        '403': { $ref: '#/components/responses/Problem' }
        '409':
          description: Either FACILITY_IMPORT_IN_PROGRESS (one import runs per facility at a time — wait for the running job to reach a terminal status) or IDEMPOTENCY_KEY_REUSED (the same Idempotency-Key arrived with different content).
          content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } }
        '413': { $ref: '#/components/responses/Problem' }
        '422':
          description: >-
            ACTIVITY_CAPABILITY_UNAVAILABLE — the row is well-formed but the activity_code,
            catalog_item_code and unit combination is not enabled for this facility and period, or
            weight_kg and distance_km were attached to a row that is not measured in tonne-kilometres.
            Check /integrations/catalog/activity-codes. A malformed number answers 400, not this.
          content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } }
        '429': { $ref: '#/components/responses/OAuthError' }
        '503': { $ref: '#/components/responses/Problem' }
  /integrations/activity-imports/{job_id}:
    get:
      operationId: getActivityImport
      tags: [Activity imports]
      summary: Read one import job — the authoritative outcome of a submission
      security: [{ CFOAccessToken: [] }]
      parameters:
        - { name: job_id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        '200':
          description: >-
            Poll until phase is terminal. On completed, completed_with_skips and
            completed_with_errors the counters are written in the same statement as the status and
            describe every row. A job retired as failed after its lease repeatedly expired is also
            terminal, but its counters are left wherever processing stopped and describe only the
            rows reached — read the items endpoint for the truth on that path.
          content: { application/json: { schema: { $ref: '#/components/schemas/ImportJob' } } }
        '401': { $ref: '#/components/responses/AuthError' }
        '404': { $ref: '#/components/responses/Problem' }
        '429': { $ref: '#/components/responses/AuthError' }
        '503': { $ref: '#/components/responses/Problem' }
  /integrations/activity-imports/{job_id}/items:
    get:
      operationId: listActivityImportItems
      tags: [Activity imports]
      summary: Read the per-row outcome of an import job
      security: [{ CFOAccessToken: [] }]
      parameters:
        - { name: job_id, in: path, required: true, schema: { type: string, format: uuid } }
        - { name: cursor, in: query, required: false, schema: { type: integer, default: 0 }, description: Pass next_cursor from the previous page. }
        - { name: limit, in: query, required: false, schema: { type: integer, default: 100 } }
      responses:
        '200':
          description: One page of rows. Continue while has_more is true, using next_cursor.
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      type: object
                      properties:
                        row_sequence: { type: integer }
                        external_ref: { type: string }
                        weight_kg:
                          type: number
                          description: Echoed back when supplied, absent otherwise.
                        distance_km:
                          type: number
                          description: Echoed back when supplied, absent otherwise.
                        tkm_source:
                          type: string
                          enum: [client, derived]
                          description: Whether the tonne-kilometre figure was the one you sent or one CFO derived from weight_kg and distance_km. Absent on rows that are not tonne-kilometre rows.
                        status:
                          type: string
                          enum: [queued, validated, would_create, created, would_skip_duplicate, skipped_duplicate, would_conflict, failed, pending_review]
                          description: created means written; skipped_duplicate means this external_ref was already written and was deliberately not written again; would_* are the dry-run equivalents; would_conflict and failed both carry error_code EXTERNAL_REF_CONFLICT when the same external_ref arrives with different content.
                        calculated_tco2e: { type: number }
                        factor_code: { type: string }
                        factor_value: { type: number }
                        error_code: { type: string }
                        error_message: { type: string }
                  next_cursor: { type: integer, description: Absent when there is no further page. }
                  has_more: { type: boolean }
        '400': { $ref: '#/components/responses/Problem' }
        '401': { $ref: '#/components/responses/AuthError' }
        '404': { $ref: '#/components/responses/Problem' }
        '429': { $ref: '#/components/responses/AuthError' }
        '503': { $ref: '#/components/responses/Problem' }
components:
  securitySchemes:
    CFOAccessToken:
      type: http
      scheme: bearer
      bearerFormat: opaque
      description: Short-lived token used only by the ERP or another approved server-side integration runtime. Browser-based API documentation is read-only and must not receive this token.
  schemas:
    ImportJob:
      type: object
      properties:
        job_id: { type: string, format: uuid }
        status:
          type: string
          enum: [queued, running, completed, completed_with_skips, completed_with_errors, failed]
          description: >-
            Terminal statuses are completed, completed_with_skips, completed_with_errors and failed.
            A job that skipped duplicate rows ends completed_with_skips — a normal, successful
            outcome, not an error. A job with any failed or pending-review row ends
            completed_with_errors. Treat phase == terminal as the definitive signal to stop polling.
        phase: { type: string, enum: [queued, validating, committing, terminal] }
        external_batch_id: { type: string }
        received_rows: { type: integer }
        processed_rows: { type: integer }
        created_rows: { type: integer }
        validated_rows: { type: integer }
        skipped_rows: { type: integer }
        pending_review_rows: { type: integer }
        failed_rows: { type: integer }
        submitted_at: { type: string, format: date-time }
        finished_at: { type: string, format: date-time }
        correlation_id: { type: string }
    Problem:
      type: object
      description: RFC 7807 problem detail.
      properties:
        type: { type: string }
        title: { type: string }
        status: { type: integer }
        code: { type: string }
        detail: { type: string }
  responses:
    JSON:
      description: JSON response
      content: { application/json: { schema: { type: object } } }
    Problem:
      description: Problem detail with a machine-readable code.
      content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } }
    AuthError:
      description: >-
        Authentication, authorization-context or rate-limit error on a resource endpoint.
        Note this is not the OAuth error shape — that one is used only by the token endpoint.
        429 responses carry a Retry-After header.
      content:
        application/json:
          schema:
            type: object
            required: [code, message]
            properties:
              code: { type: string }
              message: { type: string }
    OAuthError:
      description: Token-endpoint error, RFC 6749 shape, without credential detail. A 429 response includes Retry-After in seconds.
      headers:
        Retry-After:
          description: Seconds the client must wait before retrying a rate-limited token request. Present on 429 responses.
          schema: { type: integer, minimum: 1 }
      content:
        application/json:
          schema:
            type: object
            required: [error, error_description]
            properties:
              error: { type: string }
              error_description: { type: string }
