openapi: 3.0.3
info:
  title: FinXteamAI REST API
  description: |
    Mobile + Web REST API for FinXteamAI. This is the **single source of truth**
    for the mobile client; the Flutter app generates its typed client + DTOs
    from this file via `openapi-generator`.

    Conventions (apply to every operation):
    - Base URL is environment-specific; the active value is exposed via
      `GET /v1/config/client.api_base_url`.
    - Auth: `Authorization: Bearer <accessToken>` for every operation tagged
      `auth: bearer`. Refresh tokens are device-bound (see AUTH_FLOW.md).
    - Business context: routes that operate on a single business require
      `X-Business-Id` header. Multi-entity endpoints (e.g. `/v1/businesses`)
      do not.
    - Pagination: cursor-based. `cursor` query param + `next_cursor` in
      response, format `"<iso8601>|<uuid>"`.
    - Money: `{ amount: string, currency: ISO4217 }`. The amount is a
      decimal string to preserve precision.
    - Errors: `{ error: { code, message, field?, traceId? } }`.
    - Idempotency: write endpoints accept `Idempotency-Key` header. Replays
      with the same key + same body return the original response; same key
      + different body returns 409 `IDEMPOTENCY_KEY_REPLAYED_DIFFERENT_BODY`.

    For the full design rationale see `REST_API/MOBILE_API_GAPS.md`.
  version: 1.0.0
  contact:
    name: FinXteamAI Engineering
    email: engineering@finxteamai.com

servers:
  - url: https://api.finxteamai.com
    description: Production
  - url: https://api.staging.finxteamai.com
    description: Staging
  - url: http://localhost:2442
    description: Local

tags:
  - name: auth
    description: Signup, login, refresh, devices, biometrics, account deletion
  - name: onboarding
    description: Resumable onboarding draft + finalize
  - name: businesses
    description: Multi-entity (Business CRUD + members)
  - name: dashboard
    description: KPIs, cashflow, tiles, insights, alerts
  - name: transactions
    description: List, search, splits, bulk operations
  - name: receipts
    description: Upload, OCR, link to transactions
  - name: categories
    description: Global category catalog
  - name: rules
    description: Auto-categorization rules
  - name: plaid
    description: Bank connection lifecycle
  - name: integrations
    description: Shopify, Stripe, Square, PayPal, Gusto, QBO
  - name: reports
    description: P&L, tax, balance sheet, cashflow, deduction finder
  - name: export
    description: Async data exports
  - name: billing
    description: Stripe subscription + plan catalog
  - name: settings
    description: Profile, preferences, notifications, CPA invites
  - name: notifications
    description: In-app notification inbox
  - name: ai
    description: Conversational assistant + suggestions
  - name: support
    description: Tickets + Intercom identity
  - name: sync
    description: Offline-first sync push/pull
  - name: cross-cutting
    description: Version, config, feature flags, telemetry, uploads

# ============================================================================
# Reusable components
# ============================================================================
components:
  securitySchemes:
    bearer:
      type: http
      scheme: bearer
      bearerFormat: JWT

  parameters:
    BusinessId:
      name: X-Business-Id
      in: header
      required: true
      schema: { type: string, format: uuid }
    Cursor:
      name: cursor
      in: query
      schema: { type: string, description: 'Keyset cursor "<iso>|<uuid>"' }
    Limit:
      name: limit
      in: query
      schema: { type: integer, minimum: 1, maximum: 100, default: 30 }
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      schema: { type: string }

  schemas:
    Money:
      type: object
      required: [amount, currency]
      properties:
        amount: { type: string, example: "123.45" }
        currency: { type: string, example: "USD", minLength: 3, maxLength: 3 }

    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code: { type: string, example: "NOT_FOUND" }
            message: { type: string }
            field: { type: string }
            traceId: { type: string }

    CursorPage:
      type: object
      properties:
        next_cursor: { type: string, nullable: true }
        has_more: { type: boolean }

    # ---------- domain DTOs ----------
    User:
      type: object
      properties:
        id: { type: string, format: uuid }
        email: { type: string, format: email }
        full_name: { type: string }
        avatar_url: { type: string, nullable: true }
        timezone: { type: string }
        phone: { type: string, nullable: true }
        email_verified_at: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time }

    AuthTokens:
      type: object
      required: [access_token, refresh_token, expires_in]
      properties:
        access_token: { type: string }
        refresh_token: { type: string }
        expires_in: { type: integer, description: "Seconds until access_token expires" }
        token_type: { type: string, default: "Bearer" }

    LoginResponse:
      allOf:
        - $ref: '#/components/schemas/AuthTokens'
        - type: object
          properties:
            user: { $ref: '#/components/schemas/User' }
            requires_2fa: { type: boolean }

    Device:
      type: object
      properties:
        id: { type: string, format: uuid }
        platform: { type: string, enum: [ios, android, web] }
        device_name: { type: string }
        os_version: { type: string, nullable: true }
        app_version: { type: string, nullable: true }
        biometric_enabled: { type: boolean }
        biometric_type: { type: string, enum: [face_id, touch_id, fingerprint], nullable: true }
        last_seen_at: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time }

    Business:
      type: object
      properties:
        id: { type: string, format: uuid }
        legal_name: { type: string }
        dba_name: { type: string, nullable: true }
        business_type: { type: string }
        industry: { type: string }
        state: { type: string, minLength: 2, maxLength: 2 }
        fiscal_year_start: { type: string, format: date }
        accounting_method: { type: string, enum: [cash, accrual] }

    BusinessMember:
      type: object
      properties:
        user_id: { type: string, format: uuid }
        email: { type: string, format: email }
        full_name: { type: string }
        role: { type: string, enum: [owner, co_owner, viewer, cpa_viewer] }
        accepted_at: { type: string, format: date-time, nullable: true }
        revoked_at: { type: string, format: date-time, nullable: true }

    OnboardingDraft:
      type: object
      properties:
        step: { type: string, enum: [business, industry, fiscal_year, tax_profile, accounting_method, connect_bank, done] }
        data: { type: object, additionalProperties: true }
        updated_at: { type: string, format: date-time, nullable: true }

    OnboardingStatus:
      type: object
      properties:
        current_step: { type: string }
        completed_steps: { type: array, items: { type: string } }
        remaining_steps: { type: array, items: { type: string } }
        percent_complete: { type: integer, minimum: 0, maximum: 100 }
        updated_at: { type: string, format: date-time, nullable: true }

    Category:
      type: object
      properties:
        id: { type: string, format: uuid }
        code: { type: string }
        display_name: { type: string }
        is_expense: { type: boolean }
        is_excluded: { type: boolean }
        sort_order: { type: integer }

    Transaction:
      type: object
      properties:
        id: { type: string, format: uuid }
        business_id: { type: string, format: uuid }
        bank_account_id: { type: string, format: uuid, nullable: true }
        category_id: { type: string, format: uuid, nullable: true }
        amount: { $ref: '#/components/schemas/Money' }
        transaction_date: { type: string, format: date }
        raw_description: { type: string }
        cleaned_merchant_name: { type: string, nullable: true }
        review_status: { type: string, enum: [auto_approved, pending_review, user_confirmed, user_changed, excluded] }
        edge_case_type: { type: string, enum: [transfer, credit_card_payment, duplicate, refund, owner_draw, personal_expense, currency_mismatch], nullable: true }
        is_personal: { type: boolean }
        receipt_id: { type: string, format: uuid, nullable: true }

    TransactionList:
      allOf:
        - $ref: '#/components/schemas/CursorPage'
        - type: object
          properties:
            items:
              type: array
              items: { $ref: '#/components/schemas/Transaction' }

    TransactionSplit:
      type: object
      properties:
        id: { type: string, format: uuid }
        category_id: { type: string, format: uuid }
        amount: { $ref: '#/components/schemas/Money' }
        notes: { type: string, nullable: true }
        position: { type: integer }

    Receipt:
      type: object
      properties:
        id: { type: string, format: uuid }
        file_key: { type: string }
        mime_type: { type: string, nullable: true }
        size_bytes: { type: integer, nullable: true }
        ocr_status: { type: string, enum: [pending, processing, done, failed] }
        ocr_merchant: { type: string, nullable: true }
        ocr_total: { allOf: [{ $ref: '#/components/schemas/Money' }], nullable: true }
        ocr_date: { type: string, format: date, nullable: true }
        transaction_id: { type: string, format: uuid, nullable: true }
        created_at: { type: string, format: date-time }

    Rule:
      type: object
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        priority: { type: integer }
        enabled: { type: boolean }
        applied_count: { type: integer }
        condition:
          type: object
          properties:
            field: { type: string, enum: [raw_description, cleaned_merchant_name, amount, plaid_category] }
            op: { type: string, enum: [equals, contains, starts_with, regex, gt, lt, between] }
            value: {}
        action:
          type: object
          properties:
            type: { type: string, enum: [set_category, set_review_status, flag_personal, exclude_from_pnl] }
            category_id: { type: string, format: uuid }
            review_status: { type: string }

    PlaidItem:
      type: object
      properties:
        id: { type: string, format: uuid }
        institution_name: { type: string }
        institution_logo_url: { type: string, nullable: true }
        status: { type: string }
        last_successful_sync_at: { type: string, format: date-time, nullable: true }

    BankAccount:
      type: object
      properties:
        id: { type: string, format: uuid }
        plaid_item_id: { type: string, format: uuid, nullable: true }
        display_name: { type: string }
        account_type: { type: string, enum: [checking, savings, credit_card, loan, investment, other] }
        mask: { type: string, nullable: true }
        current_balance: { allOf: [{ $ref: '#/components/schemas/Money' }], nullable: true }

    IntegrationAccount:
      type: object
      properties:
        id: { type: string, format: uuid }
        provider: { type: string, enum: [shopify, stripe, square, paypal, gusto, qbo] }
        external_id: { type: string }
        label: { type: string }
        status: { type: string }
        last_sync_at: { type: string, format: date-time, nullable: true }
        last_error_at: { type: string, format: date-time, nullable: true }
        last_error_code: { type: string, nullable: true }
        next_scheduled_sync_at: { type: string, format: date-time, nullable: true }

    MeResponse:
      type: object
      properties:
        user:
          type: object
          properties:
            id: { type: string, format: uuid }
            email: { type: string, format: email }
            full_name: { type: string }
            timezone: { type: string }
            phone: { type: string, nullable: true }
            avatar_url: { type: string, nullable: true }
            email_verified_at: { type: string, format: date-time, nullable: true }
        business:
          allOf: [{ $ref: '#/components/schemas/Business' }]
          nullable: true

    DashboardSummary:
      type: object
      properties:
        period:
          type: object
          properties:
            start: { type: string, format: date-time }
            end: { type: string, format: date-time }
            label: { type: string, example: current_month }
        revenue: { $ref: '#/components/schemas/Money' }
        expenses: { $ref: '#/components/schemas/Money' }
        net_income: { $ref: '#/components/schemas/Money' }
        sales_tax_owed:
          allOf: [{ $ref: '#/components/schemas/Money' }]
          nullable: true
          description: Unpaid balance across open sales-tax liabilities.
        cash_on_hand: { $ref: '#/components/schemas/Money' }
        review_queue_count: { type: integer }
        accounts_connected: { type: integer }
        deltas:
          type: object
          description: Signed period-over-period change vs the prior month. Each field is null when there is no prior data.
          properties:
            revenue: { allOf: [{ $ref: '#/components/schemas/Money' }], nullable: true }
            expenses: { allOf: [{ $ref: '#/components/schemas/Money' }], nullable: true }
            net_income: { allOf: [{ $ref: '#/components/schemas/Money' }], nullable: true }
        revenue_sparkline:
          type: array
          description: Trailing 6-month revenue, oldest → newest, for the Home sparkline.
          items:
            type: object
            properties:
              month: { type: string, example: '2026-05' }
              revenue: { $ref: '#/components/schemas/Money' }

    DashboardCashflow:
      type: object
      properties:
        range: { type: string, enum: [30d, 90d, ytd, 1y] }
        series:
          type: array
          items:
            type: object
            properties:
              bucket: { type: string }
              inflow: { $ref: '#/components/schemas/Money' }
              outflow: { $ref: '#/components/schemas/Money' }
              net: { $ref: '#/components/schemas/Money' }

    DashboardTile:
      type: object
      properties:
        id: { type: string, format: uuid }
        type: { type: string }
        position: { type: integer }
        visible: { type: boolean }
        config: { type: object }

    Insight:
      type: object
      properties:
        id: { type: string, format: uuid }
        category: { type: string }
        estimated_savings: { $ref: '#/components/schemas/Money' }
        confidence: { type: string, enum: [green, yellow, red] }
        explanation: { type: string }
        status: { type: string, enum: [suggested, accepted, dismissed] }

    Alert:
      type: object
      properties:
        id: { type: string }
        kind: { type: string, enum: [bank_reconnect, edge_case_pending, usage_limit_warning] }
        severity: { type: string, enum: [info, warning, critical] }
        message: { type: string }
        deep_link: { type: string, nullable: true }

    ReportJob:
      type: object
      properties:
        id: { type: string, format: uuid }
        kind: { type: string, enum: [pnl_pdf, tax_package, balance_sheet, cashflow] }
        format: { type: string }
        status: { type: string, enum: [queued, running, done, failed] }
        progress: { type: integer }
        file_key: { type: string, nullable: true }
        error_message: { type: string, nullable: true }
        created_at: { type: string, format: date-time }
        finished_at: { type: string, format: date-time, nullable: true }

    ExportJob:
      allOf:
        - $ref: '#/components/schemas/ReportJob'
        - type: object
          properties:
            kind: { type: string, enum: [transactions_csv, receipts_zip, data_export] }

    Subscription:
      type: object
      properties:
        id: { type: string, format: uuid }
        plan: { type: string, enum: [trial, starter, pro, business] }
        status: { type: string, enum: [trialing, active, past_due, canceled, unpaid] }
        current_period_end: { type: string, format: date-time, nullable: true }
        trial_end: { type: string, format: date-time, nullable: true }
        canceled_at: { type: string, format: date-time, nullable: true }
        monthly_transaction_limit: { type: integer }
        monthly_transactions_used: { type: integer }

    NotificationInboxItem:
      type: object
      properties:
        id: { type: string, format: uuid }
        type: { type: string, enum: [receipt_matched, deduction_found, sync_error, billing_alert, review_queue, system] }
        title: { type: string }
        body: { type: string }
        deep_link: { type: string, nullable: true }
        data: { type: object, nullable: true }
        read_at: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time }

    AiConversation:
      type: object
      properties:
        id: { type: string, format: uuid }
        title: { type: string, nullable: true }
        preview: { type: string, nullable: true }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    AiMessage:
      type: object
      properties:
        id: { type: string, format: uuid }
        role: { type: string, enum: [user, assistant, system] }
        content: { type: string }
        sources: { type: object, nullable: true }
        created_at: { type: string, format: date-time }

    SupportTicket:
      type: object
      properties:
        id: { type: string, format: uuid }
        subject: { type: string }
        body: { type: string }
        category: { type: string, enum: [billing, bug, feature, account] }
        status: { type: string, enum: [open, pending, closed] }
        intercom_conversation_id: { type: string, nullable: true }
        created_at: { type: string, format: date-time }

    SyncPullDelta:
      type: object
      properties:
        since: { type: string, format: date-time }
        now: { type: string, format: date-time }
        transactions:
          type: array
          items: { $ref: '#/components/schemas/Transaction' }
        receipts:
          type: array
          items: { $ref: '#/components/schemas/Receipt' }
        categories:
          type: array
          items: { $ref: '#/components/schemas/Category' }
        deletions:
          type: array
          items:
            type: object
            properties:
              entity: { type: string }
              id: { type: string, format: uuid }

    SyncPushBatch:
      type: object
      required: [ops]
      properties:
        ops:
          type: array
          items:
            type: object
            required: [op_id, type, payload]
            properties:
              op_id: { type: string, format: uuid }
              type: { type: string, enum: [transaction.update, transaction.split, receipt.create, receipt.link, rule.create] }
              payload: { type: object }

    SyncPushResult:
      type: object
      properties:
        results:
          type: array
          items:
            type: object
            properties:
              op_id: { type: string, format: uuid }
              status: { type: string, enum: [applied, conflict, replayed, rejected] }
              error: { type: string, nullable: true }

  responses:
    Unauthorized:
      description: Missing or invalid bearer token
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Forbidden:
      description: Caller cannot access this resource
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    NotFound:
      description: Resource not found
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    BadRequest:
      description: Validation failed
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }

security:
  - bearer: []

# ============================================================================
# Paths
# ============================================================================
paths:
  # ---------- §1 Auth ----------
  /v1/auth/signup:
    post:
      tags: [auth]
      security: []
      summary: Create a user account
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password, full_name]
              properties:
                email: { type: string, format: email }
                password: { type: string, minLength: 8 }
                full_name: { type: string }
      responses:
        '201':
          description: User created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LoginResponse' }
        '400': { $ref: '#/components/responses/BadRequest' }

  /v1/auth/login:
    post:
      tags: [auth]
      security: []
      summary: Email/password login
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password]
              properties:
                email: { type: string, format: email }
                password: { type: string }
      responses:
        '200':
          description: Logged in
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LoginResponse' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/auth/verify-2fa:
    post:
      tags: [auth]
      security: []
      summary: Submit a TOTP code after login
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [pending_token, code]
              properties:
                pending_token: { type: string }
                code: { type: string, minLength: 6, maxLength: 6 }
      responses:
        '200':
          description: 2FA verified
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LoginResponse' }

  /v1/auth/refresh:
    post:
      tags: [auth]
      security: []
      summary: Refresh tokens (web cookie path)
      responses:
        '200':
          description: Refreshed
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AuthTokens' }

  /v1/auth/refresh-mobile:
    post:
      tags: [auth]
      security: []
      summary: Refresh tokens via JSON body (mobile)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [refresh_token]
              properties:
                refresh_token: { type: string }
      responses:
        '200':
          description: Refreshed
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AuthTokens' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/auth/logout:
    post:
      tags: [auth]
      summary: Revoke the current refresh token
      responses:
        '200':
          description: Logged out

  /v1/auth/logout-all:
    post:
      tags: [auth]
      summary: Revoke ALL refresh tokens for the user
      responses:
        '200':
          description: All sessions revoked

  /v1/auth/forgot-password:
    post:
      tags: [auth]
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email }
      responses:
        '200':
          description: If the email exists, a reset link has been sent

  /v1/auth/reset-password:
    post:
      tags: [auth]
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token, new_password]
              properties:
                token: { type: string }
                new_password: { type: string, minLength: 8 }
      responses:
        '200':
          description: Password reset

  /v1/auth/change-password:
    post:
      tags: [auth]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [current_password, new_password]
              properties:
                current_password: { type: string }
                new_password: { type: string, minLength: 8 }
      responses:
        '200':
          description: Password changed

  /v1/auth/2fa/enroll:
    post:
      tags: [auth]
      responses:
        '200':
          description: TOTP secret + QR provisioning URI

  /v1/auth/2fa/disable:
    post:
      tags: [auth]
      responses:
        '200':
          description: 2FA disabled

  /v1/auth/me:
    get:
      tags: [auth]
      responses:
        '200':
          description: Current user
          content:
            application/json:
              schema: { $ref: '#/components/schemas/User' }

  /v1/auth/security:
    get:
      tags: [auth]
      responses:
        '200':
          description: Security posture (2FA, sessions, recent logins)

  /v1/auth/google:
    post:
      tags: [auth]
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [id_token]
              properties:
                id_token: { type: string }
      responses:
        '200':
          description: Logged in
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LoginResponse' }

  /v1/auth/biometric/enable:
    post:
      tags: [auth]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [device_id, biometric_type]
              properties:
                device_id: { type: string, format: uuid }
                biometric_type: { type: string, enum: [face_id, touch_id, fingerprint] }
      responses:
        '200':
          description: Biometric unlock enabled for the device

  /v1/auth/verify-email:
    post:
      tags: [auth]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token]
              properties:
                token: { type: string }
      responses:
        '200':
          description: Email verified

  /v1/auth/device/register:
    post:
      tags: [auth]
      summary: Register a mobile device + bind a refresh token
      description: |
        The mobile client calls this immediately after successful login.
        Returns a device-bound refresh token; logging out the device revokes
        only that single token. Idempotent on (user, fingerprint).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [fingerprint, platform, device_name]
              properties:
                fingerprint: { type: string }
                platform: { type: string, enum: [ios, android] }
                device_name: { type: string }
                os_version: { type: string }
                app_version: { type: string }
                push_token: { type: string }
                biometric_enabled: { type: boolean }
                biometric_type: { type: string, enum: [face_id, touch_id, fingerprint], nullable: true }
      responses:
        '201':
          description: Device registered
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Device' }

  /v1/auth/devices:
    get:
      tags: [auth]
      responses:
        '200':
          description: List of devices for the current user
          content:
            application/json:
              schema:
                type: object
                properties:
                  devices:
                    type: array
                    items: { $ref: '#/components/schemas/Device' }

  /v1/auth/devices/{id}:
    delete:
      tags: [auth]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        '200':
          description: Device revoked

  /v1/auth/account-deletion:
    post:
      tags: [auth]
      summary: Request account deletion (30-day grace)
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                reason: { type: string }
                feedback: { type: string }
      responses:
        '202':
          description: Deletion scheduled
    get:
      tags: [auth]
      responses:
        '200':
          description: Current deletion request state
    delete:
      tags: [auth]
      summary: Cancel a pending deletion
      responses:
        '200':
          description: Cancelled

  /v1/auth/account/data-export:
    post:
      tags: [auth]
      summary: Enqueue a full data export (GDPR/portability)
      responses:
        '202':
          description: Export job created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ExportJob' }

  # ---------- §2 Onboarding ----------
  /v1/onboarding/status:
    get:
      tags: [onboarding]
      responses:
        '200':
          description: Resumable onboarding status
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OnboardingStatus' }

  /v1/onboarding/draft:
    get:
      tags: [onboarding]
      responses:
        '200':
          description: Current draft
          content:
            application/json:
              schema:
                type: object
                properties:
                  draft: { $ref: '#/components/schemas/OnboardingDraft' }
    put:
      tags: [onboarding]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [step, data]
              properties:
                step: { type: string }
                data: { type: object }
      responses:
        '200':
          description: Saved
          content:
            application/json:
              schema:
                type: object
                properties:
                  draft: { $ref: '#/components/schemas/OnboardingDraft' }

  /v1/onboarding/step/business:
    post:
      tags: [onboarding]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [legal_name, business_type, industry, state]
              properties:
                legal_name: { type: string }
                dba_name: { type: string }
                business_type: { type: string }
                industry: { type: string }
                state: { type: string }
                fiscal_year_start: { type: string }
      responses:
        '200':
          description: Step persisted

  /v1/onboarding/step/tax-profile:
    post:
      tags: [onboarding]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [filing_status, state, estimated_income]
              properties:
                filing_status: { type: string, enum: [single, married_joint, married_separate, head_of_household] }
                state: { type: string }
                estimated_income: { $ref: '#/components/schemas/Money' }
                has_dependents: { type: boolean }
      responses:
        '200':
          description: Step persisted

  /v1/onboarding/step/accounting-method:
    post:
      tags: [onboarding]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [method]
              properties:
                method: { type: string, enum: [cash, accrual] }
      responses:
        '200':
          description: Step persisted

  /v1/onboarding/finalize:
    post:
      tags: [onboarding]
      responses:
        '201':
          description: Business + Subscription created
          content:
            application/json:
              schema:
                type: object
                properties:
                  business: { $ref: '#/components/schemas/Business' }

  /v1/onboarding/plaid/link-token:
    post:
      tags: [onboarding]
      responses:
        '200':
          description: Plaid Link token

  /v1/onboarding/plaid/exchange:
    post:
      tags: [onboarding]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [public_token]
              properties:
                public_token: { type: string }
                institution_id: { type: string }
                institution_name: { type: string }
      responses:
        '200':
          description: Plaid item linked

  /v1/onboarding/sync-status:
    get:
      tags: [onboarding]
      responses:
        '200':
          description: Sync progress polled by /processing screen

  # ---------- §3 Businesses ----------
  /v1/businesses:
    get:
      tags: [businesses]
      responses:
        '200':
          description: List of businesses the caller belongs to
          content:
            application/json:
              schema:
                type: object
                properties:
                  businesses:
                    type: array
                    items: { $ref: '#/components/schemas/Business' }
    post:
      tags: [businesses]
      parameters: [{ $ref: '#/components/parameters/IdempotencyKey' }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/Business' }
      responses:
        '201': { description: Created, content: { application/json: { schema: { $ref: '#/components/schemas/Business' } } } }

  /v1/businesses/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      tags: [businesses]
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Business' } } } }
    patch:
      tags: [businesses]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/Business' }
      responses:
        '200': { description: Updated }
    delete:
      tags: [businesses]
      responses:
        '200': { description: Soft-deleted }

  /v1/businesses/{id}/switch:
    post:
      tags: [businesses]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        '200':
          description: Active business switched

  /v1/businesses/{id}/members:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      tags: [businesses]
      responses:
        '200':
          description: Members
          content:
            application/json:
              schema:
                type: object
                properties:
                  members:
                    type: array
                    items: { $ref: '#/components/schemas/BusinessMember' }

  /v1/businesses/{id}/members/invite:
    post:
      tags: [businesses]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
        - { $ref: '#/components/parameters/IdempotencyKey' }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, role]
              properties:
                email: { type: string, format: email }
                role: { type: string, enum: [owner, co_owner, viewer, cpa_viewer] }
      responses:
        '201': { description: Invited }

  /v1/businesses/{id}/members/{userId}/role:
    patch:
      tags: [businesses]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
        - { name: userId, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [role]
              properties:
                role: { type: string }
      responses:
        '200': { description: Role updated }

  /v1/businesses/{id}/members/{userId}:
    delete:
      tags: [businesses]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
        - { name: userId, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        '200': { description: Removed }

  # ---------- §4 Dashboard ----------
  /v1/dashboard/summary:
    get:
      tags: [dashboard]
      parameters: [{ $ref: '#/components/parameters/BusinessId' }]
      responses:
        '200':
          description: KPIs
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DashboardSummary' }

  /v1/dashboard/cashflow:
    get:
      tags: [dashboard]
      parameters:
        - { $ref: '#/components/parameters/BusinessId' }
        - { name: range, in: query, schema: { type: string, enum: [30d, 90d, ytd, 1y] } }
      responses:
        '200':
          description: Cashflow series
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DashboardCashflow' }

  /v1/dashboard/tiles:
    get:
      tags: [dashboard]
      parameters: [{ $ref: '#/components/parameters/BusinessId' }]
      responses:
        '200':
          description: Configured dashboard tiles
          content:
            application/json:
              schema:
                type: object
                properties:
                  tiles:
                    type: array
                    items: { $ref: '#/components/schemas/DashboardTile' }

  /v1/dashboard/tiles/reorder:
    post:
      tags: [dashboard]
      parameters: [{ $ref: '#/components/parameters/BusinessId' }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [tiles]
              properties:
                tiles:
                  type: array
                  items:
                    type: object
                    properties:
                      type: { type: string }
                      position: { type: integer }
                      visible: { type: boolean }
                      config: { type: object }
      responses:
        '200': { description: Reordered }

  /v1/dashboard/insights:
    get:
      tags: [dashboard]
      parameters: [{ $ref: '#/components/parameters/BusinessId' }]
      responses:
        '200':
          description: Insights
          content:
            application/json:
              schema:
                type: object
                properties:
                  insights:
                    type: array
                    items: { $ref: '#/components/schemas/Insight' }

  /v1/dashboard/alerts:
    get:
      tags: [dashboard]
      parameters: [{ $ref: '#/components/parameters/BusinessId' }]
      responses:
        '200':
          description: Active alerts
          content:
            application/json:
              schema:
                type: object
                properties:
                  alerts:
                    type: array
                    items: { $ref: '#/components/schemas/Alert' }

  # ---------- §5 Transactions ----------
  /v1/transactions:
    get:
      tags: [transactions]
      parameters:
        - { $ref: '#/components/parameters/BusinessId' }
        - { $ref: '#/components/parameters/Cursor' }
        - { $ref: '#/components/parameters/Limit' }
        - { name: review_status, in: query, schema: { type: string } }
        - { name: category_id, in: query, schema: { type: string, format: uuid } }
        - { name: from, in: query, schema: { type: string, format: date } }
        - { name: to, in: query, schema: { type: string, format: date } }
      responses:
        '200':
          description: Cursor page of transactions
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TransactionList' }

  /v1/transactions/review-queue:
    get:
      tags: [transactions]
      parameters: [{ $ref: '#/components/parameters/BusinessId' }]
      responses:
        '200':
          description: Pending review items
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TransactionList' }

  /v1/transactions/search:
    get:
      tags: [transactions]
      parameters:
        - { $ref: '#/components/parameters/BusinessId' }
        - { name: q, in: query, schema: { type: string } }
        - { name: from, in: query, schema: { type: string, format: date } }
        - { name: to, in: query, schema: { type: string, format: date } }
        - { name: min, in: query, schema: { type: number } }
        - { name: max, in: query, schema: { type: number } }
        - { name: category_id, in: query, schema: { type: string, format: uuid } }
      responses:
        '200':
          description: Search results
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TransactionList' }

  /v1/transactions/duplicates:
    get:
      tags: [transactions]
      parameters: [{ $ref: '#/components/parameters/BusinessId' }]
      responses:
        '200':
          description: Suspected duplicates

  /v1/transactions/bulk-categorize:
    post:
      tags: [transactions]
      parameters:
        - { $ref: '#/components/parameters/BusinessId' }
        - { $ref: '#/components/parameters/IdempotencyKey' }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [transaction_ids, category_id]
              properties:
                transaction_ids:
                  type: array
                  items: { type: string, format: uuid }
                category_id: { type: string, format: uuid }
      responses:
        '200':
          description: Per-id result list

  /v1/transactions/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      tags: [transactions]
      parameters: [{ $ref: '#/components/parameters/BusinessId' }]
      responses:
        '200':
          description: One transaction
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Transaction' }

  /v1/transactions/{id}/confirm:
    put:
      tags: [transactions]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
        - { $ref: '#/components/parameters/BusinessId' }
      responses:
        '200': { description: Confirmed }

  /v1/transactions/{id}/change:
    put:
      tags: [transactions]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
        - { $ref: '#/components/parameters/BusinessId' }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                category_id: { type: string, format: uuid }
                review_status: { type: string }
      responses:
        '200': { description: Updated }

  /v1/transactions/{id}/flag-personal:
    put:
      tags: [transactions]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
        - { $ref: '#/components/parameters/BusinessId' }
      responses:
        '200': { description: Flagged personal }

  /v1/transactions/{id}/match-receipt:
    post:
      tags: [transactions]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
        - { $ref: '#/components/parameters/BusinessId' }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [receipt_id]
              properties:
                receipt_id: { type: string, format: uuid }
      responses:
        '200': { description: Linked }

  /v1/transactions/{id}/splits:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - { $ref: '#/components/parameters/BusinessId' }
    get:
      tags: [transactions]
      responses:
        '200':
          description: Splits for this transaction
          content:
            application/json:
              schema:
                type: object
                properties:
                  splits:
                    type: array
                    items: { $ref: '#/components/schemas/TransactionSplit' }
    post:
      tags: [transactions]
      parameters: [{ $ref: '#/components/parameters/IdempotencyKey' }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [splits]
              properties:
                splits:
                  type: array
                  items: { $ref: '#/components/schemas/TransactionSplit' }
      responses:
        '201': { description: Split saved }
    delete:
      tags: [transactions]
      responses:
        '200': { description: Splits cleared }

  # ---------- §6 Receipts ----------
  /v1/receipts:
    get:
      tags: [receipts]
      parameters:
        - { $ref: '#/components/parameters/BusinessId' }
        - { $ref: '#/components/parameters/Cursor' }
        - { $ref: '#/components/parameters/Limit' }
        - { name: unmatched, in: query, schema: { type: boolean } }
      responses:
        '200':
          description: Receipts page
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/CursorPage'
                  - type: object
                    properties:
                      items:
                        type: array
                        items: { $ref: '#/components/schemas/Receipt' }
    post:
      tags: [receipts]
      parameters:
        - { $ref: '#/components/parameters/BusinessId' }
        - { $ref: '#/components/parameters/IdempotencyKey' }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [file_key]
              properties:
                file_key: { type: string }
                mime_type: { type: string }
                size_bytes: { type: integer }
          multipart/form-data:
            schema:
              type: object
              properties:
                file: { type: string, format: binary }
      responses:
        '201':
          description: Receipt created (OCR enqueued)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Receipt' }

  /v1/receipts/bulk-upload:
    post:
      tags: [receipts]
      parameters: [{ $ref: '#/components/parameters/BusinessId' }, { $ref: '#/components/parameters/IdempotencyKey' }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [files]
              properties:
                files:
                  type: array
                  items:
                    type: object
                    properties:
                      file_key: { type: string }
                      mime_type: { type: string }
                      size_bytes: { type: integer }
      responses:
        '201':
          description: Bulk receipts created

  /v1/receipts/ocr-status/{jobId}:
    get:
      tags: [receipts]
      parameters:
        - { name: jobId, in: path, required: true, schema: { type: string } }
        - { $ref: '#/components/parameters/BusinessId' }
      responses:
        '200':
          description: OCR job status

  /v1/receipts/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - { $ref: '#/components/parameters/BusinessId' }
    get:
      tags: [receipts]
      responses:
        '200': { description: Receipt, content: { application/json: { schema: { $ref: '#/components/schemas/Receipt' } } } }
    patch:
      tags: [receipts]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                ocr_merchant: { type: string }
                ocr_total: { $ref: '#/components/schemas/Money' }
                ocr_date: { type: string, format: date }
      responses:
        '200': { description: Updated }
    delete:
      tags: [receipts]
      responses:
        '200': { description: Soft-deleted }

  /v1/receipts/{id}/link:
    put:
      tags: [receipts]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
        - { $ref: '#/components/parameters/BusinessId' }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [transaction_id]
              properties:
                transaction_id: { type: string, format: uuid }
      responses:
        '200': { description: Linked }

  /v1/receipts/{id}/match-transaction:
    post:
      tags: [receipts]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
        - { $ref: '#/components/parameters/BusinessId' }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [transaction_id]
              properties:
                transaction_id: { type: string, format: uuid }
      responses:
        '200': { description: Linked }

  /v1/receipts/{id}/ocr-retry:
    post:
      tags: [receipts]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
        - { $ref: '#/components/parameters/BusinessId' }
      responses:
        '202': { description: OCR re-enqueued }

  # ---------- §7 Categories + Rules ----------
  /v1/categories:
    get:
      tags: [categories]
      responses:
        '200':
          description: Global category catalog
          content:
            application/json:
              schema:
                type: object
                properties:
                  categories:
                    type: array
                    items: { $ref: '#/components/schemas/Category' }
    post:
      tags: [categories]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/Category' }
      responses:
        '201': { description: Created }

  /v1/categories/{id}:
    patch:
      tags: [categories]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/Category' }
      responses:
        '200': { description: Updated }

  /v1/rules:
    get:
      tags: [rules]
      parameters: [{ $ref: '#/components/parameters/BusinessId' }]
      responses:
        '200':
          description: Rules
          content:
            application/json:
              schema:
                type: object
                properties:
                  rules:
                    type: array
                    items: { $ref: '#/components/schemas/Rule' }
    post:
      tags: [rules]
      parameters: [{ $ref: '#/components/parameters/BusinessId' }, { $ref: '#/components/parameters/IdempotencyKey' }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/Rule' }
      responses:
        '201': { description: Created or updated }

  /v1/rules/{id}:
    patch:
      tags: [rules]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
        - { $ref: '#/components/parameters/BusinessId' }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/Rule' }
      responses:
        '200': { description: Updated }
    delete:
      tags: [rules]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
        - { $ref: '#/components/parameters/BusinessId' }
      responses:
        '200': { description: Deleted }

  /v1/rules/preview:
    post:
      tags: [rules]
      parameters: [{ $ref: '#/components/parameters/BusinessId' }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/Rule' }
      responses:
        '200':
          description: Dry-run match count + sample

  # ---------- §8 Plaid ----------
  /v1/plaid/link-token:
    post:
      tags: [plaid]
      parameters: [{ $ref: '#/components/parameters/BusinessId' }]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                item_id: { type: string, format: uuid }
                platform: { type: string, enum: [ios, android, web] }
      responses:
        '200':
          description: Plaid Link token

  /v1/plaid/items:
    get:
      tags: [plaid]
      parameters: [{ $ref: '#/components/parameters/BusinessId' }]
      responses:
        '200':
          description: Linked items
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: '#/components/schemas/PlaidItem' }

  /v1/plaid/items/{id}/resync:
    post:
      tags: [plaid]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
        - { $ref: '#/components/parameters/BusinessId' }
      responses:
        '202': { description: Resync queued }

  /v1/plaid/items/{id}/relink:
    post:
      tags: [plaid]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
        - { $ref: '#/components/parameters/BusinessId' }
      responses:
        '200':
          description: Link-update token

  /v1/plaid/accounts:
    get:
      tags: [plaid]
      parameters: [{ $ref: '#/components/parameters/BusinessId' }]
      responses:
        '200':
          description: Bank accounts
          content:
            application/json:
              schema:
                type: object
                properties:
                  accounts:
                    type: array
                    items: { $ref: '#/components/schemas/BankAccount' }

  /v1/plaid/accounts/{id}:
    delete:
      tags: [plaid]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
        - { $ref: '#/components/parameters/BusinessId' }
      responses:
        '200': { description: Removed }

  /v1/plaid/health:
    get:
      tags: [plaid]
      parameters: [{ $ref: '#/components/parameters/BusinessId' }]
      responses:
        '200':
          description: Aggregated link health

  # ---------- §9 Integrations ----------
  /v1/integrations:
    get:
      tags: [integrations]
      parameters: [{ $ref: '#/components/parameters/BusinessId' }]
      responses:
        '200':
          description: Connected integrations
          content:
            application/json:
              schema:
                type: object
                properties:
                  integrations:
                    type: array
                    items: { $ref: '#/components/schemas/IntegrationAccount' }

  /v1/integrations/{type}/connect:
    post:
      tags: [integrations]
      parameters:
        - { name: type, in: path, required: true, schema: { type: string, enum: [shopify, stripe, square, paypal, gusto, qbo] } }
        - { $ref: '#/components/parameters/BusinessId' }
      responses:
        '200':
          description: Returns OAuth auth_url + state

  /v1/integrations/{type}/callback:
    post:
      tags: [integrations]
      parameters:
        - { name: type, in: path, required: true, schema: { type: string } }
        - { $ref: '#/components/parameters/BusinessId' }
        - { $ref: '#/components/parameters/IdempotencyKey' }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [code, state]
              properties:
                code: { type: string }
                state: { type: string }
      responses:
        '200':
          description: Account upserted

  /v1/integrations/{id}/sync:
    post:
      tags: [integrations]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
        - { $ref: '#/components/parameters/BusinessId' }
      responses:
        '202': { description: Sync queued }

  /v1/integrations/{id}:
    delete:
      tags: [integrations]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
        - { $ref: '#/components/parameters/BusinessId' }
      responses:
        '200': { description: Removed }

  /v1/integrations/{id}/logs:
    get:
      tags: [integrations]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
        - { $ref: '#/components/parameters/BusinessId' }
      responses:
        '200': { description: Sync logs }

  /v1/integrations/{id}/status:
    get:
      tags: [integrations]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
        - { $ref: '#/components/parameters/BusinessId' }
      responses:
        '200': { description: Status }

  # ---------- §10 Reports ----------
  /v1/reports/pnl:
    get:
      tags: [reports]
      deprecated: true
      summary: "DEPRECATED — use /v1/reports/ledger-pnl. 301 redirect; emits telemetry."
      parameters:
        - { $ref: '#/components/parameters/BusinessId' }
        - { name: start, in: query, required: true, schema: { type: string, format: date } }
        - { name: end, in: query, required: true, schema: { type: string, format: date } }
      responses:
        '301': { description: Redirect to /v1/reports/ledger-pnl }

  /v1/reports/ledger-pnl:
    get:
      tags: [reports]
      summary: "Profit & Loss (canonical). Accrual default; ?method=cash for bank-tx aggregation."
      parameters:
        - { $ref: '#/components/parameters/BusinessId' }
        - { name: start,   in: query, schema: { type: string, format: date }, description: "Explicit window start. Wins over period shorthand." }
        - { name: end,     in: query, schema: { type: string, format: date }, description: "Explicit window end." }
        - { name: period,  in: query, schema: { type: string, enum: [monthly, quarterly, annual, current_month, last_month, current_quarter, last_quarter, ytd, mtd, last_year] }, description: "Period shorthand. Required when start/end omitted." }
        - { name: year,    in: query, schema: { type: integer }, description: "Required for monthly/quarterly/annual." }
        - { name: month,   in: query, schema: { type: integer, minimum: 1, maximum: 12 }, description: "Required when period=monthly." }
        - { name: quarter, in: query, schema: { type: integer, minimum: 1, maximum: 4 }, description: "Required when period=quarterly." }
        - { name: method,  in: query, schema: { type: string, enum: [cash, accrual], default: accrual }, description: "Cash basis re-aggregates from Transaction; accrual reads JournalLine." }
        - { name: fresh,   in: query, schema: { type: boolean, default: false }, description: "Skip the PnlSnapshot cache for closed periods." }
      responses:
        '200':
          description: P&L statement (Money-wrapped totals + EBITDA breakouts)
          content:
            application/json:
              schema:
                type: object
                properties:
                  method: { type: string, enum: [cash, accrual] }
                  start: { type: string, format: date-time }
                  end:   { type: string, format: date-time }
                  revenue:
                    type: array
                    items:
                      type: object
                      properties:
                        account_id:   { type: string, format: uuid, nullable: true }
                        number:       { type: string }
                        name:         { type: string }
                        subtype:      { type: string }
                        amount_cents: { type: integer }
                        balance:      { $ref: '#/components/schemas/Money' }
                  cogs:    { type: array, items: { type: object } }
                  expense: { type: array, items: { type: object } }
                  revenue_total:  { $ref: '#/components/schemas/Money' }
                  cogs_total:     { $ref: '#/components/schemas/Money' }
                  gross_profit:   { $ref: '#/components/schemas/Money' }
                  expense_total:  { $ref: '#/components/schemas/Money' }
                  tax_total:      { $ref: '#/components/schemas/Money' }
                  interest_total: { $ref: '#/components/schemas/Money' }
                  dna_total:      { $ref: '#/components/schemas/Money', description: "Depreciation + Amortization." }
                  ebitda:         { $ref: '#/components/schemas/Money', description: "Net Income + Tax + Interest + D&A." }
                  net_income:     { $ref: '#/components/schemas/Money' }
                  row_count:      { type: integer }
                  cached:         { type: boolean, description: "True when served from PnlSnapshot." }
                  computed_at:    { type: string, format: date-time, nullable: true }

  /v1/reports/ledger-pnl/account/{accountId}/lines:
    get:
      tags: [reports]
      summary: "Drill-down — list posted JournalLine rows for one account in a period (cap 1000)."
      parameters:
        - { $ref: '#/components/parameters/BusinessId' }
        - { name: accountId, in: path,  required: true, schema: { type: string, format: uuid } }
        - { name: start,     in: query, required: true, schema: { type: string, format: date } }
        - { name: end,       in: query, required: true, schema: { type: string, format: date } }
      responses:
        '200':
          description: Per-line drill-down for one ledger account
          content:
            application/json:
              schema:
                type: object
                properties:
                  account:
                    type: object
                    properties:
                      id:      { type: string, format: uuid }
                      number:  { type: string }
                      name:    { type: string }
                      type:    { type: string }
                      subtype: { type: string }
                  start:     { type: string, format: date-time }
                  end:       { type: string, format: date-time }
                  total:     { $ref: '#/components/schemas/Money' }
                  truncated: { type: boolean }
                  lines:
                    type: array
                    items:
                      type: object
                      properties:
                        line_id:      { type: string, format: uuid }
                        entry_id:     { type: string, format: uuid }
                        entry_number: { type: string }
                        entry_date:   { type: string, format: date }
                        description:  { type: string, nullable: true }
                        source:       { type: string }
                        source_id:    { type: string, nullable: true }
                        debit:        { $ref: '#/components/schemas/Money' }
                        credit:       { $ref: '#/components/schemas/Money' }
                        amount:       { $ref: '#/components/schemas/Money', description: "Signed against the account's normal side." }
        '404': { description: Account not found in this business }

  /v1/reports/tax-estimate:
    get:
      tags: [reports]
      parameters:
        - { $ref: '#/components/parameters/BusinessId' }
        - { name: year, in: query, required: true, schema: { type: integer } }
        - { name: quarter, in: query, required: true, schema: { type: integer, minimum: 1, maximum: 4 } }
      responses:
        '200': { description: Tax estimate }

  /v1/reports/health:
    get:
      tags: [reports]
      parameters: [{ $ref: '#/components/parameters/BusinessId' }]
      responses:
        '200': { description: Health score }

  /v1/reports/balance-sheet:
    get:
      tags: [reports]
      parameters:
        - { $ref: '#/components/parameters/BusinessId' }
        - { name: as_of, in: query, schema: { type: string, format: date } }
      responses:
        '200': { description: Snapshot balance sheet }

  /v1/reports/cashflow:
    get:
      tags: [reports]
      parameters:
        - { $ref: '#/components/parameters/BusinessId' }
        - { name: start, in: query, required: true, schema: { type: string, format: date } }
        - { name: end, in: query, required: true, schema: { type: string, format: date } }
      responses:
        '200': { description: Cashflow }

  /v1/reports/deduction-finder:
    get:
      tags: [reports]
      parameters:
        - { $ref: '#/components/parameters/BusinessId' }
        - { name: status, in: query, schema: { type: string, enum: [suggested, accepted, dismissed] } }
      responses:
        '200':
          description: Cached deduction suggestions
          content:
            application/json:
              schema:
                type: object
                properties:
                  total_estimated_savings: { $ref: '#/components/schemas/Money' }
                  suggestions:
                    type: array
                    items: { $ref: '#/components/schemas/Insight' }

  /v1/reports/generate:
    post:
      tags: [reports]
      parameters: [{ $ref: '#/components/parameters/BusinessId' }, { $ref: '#/components/parameters/IdempotencyKey' }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [kind]
              properties:
                kind: { type: string, enum: [pnl_pdf, tax_package, balance_sheet, cashflow] }
                params: { type: object }
                format: { type: string, enum: [pdf, xlsx, csv] }
      responses:
        '202':
          description: Job enqueued
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ReportJob' }

  /v1/reports/jobs/{id}:
    get:
      tags: [reports]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
        - { $ref: '#/components/parameters/BusinessId' }
      responses:
        '200': { description: Job status, content: { application/json: { schema: { $ref: '#/components/schemas/ReportJob' } } } }

  # ---------- §11 Export ----------
  /v1/export/tax-package:
    post:
      tags: [export]
      parameters: [{ $ref: '#/components/parameters/BusinessId' }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [year]
              properties:
                year: { type: integer }
                format: { type: string, enum: [pdf, excel, both] }
      responses:
        '200': { description: Tax package }

  /v1/export/transactions:
    post:
      tags: [export]
      parameters: [{ $ref: '#/components/parameters/BusinessId' }, { $ref: '#/components/parameters/IdempotencyKey' }]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                start: { type: string, format: date }
                end: { type: string, format: date }
                category_id: { type: string, format: uuid }
                review_status: { type: string }
                format: { type: string, enum: [csv, xlsx] }
      responses:
        '202':
          description: Job enqueued
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ExportJob' }

  /v1/export/receipts-zip:
    post:
      tags: [export]
      parameters: [{ $ref: '#/components/parameters/BusinessId' }, { $ref: '#/components/parameters/IdempotencyKey' }]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                start: { type: string, format: date }
                end: { type: string, format: date }
                include_unmatched: { type: boolean }
      responses:
        '202':
          description: Job enqueued
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ExportJob' }

  /v1/export/jobs/{id}:
    get:
      tags: [export]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
        - { $ref: '#/components/parameters/BusinessId' }
      responses:
        '200': { description: Job, content: { application/json: { schema: { $ref: '#/components/schemas/ExportJob' } } } }

  /v1/export/jobs/{id}/download:
    get:
      tags: [export]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
        - { $ref: '#/components/parameters/BusinessId' }
      responses:
        '200':
          description: Presigned download URL (15-min TTL)
          content:
            application/json:
              schema:
                type: object
                properties:
                  file_key: { type: string }
                  download_url: { type: string }
                  expires_in: { type: integer }

  # ---------- §12 Billing ----------
  /v1/billing/plans:
    get:
      tags: [billing]
      responses:
        '200':
          description: Plan catalog

  /v1/billing/subscription:
    get:
      tags: [billing]
      parameters: [{ $ref: '#/components/parameters/BusinessId' }]
      responses:
        '200':
          description: Subscription
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Subscription' }

  /v1/billing/checkout:
    post:
      tags: [billing]
      parameters: [{ $ref: '#/components/parameters/BusinessId' }, { $ref: '#/components/parameters/IdempotencyKey' }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [plan]
              properties:
                plan: { type: string, enum: [starter, pro, business] }
                success_url: { type: string }
                cancel_url: { type: string }
      responses:
        '200':
          description: Stripe Checkout URL

  /v1/billing/update-payment-method:
    post:
      tags: [billing]
      parameters: [{ $ref: '#/components/parameters/BusinessId' }, { $ref: '#/components/parameters/IdempotencyKey' }]
      responses:
        '200':
          description: Stripe portal URL

  /v1/billing/cancel:
    post:
      tags: [billing]
      parameters: [{ $ref: '#/components/parameters/BusinessId' }, { $ref: '#/components/parameters/IdempotencyKey' }]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                reason: { type: string }
                feedback: { type: string }
      responses:
        '200':
          description: Cancellation scheduled at period end

  /v1/billing/reactivate:
    post:
      tags: [billing]
      parameters: [{ $ref: '#/components/parameters/BusinessId' }, { $ref: '#/components/parameters/IdempotencyKey' }]
      responses:
        '200':
          description: Reactivated

  /v1/billing/invoices:
    get:
      tags: [billing]
      parameters: [{ $ref: '#/components/parameters/BusinessId' }]
      responses:
        '200':
          description: Invoices

  /v1/billing/usage:
    get:
      tags: [billing]
      parameters: [{ $ref: '#/components/parameters/BusinessId' }]
      responses:
        '200':
          description: Usage vs limit

  # ---------- §13 Settings ----------
  /v1/settings/profile:
    get:
      tags: [settings]
      responses:
        '200': { description: User profile }
    patch:
      tags: [settings]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                full_name: { type: string }
                avatar_url: { type: string, nullable: true }
                timezone: { type: string }
                phone: { type: string, nullable: true }
      responses:
        '200': { description: Updated }

  /v1/settings/avatar:
    post:
      tags: [settings]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [file_key]
              properties:
                file_key: { type: string }
      responses:
        '200': { description: Avatar set }

  /v1/settings/preferences:
    get:
      tags: [settings]
      parameters: [{ $ref: '#/components/parameters/BusinessId' }]
      responses:
        '200': { description: Display preferences }
    patch:
      tags: [settings]
      parameters: [{ $ref: '#/components/parameters/BusinessId' }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                theme: { type: string, enum: [system, light, dark] }
                currency: { type: string }
                locale: { type: string }
                first_day_of_week: { type: integer, minimum: 0, maximum: 6 }
                date_format: { type: string }
                show_balances: { type: boolean }
                haptics_enabled: { type: boolean }
      responses:
        '200': { description: Updated }

  /v1/settings/notifications:
    get:
      tags: [settings]
      parameters: [{ $ref: '#/components/parameters/BusinessId' }]
      responses:
        '200': { description: Per-channel toggles }
    put:
      tags: [settings]
      parameters: [{ $ref: '#/components/parameters/BusinessId' }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object }
      responses:
        '200': { description: Saved }

  /v1/settings/cpa-invites:
    get:
      tags: [settings]
      parameters: [{ $ref: '#/components/parameters/BusinessId' }]
      responses:
        '200': { description: CPA invitations }
    post:
      tags: [settings]
      parameters: [{ $ref: '#/components/parameters/BusinessId' }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [cpa_email]
              properties:
                cpa_email: { type: string, format: email }
                cpa_name: { type: string }
      responses:
        '201': { description: Invited }

  /v1/settings/cpa-invites/{id}:
    delete:
      tags: [settings]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
        - { $ref: '#/components/parameters/BusinessId' }
      responses:
        '200': { description: Revoked }

  # ---------- §14 Notifications ----------
  /v1/notifications:
    get:
      tags: [notifications]
      parameters:
        - { $ref: '#/components/parameters/Cursor' }
        - { $ref: '#/components/parameters/Limit' }
        - { name: unread, in: query, schema: { type: boolean } }
      responses:
        '200':
          description: Inbox page
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/CursorPage'
                  - type: object
                    properties:
                      items:
                        type: array
                        items: { $ref: '#/components/schemas/NotificationInboxItem' }
                      unread_count: { type: integer }

  /v1/notifications/unread-count:
    get:
      tags: [notifications]
      responses:
        '200':
          description: Tab-bar badge count

  /v1/notifications/{id}/read:
    patch:
      tags: [notifications]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        '200': { description: Marked read }

  /v1/notifications/mark-all-read:
    post:
      tags: [notifications]
      responses:
        '200': { description: All marked read }

  /v1/notifications/{id}:
    delete:
      tags: [notifications]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        '200': { description: Removed }

  /v1/notifications/test:
    post:
      tags: [notifications]
      summary: Dev-only — insert a synthetic inbox row
      responses:
        '201': { description: Inserted }

  # ---------- §15 AI ----------
  /v1/ai/ask:
    post:
      tags: [ai]
      parameters: [{ $ref: '#/components/parameters/BusinessId' }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [question]
              properties:
                question: { type: string }
                context: { type: object }
      responses:
        '200':
          description: One-shot answer

  /v1/ai/conversations:
    get:
      tags: [ai]
      parameters: [{ $ref: '#/components/parameters/BusinessId' }]
      responses:
        '200':
          description: List
          content:
            application/json:
              schema:
                type: object
                properties:
                  conversations:
                    type: array
                    items: { $ref: '#/components/schemas/AiConversation' }
    post:
      tags: [ai]
      parameters: [{ $ref: '#/components/parameters/BusinessId' }]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                title: { type: string }
      responses:
        '201':
          description: Created

  /v1/ai/conversations/{id}:
    get:
      tags: [ai]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
        - { $ref: '#/components/parameters/BusinessId' }
      responses:
        '200':
          description: Conversation + messages
    delete:
      tags: [ai]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
        - { $ref: '#/components/parameters/BusinessId' }
      responses:
        '200': { description: Deleted }

  /v1/ai/conversations/{id}/messages:
    post:
      tags: [ai]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
        - { $ref: '#/components/parameters/BusinessId' }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [content]
              properties:
                content: { type: string }
      responses:
        '201':
          description: User + assistant messages

  /v1/ai/feedback:
    post:
      tags: [ai]
      parameters: [{ $ref: '#/components/parameters/BusinessId' }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [message_id, rating]
              properties:
                message_id: { type: string, format: uuid }
                rating: { type: string, enum: [up, down] }
                comment: { type: string }
      responses:
        '200': { description: Recorded }

  /v1/ai/edge-cases:
    get:
      tags: [ai]
      parameters:
        - { $ref: '#/components/parameters/BusinessId' }
        - { name: type, in: query, schema: { type: string } }
      responses:
        '200': { description: Edge-case transactions }

  /v1/ai/suggest-category:
    post:
      tags: [ai]
      parameters: [{ $ref: '#/components/parameters/BusinessId' }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                transaction_id: { type: string, format: uuid }
                merchant: { type: string }
                amount: { type: number }
                raw_description: { type: string }
      responses:
        '200':
          description: Top-N category suggestions

  # ---------- §16 Support ----------
  /v1/support/ticket:
    post:
      tags: [support]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [subject, body, category]
              properties:
                subject: { type: string }
                body: { type: string }
                category: { type: string, enum: [billing, bug, feature, account] }
                attachments:
                  type: array
                  items:
                    type: object
                    properties:
                      file_key: { type: string }
      responses:
        '201':
          description: Ticket created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SupportTicket' }

  /v1/support/tickets:
    get:
      tags: [support]
      parameters:
        - { name: status, in: query, schema: { type: string, enum: [open, pending, closed] } }
      responses:
        '200':
          description: My tickets
          content:
            application/json:
              schema:
                type: object
                properties:
                  tickets:
                    type: array
                    items: { $ref: '#/components/schemas/SupportTicket' }

  /v1/support/intercom-token:
    get:
      tags: [support]
      responses:
        '200':
          description: HMAC identity token for Intercom mobile SDK

  /v1/support/articles:
    get:
      tags: [support]
      parameters:
        - { name: search, in: query, schema: { type: string } }
        - { name: category, in: query, schema: { type: string } }
      responses:
        '200':
          description: Knowledge base articles

  # ---------- §17 Cross-cutting ----------
  /v1/version:
    get:
      tags: [cross-cutting]
      security: []
      responses:
        '200':
          description: Server + minimum supported app version

  /v1/config/client:
    get:
      tags: [cross-cutting]
      security: []
      responses:
        '200':
          description: Public runtime config (api base, feature toggles, vendor IDs)

  /v1/feature-flags:
    get:
      tags: [cross-cutting]
      responses:
        '200':
          description: Per-user flag values

  /v1/telemetry/event:
    post:
      tags: [cross-cutting]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string }
                props: { type: object }
                ts: { type: string, format: date-time }
      responses:
        '202': { description: Buffered }

  /v1/uploads/presign:
    post:
      tags: [cross-cutting]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [filename, mime_type]
              properties:
                filename: { type: string }
                mime_type: { type: string }
                size_bytes: { type: integer }
                kind: { type: string, enum: [receipt, avatar, attachment] }
      responses:
        '200':
          description: Presigned PUT URL + file_key

  /v1/sync/pull:
    get:
      tags: [sync]
      parameters:
        - { $ref: '#/components/parameters/BusinessId' }
        - { name: since, in: query, required: true, schema: { type: string, format: date-time } }
      responses:
        '200':
          description: Delta since timestamp
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SyncPullDelta' }

  /v1/sync/push:
    post:
      tags: [sync]
      parameters: [{ $ref: '#/components/parameters/BusinessId' }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SyncPushBatch' }
      responses:
        '200':
          description: Per-op result
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SyncPushResult' }

  /health:
    get:
      tags: [cross-cutting]
      security: []
      responses:
        '200': { description: Liveness probe }

  # =========================================================================
  # CPA Invite v2 — see docs/CPA_Invite_Backend_Sprint_Plan.md
  # =========================================================================

  /v1/me:
    put:
      tags: [auth]
      summary: Update the caller's own profile
      description: >
        Updates full_name, phone, timezone, and/or avatar_url for the
        authenticated user. Email is immutable here (changing it requires a
        re-verification flow). Returns the same { user, business } envelope as
        GET /v1/auth/me.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                full_name: { type: string, minLength: 1, maxLength: 200 }
                phone: { type: string, maxLength: 40, nullable: true }
                timezone: { type: string, maxLength: 64 }
                avatar_url: { type: string, nullable: true, description: Public URL of an uploaded avatar (from /v1/uploads/presign). }
      responses:
        '200':
          description: Updated profile
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MeResponse' }
        '400': { description: Validation error }

  /v1/agents:
    get:
      tags: [dashboard]
      summary: Specialist AI-CPA agent roster
      description: >
        Returns the 8-agent roster for the mobile Agents tab. Bookkeeping
        accuracy and the Accounting pending-approval count are derived from
        live data; other metrics are stable labels.
      parameters: [{ $ref: '#/components/parameters/BusinessId' }]
      responses:
        '200':
          description: Agent roster
          content:
            application/json:
              schema:
                type: object
                properties:
                  summary:
                    type: object
                    properties:
                      working: { type: integer }
                      waiting: { type: integer }
                      total: { type: integer }
                  agents:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string, example: bookkeeping }
                        name: { type: string }
                        tagline: { type: string }
                        color: { type: string, example: '#0084FF' }
                        icon: { type: string }
                        status: { type: string, enum: [working, waiting, idle, soon] }
                        status_label: { type: string }
                        metric: { type: string, nullable: true }
                        metric_label: { type: string, nullable: true }
                        activity: { type: string, nullable: true }
                        attention_count: { type: integer, nullable: true }
                        available: { type: boolean }

  /v1/me/memberships:
    get:
      tags: [cpa-invite-v2]
      summary: List businesses the caller belongs to
      responses:
        '200':
          description: Memberships
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    membership_id: { type: string, format: uuid }
                    business:
                      type: object
                      properties:
                        id: { type: string, format: uuid }
                        name: { type: string }
                    role: { type: string }
                    status: { type: string }
                    access_starts_at: { type: string, format: date-time, nullable: true }
                    access_ends_at: { type: string, format: date-time, nullable: true }
                    last_active_at: { type: string, format: date-time, nullable: true }
                    membership_version: { type: integer }

  /v1/auth/switch-business:
    post:
      tags: [cpa-invite-v2]
      summary: Re-mint access token for a different business the caller belongs to
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [business_id]
              properties: { business_id: { type: string, format: uuid } }
      responses:
        '200':
          description: New access token
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  access_token: { type: string }
                  access_token_expires_in: { type: integer }
                  business_id: { type: string, format: uuid }
                  role: { type: string }
        '403': { description: Not a member, expired, or revoked }

  /v1/team/{businessId}/members:
    parameters:
      - in: path
        name: businessId
        required: true
        schema: { type: string, format: uuid }
    get:
      tags: [cpa-invite-v2]
      summary: List members of a business
      responses:
        '200': { description: Members }
        '403': { description: Not allowed for caller's role }

  /v1/team/{businessId}/members/{memberId}:
    parameters:
      - in: path
        name: businessId
        required: true
        schema: { type: string, format: uuid }
      - in: path
        name: memberId
        required: true
        schema: { type: string, format: uuid }
    patch:
      tags: [cpa-invite-v2]
      summary: Edit role / access window / status of a member
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                role: { type: string, enum: [owner, co_owner, viewer, cpa_viewer, accountant_reviewer] }
                access_starts_at: { type: string, format: date-time, nullable: true }
                access_ends_at: { type: string, format: date-time, nullable: true }
                entity_scope: {}
                status: { type: string, enum: [active, pending, expired, revoked] }
      responses:
        '200': { description: Updated }
    delete:
      tags: [cpa-invite-v2]
      summary: Revoke a member
      responses:
        '200': { description: Revoked }

  /v1/team/{businessId}/invites:
    parameters:
      - in: path
        name: businessId
        required: true
        schema: { type: string, format: uuid }
    get:
      tags: [cpa-invite-v2]
      summary: List invitations for a business
      responses: { '200': { description: Invitations } }
    post:
      tags: [cpa-invite-v2]
      summary: Create or refresh an invitation (one open invite per business+email)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, role]
              properties:
                email: { type: string, format: email }
                name: { type: string }
                role: { type: string, enum: [cpa_viewer, accountant_reviewer, viewer] }
                access_starts_at: { type: string, format: date-time }
                access_ends_at: { type: string, format: date-time }
                entity_scope: {}
      responses:
        '201': { description: Created or refreshed }
        '429': { description: Rate-limited (20/business/day) }

  /v1/team/{businessId}/invites/{id}/resend:
    parameters:
      - in: path
        name: businessId
        required: true
        schema: { type: string, format: uuid }
      - in: path
        name: id
        required: true
        schema: { type: string, format: uuid }
    post:
      tags: [cpa-invite-v2]
      summary: Resend a pending invitation (rate-limited)
      responses:
        '200': { description: Resent (sent_count incremented) }
        '409': { description: Invite is not pending }
        '429': { description: Rate-limited }

  /v1/invites/{token}:
    parameters:
      - in: path
        name: token
        required: true
        schema: { type: string }
    get:
      tags: [cpa-invite-v2]
      security: []
      summary: Public lookup of invite metadata by token
      responses:
        '200': { description: Invite metadata }
        '404': { description: Not found }

  /v1/invites/{token}/accept:
    parameters:
      - in: path
        name: token
        required: true
        schema: { type: string }
    post:
      tags: [cpa-invite-v2]
      summary: Bind invitation to authenticated user (email must match)
      responses:
        '200': { description: Membership bound }
        '403': { description: Email mismatch with the invited address }
        '404': { description: Invite not found }
        '410': { description: Expired or already used }

  /v1/team/{businessId}/adjustments:
    parameters:
      - in: path
        name: businessId
        required: true
        schema: { type: string, format: uuid }
    get:
      tags: [cpa-invite-v2]
      summary: List proposed adjustments
      responses: { '200': { description: List } }
    post:
      tags: [cpa-invite-v2]
      summary: Reviewer proposes an adjustment
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [target_type, target_id, after_payload]
              properties:
                target_type: { type: string, enum: [transaction, invoice, bill] }
                target_id: { type: string, format: uuid }
                after_payload: { type: object }
                note: { type: string }
      responses:
        '201': { description: Created }
        '409': { description: Pending suggestion already exists for this target }

  /v1/team/{businessId}/adjustments/{id}/approve:
    post:
      tags: [cpa-invite-v2]
      summary: Owner approves the proposed adjustment (idempotent)
      parameters:
        - in: path
          name: businessId
          required: true
          schema: { type: string, format: uuid }
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      responses: { '200': { description: Applied } }

  /v1/team/{businessId}/adjustments/{id}/reject:
    post:
      tags: [cpa-invite-v2]
      summary: Owner rejects the proposed adjustment
      parameters:
        - in: path
          name: businessId
          required: true
          schema: { type: string, format: uuid }
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      responses: { '200': { description: Rejected } }

  /v1/team/{businessId}/review-threads:
    parameters:
      - in: path
        name: businessId
        required: true
        schema: { type: string, format: uuid }
    get:
      tags: [cpa-invite-v2]
      summary: List question threads
      responses: { '200': { description: List } }
    post:
      tags: [cpa-invite-v2]
      summary: Open a new question thread (any accountant role)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [target_type, target_id, body]
              properties:
                target_type: { type: string, enum: [transaction, invoice, bill, report, customer, vendor, journal_entry] }
                target_id: { type: string, format: uuid }
                subject: { type: string }
                body: { type: string }
      responses: { '201': { description: Opened } }

  /v1/team/{businessId}/review-threads/{id}/messages:
    post:
      tags: [cpa-invite-v2]
      summary: Reply on a thread (state-machine flips status as needed)
      parameters:
        - in: path
          name: businessId
          required: true
          schema: { type: string, format: uuid }
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [body]
              properties:
                body: { type: string }
                resulting_adjustment_id: { type: string, format: uuid }
      responses: { '201': { description: Posted } }

  /v1/team/{businessId}/review-threads/{id}/resolve:
    post:
      tags: [cpa-invite-v2]
      summary: Mark a thread resolved (owner or opener)
      parameters:
        - in: path
          name: businessId
          required: true
          schema: { type: string, format: uuid }
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      responses: { '200': { description: Resolved } }

  /v1/team/{businessId}/audit-log:
    get:
      tags: [cpa-invite-v2]
      summary: Query the audit log (owner-only)
      parameters:
        - in: path
          name: businessId
          required: true
          schema: { type: string, format: uuid }
        - in: query
          name: actor
          schema: { type: string, format: uuid }
        - in: query
          name: action
          schema: { type: string }
        - in: query
          name: from
          schema: { type: string, format: date-time }
        - in: query
          name: to
          schema: { type: string, format: date-time }
        - in: query
          name: limit
          schema: { type: integer, default: 50, maximum: 200 }
        - in: query
          name: cursor
          schema: { type: string }
      responses: { '200': { description: Page of audit-log rows } }

  /v1/team/{businessId}/audit-log.csv:
    get:
      tags: [cpa-invite-v2]
      summary: Stream the audit log as CSV
      parameters:
        - in: path
          name: businessId
          required: true
          schema: { type: string, format: uuid }
        - in: query
          name: from
          schema: { type: string, format: date-time }
        - in: query
          name: to
          schema: { type: string, format: date-time }
      responses:
        '200':
          description: CSV stream
          content:
            text/csv: { schema: { type: string, format: binary } }
