openapi: 3.1.0
info:
  title: Metro2 API
  description: |
    Credit reporting API by Switch Labs. Submit Metro2 records, upload files,
    manage API keys, and integrate with property management systems.

    ## Authentication

    Most endpoints use Bearer token authentication. Include your API key in the
    `Authorization` header:

    ```
    Authorization: Bearer YOUR_API_KEY
    ```

    API keys are scoped to a company and an environment (`live` or `test`).
    Records submitted with a `test` key are stored separately and never
    transmitted to credit bureaus.

    The `/api/v1/data-deletion` endpoint uses the `x-api-key` header instead.

    ## Rate Limiting

    Rate-limited endpoints return `429` with a `retry_after` field (seconds
    until the window resets). Limits are per API key.

    | Endpoint | Limit | Window |
    |----------|-------|--------|
    | POST /api/v1/submit-metro2-data | 300 | 60s |
    | POST /api/v1/files/upload-url | 60 | 60s |
    | POST /api/v1/files/{id}/process | 120 | 60s |
    | POST /api/v1/integrations/rentvine/ingest | 300 | 60s |
    | POST /api/v1/integrations/rentvine/sync | 10 | 60s |

    ## Environments

    API keys are issued for either `live` or `test`. Test-environment submissions
    are fully validated but never sent to bureaus.

    ## Errors

    All error responses follow this shape:

    ```json
    {
      "error": "Human-readable message",
      "details": "Optional additional context"
    }
    ```
  version: 1.0.0
  contact:
    name: Switch Labs Support
    email: support@switchlabs.dev
    url: https://switchlabs.dev

servers:
  - url: https://metro2.switchlabs.dev
    description: Production

tags:
  - name: Records
    description: Submit and manage Metro2 credit reporting records
  - name: Batches
    description: Track the status of record submission batches
  - name: Files
    description: Upload and process Metro2 files (CSV, Excel)
  - name: API Keys
    description: Manage API keys (requires admin session auth)
  - name: Integrations
    description: Property management system integrations
  - name: Privacy
    description: CCPA/GDPR data deletion
  - name: Webhooks
    description: Configure and operate signed webhook endpoints
  - name: Disputes
    description: Consumer dispute intake, notes, and resolution
  - name: CRA Responses
    description: Upload and inspect parsed CRA response files
  - name: Scheduling
    description: Per-bureau automated submission scheduling
  - name: Audit
    description: Record history and audit export APIs

paths:
  # ── Records ──────────────────────────────────────────────────────────
  /api/v1/submit-metro2-data:
    post:
      operationId: submitMetro2Data
      tags: [Records]
      summary: Submit Metro2 records
      description: |
        Accepts an array of Metro2 records, validates each one, and upserts
        them into the database. Records are matched on
        `(company_id, consumer_account_number)` — existing records with the
        same account number are updated.

        The request body can be either a bare JSON array of records or an
        object with a `records` array and an optional `callback_url`.

        Returns `202` if all records are accepted, or `207` if some were
        rejected. The `errors` array (capped at 20) describes each failure.
      security:
        - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf:
                - $ref: "#/components/schemas/SubmitRecordsPayload"
                - type: array
                  items:
                    $ref: "#/components/schemas/Metro2Record"
            examples:
              wrapped:
                summary: Object with records array
                value:
                  records:
                    - consumer_account_number: "ACCT-001"
                      portfolio_type: "I"
                      account_type: "00"
                      date_opened: "2024-01-15"
                      highest_credit_or_original_loan_amount: 25000
                      terms_duration: "060"
                      terms_frequency: "M"
                      account_status: "11"
                      current_balance: 18500
                      date_of_account_info: "2025-03-01"
                      surname: "Smith"
                      first_name: "Jane"
                      ecoa_code: "1"
                      address_1: "123 Main St"
                      city: "Austin"
                      state: "TX"
                      postal_code: "78701"
                      address_indicator: "C"
                  callback_url: "https://example.com/webhook"
              bare_array:
                summary: Bare array of records
                value:
                  - consumer_account_number: "ACCT-001"
                    portfolio_type: "I"
                    account_type: "00"
                    date_opened: "2024-01-15"
                    highest_credit_or_original_loan_amount: 25000
                    terms_duration: "060"
                    terms_frequency: "M"
                    account_status: "11"
                    current_balance: 18500
                    date_of_account_info: "2025-03-01"
                    surname: "Smith"
                    first_name: "Jane"
                    ecoa_code: "1"
                    address_1: "123 Main St"
                    city: "Austin"
                    state: "TX"
                    postal_code: "78701"
                    address_indicator: "C"
      responses:
        "202":
          description: All records accepted
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BatchResult"
        "207":
          description: Partial success — some records accepted, some rejected
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BatchResult"
        "400":
          description: Invalid JSON body or empty/missing records array
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          description: Missing, invalid, revoked, or expired API key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "429":
          description: Rate limit exceeded (300 req / 60s)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RateLimitResponse"
        "500":
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  # ── Batches ──────────────────────────────────────────────────────────
  /api/v1/batch-status/{id}:
    get:
      operationId: getBatchStatus
      tags: [Batches]
      summary: Get ingest batch status
      description: |
        Returns the status and result summary for a previously submitted batch.
        The batch must belong to the same company as the API key.
      security:
        - BearerAuth: []
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
          description: Batch ID returned from a submit or ingest call
      responses:
        "200":
          description: Batch found
          content:
            application/json:
              schema:
                type: object
                properties:
                  batch:
                    $ref: "#/components/schemas/IngestBatch"
        "400":
          description: Missing batch ID
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          description: Missing or invalid API key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: Batch not found or does not belong to your company
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "500":
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  # ── Files ────────────────────────────────────────────────────────────
  /api/v1/files/upload-url:
    post:
      operationId: getUploadUrl
      tags: [Files]
      summary: Get a signed upload URL
      description: |
        Creates a file record and returns a signed URL for uploading a Metro2
        file (CSV, Excel, or Metro2 fixed-width). After uploading the file to
        the returned URL via PUT, call `/api/v1/files/{id}/process` to parse
        and import the records.
      security:
        - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [filename]
              properties:
                filename:
                  type: string
                  description: Original filename including extension
                  example: "march-2025-records.csv"
                contentType:
                  type: string
                  description: MIME type of the file
                  default: application/octet-stream
                  example: "text/csv"
                size:
                  type: integer
                  description: File size in bytes (optional, for record-keeping)
                  example: 524288
      responses:
        "200":
          description: Signed URL created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UploadUrlResponse"
        "400":
          description: Missing filename
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          description: Missing or invalid API key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "429":
          description: Rate limit exceeded (60 req / 60s)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RateLimitResponse"
        "500":
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /api/v1/files/{id}/status:
    get:
      operationId: getFileStatus
      tags: [Files]
      summary: Get file processing status
      description: |
        Returns the current status of a file upload, including processing
        progress and any errors encountered.
      security:
        - BearerAuth: []
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
          description: File ID returned from the upload-url endpoint
      responses:
        "200":
          description: File status
          content:
            application/json:
              schema:
                type: object
                properties:
                  file:
                    $ref: "#/components/schemas/FileUpload"
        "400":
          description: Missing file ID
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          description: Missing or invalid API key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: File not found or does not belong to your company
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "500":
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /api/v1/files/{id}/process:
    post:
      operationId: processFile
      tags: [Files]
      summary: Process an uploaded file
      description: |
        Triggers processing of a previously uploaded file. The file is parsed,
        records are validated, and valid records are upserted into the database.

        All options are optional — sensible defaults are applied. Use `mappings`
        to remap column headers to Metro2 field names if your file uses
        non-standard headers.
      security:
        - BearerAuth: []
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
          description: File ID to process
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                mappings:
                  type: object
                  additionalProperties:
                    type: string
                  description: >
                    Map file column headers to Metro2 field names.
                    Keys are file column names, values are Metro2 field names.
                  example:
                    "Account Number": "consumer_account_number"
                    "Last Name": "surname"
                fieldMappings:
                  type: object
                  additionalProperties:
                    type: string
                  description: Alias for `mappings`
                defaultValues:
                  type: object
                  additionalProperties:
                    type: string
                  description: >
                    Default values for fields not present in the file.
                  example:
                    portfolio_type: "I"
                    address_indicator: "C"
                skipInvalidFields:
                  type: boolean
                  description: Skip fields that fail validation instead of rejecting the entire record
                  default: false
                allowMissingRequiredFields:
                  type: boolean
                  description: Allow records with missing required fields (they will be flagged)
                  default: false
                skipDuplicates:
                  type: boolean
                  description: Skip records that already exist instead of updating them
                  default: false
                batchSize:
                  type: integer
                  description: Number of records to process per batch
                callback_url:
                  type: string
                  format: uri
                  description: Webhook URL to receive processing results
      responses:
        "200":
          description: File processed
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  result:
                    type: object
                    description: Processing result with counts and any errors
        "400":
          description: Missing file ID
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          description: Missing or invalid API key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: File not found or does not belong to your company
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "429":
          description: Rate limit exceeded (120 req / 60s)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RateLimitResponse"
        "500":
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  # ── API Keys ─────────────────────────────────────────────────────────
  /api/v1/api-keys:
    get:
      operationId: listApiKeys
      tags: [API Keys]
      summary: List API keys
      description: |
        Lists all API keys for a company. Requires admin session authentication
        (not API key auth). The full key value is never returned — only the
        8-character prefix is shown.
      security:
        - SessionAuth: []
      parameters:
        - in: query
          name: company_id
          schema:
            type: string
            format: uuid
          description: >
            Company to list keys for. Defaults to the authenticated user's
            company. Superadmins can query any company.
      responses:
        "200":
          description: API keys listed
          content:
            application/json:
              schema:
                type: object
                properties:
                  keys:
                    type: array
                    items:
                      $ref: "#/components/schemas/ApiKeyRecord"
        "400":
          description: Missing company context
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          description: Not authenticated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "403":
          description: Not an admin, or non-superadmin accessing another company
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "500":
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
    post:
      operationId: createApiKey
      tags: [API Keys]
      summary: Create a new API key
      description: |
        Generates a new API key for the specified company. The full key value
        is returned **only once** in the response — store it securely.

        Requires admin session authentication. Rate limited to 30 req / 60s.
      security:
        - SessionAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:
                  type: string
                  description: Human-readable name for the key
                  example: "Production Integration"
                companyId:
                  type: string
                  format: uuid
                  description: >
                    Company to create the key for. Defaults to the
                    authenticated user's company. Superadmins can create keys
                    for any company.
                environment:
                  type: string
                  enum: [live, test]
                  default: live
                  description: >
                    `live` keys submit real data; `test` keys are sandboxed.
      responses:
        "201":
          description: API key created
          content:
            application/json:
              schema:
                type: object
                properties:
                  key:
                    type: string
                    description: >
                      The full API key value. This is shown only once — store
                      it securely.
                  record:
                    $ref: "#/components/schemas/ApiKeyRecord"
        "400":
          description: Missing name or invalid environment value
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          description: Not authenticated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "403":
          description: Not an admin, or non-superadmin creating for another company
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "429":
          description: Rate limit exceeded (30 req / 60s)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RateLimitResponse"
        "500":
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /api/v1/api-keys/{id}:
    patch:
      operationId: updateApiKey
      tags: [API Keys]
      summary: Update an API key
      description: |
        Update the name, status, or environment of an existing API key.
        Set `status` to `revoked` to permanently disable a key.

        Requires admin session authentication.
      security:
        - SessionAuth: []
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
          description: API key ID to update
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: At least one field must be provided.
              properties:
                status:
                  type: string
                  enum: [active, revoked]
                name:
                  type: string
                  description: New name (must be non-empty after trimming)
                environment:
                  type: string
                  enum: [live, test]
      responses:
        "200":
          description: Key updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  key:
                    type: object
                    properties:
                      id:
                        type: string
                        format: uuid
                      status:
                        type: string
                        enum: [active, revoked]
                      name:
                        type: string
                      environment:
                        type: string
                        enum: [live, test]
        "400":
          description: Missing key ID, no fields provided, or invalid field values
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          description: Not authenticated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "403":
          description: Not an admin, or non-superadmin updating key in another company
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: Key not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "500":
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  # ── Integrations ─────────────────────────────────────────────────────
  /api/v1/integrations/rentvine/ingest:
    post:
      operationId: ingestRentVineData
      tags: [Integrations]
      summary: Ingest RentVine tenant data
      description: |
        Accepts RentVine's native JSON format (tenants with lease and payment
        data) and automatically transforms it into Metro2 credit reporting
        records. No need to pre-format data — just POST your RentVine tenant
        data as-is.

        Each tenant is transformed, validated against Metro2 rules, and
        upserted. The `consumer_account_number` is derived from the
        `contact_id` and `lease_id`.
      security:
        - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/RentVineIngestPayload"
            example:
              tenants:
                - contact_id: 12345
                  first_name: John
                  last_name: Doe
                  email: john@example.com
                  phone: "5551234567"
                  date_of_birth: "1990-01-15"
                  ssn: "123456789"
                  lease:
                    lease_id: 67890
                    property_address: 123 Main St
                    unit: Apt 4B
                    city: Austin
                    state: TX
                    zip_code: "78701"
                    start_date: "2024-01-01"
                    end_date: "2025-01-01"
                    monthly_rent: 1500.00
                    status: active
                    current_balance: 0
                  payments:
                    - date: "2024-12-05"
                      amount: 1500.00
                      type: rent
              callback_url: "https://example.com/webhook"
      responses:
        "202":
          description: All tenant records accepted
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BatchResult"
        "207":
          description: Partial success — some accepted, some rejected
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RentVineBatchResult"
        "400":
          description: Invalid payload — Zod validation errors
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: string
                    example: "Invalid RentVine payload"
                  details:
                    type: array
                    items:
                      type: object
                      properties:
                        path:
                          type: string
                          example: "tenants.0.ssn"
                        message:
                          type: string
                          example: "String must contain exactly 9 character(s)"
        "401":
          description: Missing or invalid API key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "429":
          description: Rate limit exceeded (300 req / 60s)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RateLimitResponse"
        "500":
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /api/v1/integrations/rentvine/sync:
    post:
      operationId: syncRentVineData
      tags: [Integrations]
      summary: Trigger RentVine data sync
      description: |
        Triggers a full data sync from a connected RentVine account. The
        company must have an active RentVine integration connection configured
        in their settings.

        This endpoint has a lower rate limit (10 req / 60s) because each sync
        makes multiple API calls to RentVine.

        Use `dry_run: true` to preview what would be synced without actually
        creating or updating records.
      security:
        - BearerAuth: []
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                dry_run:
                  type: boolean
                  default: false
                  description: Preview sync results without making changes
      responses:
        "202":
          description: Sync completed — all records accepted
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SyncResult"
        "207":
          description: Sync completed — some records rejected
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SyncResult"
        "401":
          description: Missing or invalid API key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: No RentVine connection found for this company
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "409":
          description: RentVine connection exists but is not active
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "429":
          description: Rate limit exceeded (10 req / 60s)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RateLimitResponse"
        "500":
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  # ── Privacy / Data Deletion ──────────────────────────────────────────
  /api/v1/data-deletion:
    post:
      operationId: deleteConsumerData
      tags: [Privacy]
      summary: Delete consumer records (CCPA)
      description: |
        Permanently deletes all Metro2 records matching the provided consumer
        account number(s) for the authenticated company. Intended for
        CCPA/GDPR compliance.

        **Note:** This endpoint uses the `x-api-key` header for authentication
        (not the `Authorization: Bearer` header used by other endpoints).

        Maximum 100 account numbers per request.
      security:
        - XApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: Provide either a single account number or an array.
              properties:
                consumer_account_number:
                  type: string
                  description: Single account number to delete
                consumer_account_numbers:
                  type: array
                  items:
                    type: string
                  maxItems: 100
                  description: Array of account numbers to delete (max 100)
            examples:
              single:
                summary: Delete one account
                value:
                  consumer_account_number: "ACCT-001"
              batch:
                summary: Delete multiple accounts
                value:
                  consumer_account_numbers:
                    - "ACCT-001"
                    - "ACCT-002"
                    - "ACCT-003"
      responses:
        "200":
          description: Deletion completed
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  deleted_count:
                    type: integer
                    description: Number of records deleted
                    example: 3
        "400":
          description: Missing account number(s) or exceeds 100 limit
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          description: Missing or invalid API key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "500":
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  # ── Webhooks ─────────────────────────────────────────────────────────
  /api/v1/webhooks:
    get:
      operationId: listWebhooks
      tags: [Webhooks]
      summary: List webhook endpoints
      description: Returns webhook endpoints for the authenticated company with recent delivery history.
      security:
        - BearerAuth: []
      parameters:
        - in: query
          name: deliveries_limit
          schema:
            type: integer
            default: 5
            maximum: 20
          description: Number of recent deliveries to include per webhook
      responses:
        "200":
          description: Webhook list returned
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: array
                    items:
                      type: object
                  available_events:
                    type: array
                    items:
                      type: string
        "401":
          description: Missing or invalid API key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
    post:
      operationId: createWebhook
      tags: [Webhooks]
      summary: Create a webhook endpoint
      security:
        - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url, events]
              properties:
                url:
                  type: string
                  format: uri
                events:
                  type: array
                  items:
                    type: string
                enabled:
                  type: boolean
                  default: true
      responses:
        "201":
          description: Webhook created
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
        "400":
          description: Invalid payload
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          description: Missing or invalid API key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /api/v1/webhooks/{id}:
    get:
      operationId: getWebhook
      tags: [Webhooks]
      summary: Get one webhook endpoint
      security:
        - BearerAuth: []
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Webhook returned
          content:
            application/json:
              schema:
                type: object
        "401":
          description: Missing or invalid API key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: Webhook not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
    patch:
      operationId: updateWebhook
      tags: [Webhooks]
      summary: Update a webhook endpoint
      description: Update URL, subscribed events, enabled state, or rotate the signing secret.
      security:
        - BearerAuth: []
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                url:
                  type: string
                  format: uri
                events:
                  type: array
                  items:
                    type: string
                enabled:
                  type: boolean
                rotate_secret:
                  type: boolean
                  description: When true, rotate the webhook signing secret
      responses:
        "200":
          description: Webhook updated
          content:
            application/json:
              schema:
                type: object
        "400":
          description: Invalid payload
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          description: Missing or invalid API key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: Webhook not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
    delete:
      operationId: deleteWebhook
      tags: [Webhooks]
      summary: Delete a webhook endpoint
      security:
        - BearerAuth: []
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Webhook deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
        "401":
          description: Missing or invalid API key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: Webhook not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /api/v1/webhooks/{id}/deliveries:
    get:
      operationId: listWebhookDeliveries
      tags: [Webhooks]
      summary: List deliveries for a webhook endpoint
      security:
        - BearerAuth: []
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
        - in: query
          name: limit
          schema:
            type: integer
            default: 20
            maximum: 100
      responses:
        "200":
          description: Deliveries returned
          content:
            application/json:
              schema:
                type: object
        "401":
          description: Missing or invalid API key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: Webhook not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /api/v1/webhooks/{id}/test:
    post:
      operationId: testWebhook
      tags: [Webhooks]
      summary: Queue a test webhook delivery
      security:
        - BearerAuth: []
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                event:
                  type: string
                payload:
                  type: object
      responses:
        "202":
          description: Test delivery queued
          content:
            application/json:
              schema:
                type: object
        "400":
          description: Invalid event
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          description: Missing or invalid API key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: Webhook not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /api/v1/webhooks/{id}/deliveries/{deliveryId}/replay:
    post:
      operationId: replayWebhookDelivery
      tags: [Webhooks]
      summary: Replay a stored webhook delivery
      security:
        - BearerAuth: []
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
        - in: path
          name: deliveryId
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "202":
          description: Replay queued
          content:
            application/json:
              schema:
                type: object
        "401":
          description: Missing or invalid API key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: Webhook or delivery not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  # ── Disputes ─────────────────────────────────────────────────────────
  /api/v1/disputes:
    get:
      operationId: listDisputes
      tags: [Disputes]
      summary: List disputes
      security:
        - BearerAuth: []
      parameters:
        - in: query
          name: status
          schema:
            type: string
        - in: query
          name: limit
          schema:
            type: integer
            default: 100
            maximum: 500
      responses:
        "200":
          description: Disputes returned
          content:
            application/json:
              schema:
                type: object
        "401":
          description: Missing or invalid API key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
    post:
      operationId: createDispute
      tags: [Disputes]
      summary: Create a dispute
      security:
        - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - consumer_account_number
                - dispute_source
                - dispute_reason
              properties:
                record_id:
                  type: string
                  format: uuid
                consumer_account_number:
                  type: string
                consumer_name:
                  type: string
                dispute_source:
                  type: string
                  enum: [direct, equifax, experian, transunion]
                dispute_reason:
                  type: string
                dispute_code:
                  type: string
                received_at:
                  type: string
                  format: date-time
                assigned_to:
                  type: string
                  format: uuid
      responses:
        "201":
          description: Dispute created
          content:
            application/json:
              schema:
                type: object
        "400":
          description: Invalid payload
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          description: Missing or invalid API key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /api/v1/disputes/{id}:
    get:
      operationId: getDispute
      tags: [Disputes]
      summary: Get a dispute with notes
      security:
        - BearerAuth: []
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Dispute returned
          content:
            application/json:
              schema:
                type: object
        "401":
          description: Missing or invalid API key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: Dispute not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
    patch:
      operationId: updateDispute
      tags: [Disputes]
      summary: Update a dispute
      security:
        - BearerAuth: []
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        content:
          application/json:
            schema:
              type: object
      responses:
        "200":
          description: Dispute updated
          content:
            application/json:
              schema:
                type: object
        "401":
          description: Missing or invalid API key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: Dispute not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /api/v1/disputes/{id}/notes:
    post:
      operationId: addDisputeNote
      tags: [Disputes]
      summary: Add a note to a dispute
      security:
        - BearerAuth: []
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [note]
              properties:
                note:
                  type: string
      responses:
        "201":
          description: Note created
          content:
            application/json:
              schema:
                type: object
        "400":
          description: Missing note text
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          description: Missing or invalid API key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: Dispute not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /api/v1/disputes/{id}/resolve:
    post:
      operationId: resolveDispute
      tags: [Disputes]
      summary: Resolve a dispute
      security:
        - BearerAuth: []
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [resolution]
              properties:
                resolution:
                  type: string
                  enum: [verified, updated, deleted, in_dispute]
                resolution_notes:
                  type: string
      responses:
        "200":
          description: Dispute resolved
          content:
            application/json:
              schema:
                type: object
        "400":
          description: Invalid resolution value
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          description: Missing or invalid API key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: Dispute not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  # ── CRA Responses ────────────────────────────────────────────────────
  /api/v1/cra-responses:
    get:
      operationId: listCraResponses
      tags: [CRA Responses]
      summary: List CRA response files
      security:
        - BearerAuth: []
      responses:
        "200":
          description: CRA responses returned
          content:
            application/json:
              schema:
                type: object
        "401":
          description: Missing or invalid API key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /api/v1/cra-responses/upload:
    post:
      operationId: uploadCraResponse
      tags: [CRA Responses]
      summary: Upload and parse a CRA response file
      security:
        - BearerAuth: []
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file, bureau]
              properties:
                file:
                  type: string
                  format: binary
                bureau:
                  type: string
                  enum: [equifax, experian, transunion]
                transmission_id:
                  type: string
                  format: uuid
      responses:
        "201":
          description: CRA response parsed and stored
          content:
            application/json:
              schema:
                type: object
        "400":
          description: Invalid payload
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          description: Missing or invalid API key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /api/v1/cra-responses/{id}:
    get:
      operationId: getCraResponse
      tags: [CRA Responses]
      summary: Get one CRA response with parsed items
      security:
        - BearerAuth: []
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: CRA response returned
          content:
            application/json:
              schema:
                type: object
        "401":
          description: Missing or invalid API key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: CRA response not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  # ── Scheduling ───────────────────────────────────────────────────────
  /api/v1/schedules:
    get:
      operationId: listSchedules
      tags: [Scheduling]
      summary: List submission schedules
      security:
        - BearerAuth: []
      responses:
        "200":
          description: Schedules returned
          content:
            application/json:
              schema:
                type: object
        "401":
          description: Missing or invalid API key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
    post:
      operationId: createSchedule
      tags: [Scheduling]
      summary: Create or upsert a submission schedule
      security:
        - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [bureau, frequency]
              properties:
                bureau:
                  type: string
                  enum: [equifax, experian, transunion]
                frequency:
                  type: string
                  enum: [monthly, weekly, biweekly]
                day_of_month:
                  type: integer
                  minimum: 1
                  maximum: 28
                day_of_week:
                  type: integer
                  minimum: 0
                  maximum: 6
                time_utc:
                  type: string
                  example: "06:00"
                enabled:
                  type: boolean
      responses:
        "201":
          description: Schedule saved
          content:
            application/json:
              schema:
                type: object
        "400":
          description: Invalid payload
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          description: Missing or invalid API key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /api/v1/schedules/{id}:
    get:
      operationId: getSchedule
      tags: [Scheduling]
      summary: Get one submission schedule
      security:
        - BearerAuth: []
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Schedule returned
          content:
            application/json:
              schema:
                type: object
        "401":
          description: Missing or invalid API key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: Schedule not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
    patch:
      operationId: updateSchedule
      tags: [Scheduling]
      summary: Update a submission schedule
      security:
        - BearerAuth: []
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        content:
          application/json:
            schema:
              type: object
      responses:
        "200":
          description: Schedule updated
          content:
            application/json:
              schema:
                type: object
        "401":
          description: Missing or invalid API key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: Schedule not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
    delete:
      operationId: deleteSchedule
      tags: [Scheduling]
      summary: Delete a submission schedule
      security:
        - BearerAuth: []
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Schedule deleted
          content:
            application/json:
              schema:
                type: object
        "401":
          description: Missing or invalid API key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: Schedule not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  # ── Audit ────────────────────────────────────────────────────────────
  /api/v1/records/{id}/history:
    get:
      operationId: getRecordHistory
      tags: [Audit]
      summary: Get record-scoped audit history
      security:
        - BearerAuth: []
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
        - in: query
          name: limit
          schema:
            type: integer
            default: 100
            maximum: 500
      responses:
        "200":
          description: Audit history returned
          content:
            application/json:
              schema:
                type: object
        "401":
          description: Missing or invalid API key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /api/v1/audit/export:
    get:
      operationId: exportAuditLog
      tags: [Audit]
      summary: Export audit rows
      description: Export company-scoped audit rows as CSV or JSON.
      security:
        - BearerAuth: []
      parameters:
        - in: query
          name: format
          schema:
            type: string
            enum: [csv, json]
            default: csv
        - in: query
          name: record_id
          schema:
            type: string
            format: uuid
        - in: query
          name: action
          schema:
            type: string
        - in: query
          name: source
          schema:
            type: string
        - in: query
          name: limit
          schema:
            type: integer
            default: 500
            maximum: 5000
      responses:
        "200":
          description: Audit export returned
          content:
            application/json:
              schema:
                type: object
            text/csv:
              schema:
                type: string
                format: binary
        "401":
          description: Missing or invalid API key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

# ════════════════════════════════════════════════════════════════════════
# Components
# ════════════════════════════════════════════════════════════════════════
components:
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      description: >
        API key passed as a Bearer token in the Authorization header.
        Example: `Authorization: Bearer sk_live_abc123...`
    SessionAuth:
      type: apiKey
      in: cookie
      name: supabaseAuth
      description: >
        Session-based authentication via browser cookies. Used only by the
        API key management endpoints. Not available for programmatic use —
        manage keys through the Metro2 dashboard.
    XApiKeyAuth:
      type: apiKey
      in: header
      name: x-api-key
      description: >
        API key passed in the `x-api-key` header. Used only by the
        data-deletion endpoint.

  schemas:
    # ── Request Schemas ────────────────────────────────────────────────
    SubmitRecordsPayload:
      type: object
      required: [records]
      properties:
        records:
          type: array
          items:
            $ref: "#/components/schemas/Metro2Record"
          minItems: 1
          description: Array of Metro2 records to submit
        callback_url:
          type: string
          format: uri
          description: >
            Optional webhook URL. A POST request with the batch results will
            be sent here when processing completes.

    Metro2Record:
      type: object
      description: |
        A Metro2 credit reporting record. Field names accept both the
        canonical snake_case names shown here and common aliases (e.g.
        `account_status_code` → `account_status`, `zip_code` → `postal_code`,
        `social_security_number` → `ssn`).
      required:
        - consumer_account_number
        - portfolio_type
        - account_type
        - date_opened
        - highest_credit_or_original_loan_amount
        - terms_duration
        - terms_frequency
        - account_status
        - current_balance
        - date_of_account_info
        - surname
        - first_name
        - ecoa_code
        - address_1
        - city
        - state
        - postal_code
        - address_indicator
      properties:
        # ── Account identification ──
        identification_number:
          type: string
          maxLength: 20
          description: Unique furnisher/branch ID; must remain constant across all submissions
        cycle_identifier:
          type: string
          maxLength: 2
          description: Reporting cycle identifier
        consumer_account_number:
          type: string
          maxLength: 30
          description: Exact account number as shown on statements

        # ── Account type & terms ──
        portfolio_type:
          type: string
          enum: ["C", "I", "M", "O", "R"]
          description: |
            C = Line of Credit, I = Installment, M = Mortgage,
            O = Open (balance due each cycle), R = Revolving
        account_type:
          type: string
          maxLength: 2
          description: |
            2-character account type code. Examples: "00" = Auto,
            "18" = Credit Card, "26" = Conventional Mortgage,
            "48" = Collection Agency
        date_opened:
          type: string
          format: date
          description: Account opening date (YYYY-MM-DD)
        date_closed:
          type: string
          format: date
          description: Account closing date (YYYY-MM-DD). Required for closed accounts.
        credit_limit:
          type: integer
          description: Maximum credit available. Required for Revolving (R) portfolio type.
        highest_credit_or_original_loan_amount:
          type: integer
          description: Original loan amount or highest credit used
        terms_duration:
          type: string
          maxLength: 3
          description: |
            Duration of terms. Format depends on portfolio type:
            C = "LOC", I/M = 3-digit number (e.g. "060" for 60 months),
            O = "001", R = "REV"
        terms_frequency:
          type: string
          enum: ["D", "P", "W", "B", "E", "M", "L", "Q", "T", "S", "Y"]
          description: |
            Payment frequency: D=Daily, P=Payment, W=Weekly, B=Biweekly,
            E=Bimonthly, M=Monthly, L=Bimonthly(alt), Q=Quarterly,
            T=Triannually, S=Semiannually, Y=Annually
        interest_type:
          type: string
          enum: ["F", "V"]
          description: F = Fixed rate, V = Variable rate

        # ── Balance & payments ──
        scheduled_payment_amount:
          type: integer
          description: Regularly scheduled payment amount
        actual_payment_amount:
          type: integer
          description: Actual payment amount received
        current_balance:
          type: integer
          description: Current outstanding balance
        amount_past_due:
          type: integer
          description: Amount currently past due
        original_charge_off_amount:
          type: integer
          description: Original amount charged off
        account_status:
          type: string
          maxLength: 2
          description: |
            2-character status code:
            11 = Current, 13 = Paid/Zero balance, 61-65 = Paid in full
            (various), 71 = 30-59 days late, 78 = 60-89 days,
            80 = 90-119 days, 82 = 120-149 days, 83 = 150-179 days,
            84 = 180+ days, 88 = Government claim, 89 = Deed in lieu,
            93 = Collections, 94 = Foreclosure, 95 = Voluntary surrender,
            96 = Repossession, 97 = Charge-off, DA = Delete account,
            DF = Delete (fraud)
        payment_rating:
          type: string
          maxLength: 1
          description: |
            Required only for statuses 13, 65, 88, 89, 94, 95.
            Must be blank for all other statuses.
            Values: 0-6, G, L
        payment_history_profile:
          type: string
          maxLength: 24
          description: 24-month rolling payment history. Characters 0-9, B, D, G, L.
        special_comment:
          type: string
          maxLength: 2
          description: Special comment code
        compliance_condition_code:
          type: string
          maxLength: 2
          description: Compliance condition indicator

        # ── Dates ──
        date_of_account_info:
          type: string
          format: date
          description: Date the account information is current as of (YYYY-MM-DD)
        fcra_date_of_first_delinquency:
          type: string
          format: date
          description: FCRA date of first delinquency. Required for delinquent statuses (71, 78, 80, 82, 83, 84).
        date_last_payment:
          type: string
          format: date
          description: Date of last payment received (YYYY-MM-DD)

        # ── Consumer information ──
        surname:
          type: string
          maxLength: 25
          description: Consumer's last name
        first_name:
          type: string
          maxLength: 15
          description: Consumer's first name
        middle_name:
          type: string
          maxLength: 15
          description: Consumer's middle name
        generation_code:
          type: string
          enum: ["J", "S", "1", "2", "3", "4", "5", "6", "7", "8", "9"]
          description: "J=Jr, S=Sr, 1-9=I-IX"
        ssn:
          type: string
          maxLength: 9
          description: 9-digit SSN (digits only, no dashes). Non-digits are stripped automatically.
        date_of_birth:
          type: string
          format: date
          description: Consumer date of birth (YYYY-MM-DD)
        phone_number:
          type: string
          maxLength: 10
          description: 10-digit phone (digits only). Non-digits are stripped automatically.
        ecoa_code:
          type: string
          enum: ["1", "2", "3", "5", "7", "T", "X", "W", "Z"]
          description: |
            1=Individual, 2=Joint, 3=Authorized User, 5=Shared,
            7=Maker, T=Terminated, X=Assoc w/ Terminated,
            W=Business/Commercial, Z=Delete Consumer
        consumer_info_indicator:
          type: string
          maxLength: 2
          description: |
            Consumer information indicator (bankruptcy, fraud, etc.).
            Examples: A=Ch7 Filed, E=Ch7 Discharged, D=Ch13 Filed,
            H=Ch13 Completed, Q=Remove Bankruptcy

        # ── Address ──
        country_code:
          type: string
          maxLength: 2
          default: "US"
          description: 2-letter country code
        address_1:
          type: string
          maxLength: 30
          description: Primary street address
        address_2:
          type: string
          maxLength: 30
          description: Secondary address (apt, suite, etc.)
        city:
          type: string
          maxLength: 20
          description: City
        state:
          type: string
          maxLength: 2
          description: 2-letter state code
        postal_code:
          type: string
          maxLength: 11
          description: ZIP code (5 or 9 digits for US)
        address_indicator:
          type: string
          enum: ["C", "Y", "N", "M", "S", "B", "U", "D", "P"]
          description: |
            C=Confirmed, Y=Known, N=Not confirmed, M=Military,
            S=Secondary, B=Business, U=Non-deliverable,
            D=Reporter default, P=Bill Payer Service
        residence_code:
          type: string
          enum: ["O", "R", ""]
          description: O=Owns, R=Rents, blank=Unknown

        # ── Supplemental segments ──
        j1_segments:
          type: array
          items:
            $ref: "#/components/schemas/J1Segment"
          description: Associated consumers at the same address
        j2_segments:
          type: array
          items:
            $ref: "#/components/schemas/J2Segment"
          description: Associated consumers at a different address
        l1_segments:
          type: array
          items:
            $ref: "#/components/schemas/L1Segment"
          description: Account number / identification number changes
        k_segments:
          type: array
          items:
            $ref: "#/components/schemas/KSegment"
          description: Original creditor, purchased/sold, mortgage info, specialized payment
        n1_segments:
          type: array
          items:
            $ref: "#/components/schemas/N1Segment"
          description: Employment information

    # ── Supplemental Segment Schemas ─────────────────────────────────
    J1Segment:
      type: object
      description: Associated consumer at the same address as the primary consumer.
      required: [surname, ecoa_code]
      properties:
        surname:
          type: string
          maxLength: 25
        first_name:
          type: string
          maxLength: 20
        middle_name:
          type: string
          maxLength: 20
        generation_code:
          type: string
          enum: ["J", "S", "1", "2", "3", "4", "5", "6", "7", "8", "9"]
        ssn:
          type: string
          maxLength: 9
          description: 9 digits only
        date_of_birth:
          type: string
          format: date
        telephone_number:
          type: string
          maxLength: 10
          description: 10 digits only
        ecoa_code:
          type: string
          enum: ["1", "2", "5", "7", "T", "X", "W", "Z"]
        consumer_info_indicator:
          type: string
          maxLength: 2

    J2Segment:
      type: object
      description: >
        Associated consumer at a different address. Includes all J1 fields
        plus a full address.
      required: [surname, ecoa_code, country_code, address_1, city, state, postal_code, address_indicator]
      properties:
        surname:
          type: string
          maxLength: 25
        first_name:
          type: string
          maxLength: 20
        middle_name:
          type: string
          maxLength: 20
        generation_code:
          type: string
          enum: ["J", "S", "1", "2", "3", "4", "5", "6", "7", "8", "9"]
        ssn:
          type: string
          maxLength: 9
        date_of_birth:
          type: string
          format: date
        telephone_number:
          type: string
          maxLength: 10
        ecoa_code:
          type: string
          enum: ["1", "2", "5", "7", "T", "X", "W", "Z"]
        consumer_info_indicator:
          type: string
          maxLength: 2
        country_code:
          type: string
          maxLength: 2
        address_1:
          type: string
          maxLength: 32
        address_2:
          type: string
          maxLength: 32
        city:
          type: string
          maxLength: 20
        state:
          type: string
          maxLength: 2
        postal_code:
          type: string
          maxLength: 11
        address_indicator:
          type: string
          enum: ["C", "Y", "N", "M", "S", "B", "U", "D", "P"]

    L1Segment:
      type: object
      description: Account number or identification number change.
      properties:
        change_indicator:
          type: string
          enum: ["1", "2", "3"]
          description: "1=Account# only, 2=ID# only, 3=Both"
        new_consumer_account_number:
          type: string
          maxLength: 30
        new_identification_number:
          type: string
          maxLength: 10

    KSegment:
      type: object
      description: |
        Specialized segment covering original creditor (K1), purchased/sold
        (K2), mortgage info (K3), and specialized payment (K4). The
        `segment_type` field determines which sub-fields apply.
      properties:
        segment_type:
          type: string
          enum: ["K1", "K2", "K3", "K4"]
        original_creditor_name:
          type: string
          description: K1 — Name of original creditor
        creditor_classification:
          type: string
          maxLength: 2
          description: K1 — 2-digit creditor classification code (01-15)
        purchased_sold_indicator:
          type: string
          enum: ["1", "2", "9"]
          description: "K2 — 1=Purchased From, 2=Sold To, 9=Remove"
        purchased_from_sold_to_name:
          type: string
          description: K2 — Entity name
        agency_identifier:
          type: string
          description: "K3 — 00=N/A, 01=Fannie Mae, 02=Freddie Mac"
        secondary_agency_account_number:
          type: string
          description: K3 — Secondary agency account number
        mortgage_id_number:
          type: string
          description: K3 — Mortgage identification number
        specialized_payment_indicator:
          type: integer
          enum: [1, 2]
          description: "K4 — 1=Balloon, 2=Deferred"
        deferred_payment_start_date:
          type: string
          format: date
          description: K4 — Deferred payment start date (if indicator=2)
        balloon_due_date:
          type: string
          format: date
          description: K4 — Balloon payment due date (if indicator=1)
        balloon_payment_amount:
          type: integer
          description: K4 — Balloon payment amount (if indicator=1)

    N1Segment:
      type: object
      description: Consumer employment information.
      required: [employer_name]
      properties:
        employer_name:
          type: string
        employer_address_1:
          type: string
        employer_address_2:
          type: string
        employer_city:
          type: string
        employer_state:
          type: string
          maxLength: 2
        employer_postal_code:
          type: string
        occupation:
          type: string

    # ── RentVine Schemas ───────────────────────────────────────────────
    RentVineIngestPayload:
      type: object
      required: [tenants]
      properties:
        tenants:
          type: array
          minItems: 1
          maxItems: 500
          items:
            $ref: "#/components/schemas/RentVineTenant"
        callback_url:
          type: string
          format: uri
          description: Optional webhook URL to receive batch results

    RentVineTenant:
      type: object
      required: [contact_id, first_name, last_name, ssn, lease]
      properties:
        contact_id:
          type: integer
          minimum: 1
          description: RentVine contact ID
        first_name:
          type: string
        last_name:
          type: string
        middle_name:
          type: string
        email:
          type: string
          format: email
        phone:
          type: string
        date_of_birth:
          type: string
          format: date
          description: YYYY-MM-DD
        ssn:
          type: string
          pattern: "^\\d{9}$"
          description: 9-digit SSN (required for credit reporting)
        lease:
          $ref: "#/components/schemas/RentVineLease"
        payments:
          type: array
          items:
            $ref: "#/components/schemas/RentVinePayment"

    RentVineLease:
      type: object
      required:
        - lease_id
        - property_address
        - city
        - state
        - zip_code
        - start_date
        - monthly_rent
        - status
        - current_balance
      properties:
        lease_id:
          type: integer
          minimum: 1
        property_address:
          type: string
        unit:
          type: string
        city:
          type: string
        state:
          type: string
          maxLength: 2
          description: 2-letter state code
        zip_code:
          type: string
          pattern: "^\\d{5}(-\\d{4})?$"
        start_date:
          type: string
          format: date
        end_date:
          type: string
          format: date
        monthly_rent:
          type: number
          exclusiveMinimum: 0
        status:
          type: string
          description: "Lease status: active, expired, terminated, eviction, deleted"
        current_balance:
          type: number

    RentVinePayment:
      type: object
      required: [date, amount, type]
      properties:
        date:
          type: string
          format: date
        amount:
          type: number
          minimum: 0
        type:
          type: string
          description: "Payment type (e.g., rent, deposit, fee)"

    # ── Response Schemas ───────────────────────────────────────────────
    ErrorResponse:
      type: object
      properties:
        error:
          type: string
          description: Human-readable error message
        details:
          type: string
          description: Additional context (present on 500 errors)

    RateLimitResponse:
      type: object
      properties:
        error:
          type: string
          example: "Rate limit exceeded"
        retry_after:
          type: integer
          description: Seconds until the rate limit window resets

    BatchResult:
      type: object
      properties:
        batch_id:
          type: string
          format: uuid
          description: Unique batch ID for tracking via /api/v1/batch-status/{id}
        received:
          type: integer
          description: Total records received
        accepted:
          type: integer
          description: Records successfully accepted
        rejected:
          type: integer
          description: Records that failed validation
        errors:
          type: array
          maxItems: 20
          description: First 20 validation errors (if any)
          items:
            type: object
            properties:
              index:
                type: integer
                description: Zero-based index of the failed record
              errors:
                type: array
                items:
                  type: object
                  properties:
                    message:
                      type: string
                    segment:
                      type: boolean
                      description: Whether the error is from a supplemental segment

    RentVineBatchResult:
      type: object
      properties:
        batch_id:
          type: string
          format: uuid
        received:
          type: integer
        accepted:
          type: integer
        rejected:
          type: integer
        errors:
          type: array
          maxItems: 20
          items:
            type: object
            properties:
              index:
                type: integer
              contact_id:
                type: integer
                description: RentVine contact ID of the failed tenant
              lease_id:
                type: integer
                description: RentVine lease ID of the failed tenant
              errors:
                type: array
                items:
                  type: object
                  properties:
                    message:
                      type: string

    SyncResult:
      type: object
      properties:
        sync_batch_id:
          type: string
          format: uuid
        total:
          type: integer
          description: Total tenants synced from RentVine
        accepted:
          type: integer
        rejected:
          type: integer
        errors:
          type: array
          items:
            type: object
        dry_run:
          type: boolean
          description: Whether this was a dry run (no data modified)

    IngestBatch:
      type: object
      properties:
        id:
          type: string
          format: uuid
        company_id:
          type: string
          format: uuid
        status:
          type: string
          enum: [completed, partial, failed]
        totals:
          type: object
          properties:
            received:
              type: integer
            accepted:
              type: integer
            rejected:
              type: integer
        errors:
          type: array
          items:
            type: object
        created_at:
          type: string
          format: date-time
        completed_at:
          type: string
          format: date-time
          nullable: true

    UploadUrlResponse:
      type: object
      properties:
        file_id:
          type: string
          format: uuid
          description: File ID — use this with /api/v1/files/{id}/status and /api/v1/files/{id}/process
        upload_url:
          type: string
          format: uri
          description: Signed URL to upload the file to via PUT
        path:
          type: string
          description: Storage path of the file
        method:
          type: string
          enum: [PUT]
          description: HTTP method to use for the upload
        headers:
          type: object
          properties:
            Content-Type:
              type: string
          description: Headers to include in the upload request
        expires_in:
          type: integer
          description: Seconds until the signed URL expires
          example: 3600

    FileUpload:
      type: object
      properties:
        id:
          type: string
          format: uuid
        company_id:
          type: string
          format: uuid
        file_name:
          type: string
        file_path:
          type: string
        status:
          type: string
          description: "Processing status: uploaded, processing, completed, failed"
        file_size:
          type: integer
        file_type:
          type: string
        upload_date:
          type: string
          format: date-time
        processed_date:
          type: string
          format: date-time
          nullable: true
        processing_started_at:
          type: string
          format: date-time
          nullable: true
        processing_completed_at:
          type: string
          format: date-time
          nullable: true
        records_processed:
          type: integer
        records_created:
          type: integer
        records_updated:
          type: integer
        records_failed:
          type: integer
        processing_errors:
          type: array
          nullable: true
          items:
            type: object
        processing_summary:
          type: object
          nullable: true

    ApiKeyRecord:
      type: object
      properties:
        id:
          type: string
          format: uuid
        company_id:
          type: string
          format: uuid
        name:
          type: string
        prefix:
          type: string
          description: First 8 characters of the key (for identification)
        status:
          type: string
          enum: [active, revoked]
        environment:
          type: string
          enum: [live, test]
        expires_at:
          type: string
          format: date-time
          nullable: true
        last_used_at:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time
