Ctrl K
ComunidadGuías…

asset_openapi.yaml

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
openapi: "3.2.0"

info:
  title: AssetPilot API
  version: "4.134.0"
  description: |
    個人資產管理平台 AssetPilot 的完整 API Schema。
    供 Cloudflare API Shield Schema Validation 使用。
    認證方式:JWT 儲存於 httpOnly Cookie(token),除 /api/auth/* 與 /api/config 外均需認證。
    記帳 API 可用 X-Ledger-Id header 或 ledgerId query 選擇帳本,未指定則使用個人帳本。
    每次請求皆驗證登入者的帳本成員資格;viewer 僅可 GET/HEAD/OPTIONS。
    個人偏好、投資、登入憑證及 MCP/API Token/LINE 整合不採用共享帳本選擇。

servers:
  - url: https://asset.shao.one
    description: Production

# ─────────────────────────────────────────
# 標籤定義(對應 Cloudflare API Shield Endpoint Labels)
# ─────────────────────────────────────────
tags:
  - name: Ledgers
    description: 共享帳本與成員授權
  - name: Config
    description: 系統設定與版本資訊(cf-web-page)
  - name: Auth
    description: 使用者登入驗證(cf-log-in)
  - name: Account
    description: 帳號設定與個人資料(cf-account-update)
  - name: Categories
    description: 分類管理(cf-content)
  - name: ExchangeRates
    description: 匯率設定與查詢(cf-content)
  - name: Accounts
    description: 帳戶管理(cf-content)
  - name: Transactions
    description: 交易記錄(cf-content)
  - name: Budgets
    description: 預算管理(cf-content)
  - name: Recurring
    description: 固定收支(cf-content)
  - name: Dashboard
    description: 儀表板與報表(cf-content)
  - name: TWSE
    description: 台灣證交所查詢(cf-content)
  - name: Stocks
    description: 股票管理(cf-content)
  - name: StockTransactions
    description: 股票交易紀錄(cf-content)
  - name: StockDividends
    description: 股利紀錄(cf-content)
  - name: StockRealized
    description: 實現損益(cf-content)
  - name: StockSettings
    description: 股票參數設定(cf-content)
  - name: StockRecurring
    description: 股票定期定額(cf-content)
  - name: McpCredentials
    description: MCP 個人化存取權杖(PAT)管理(cf-account-update)
  - name: McpOAuthConnections
    description: 已連接 AI 工具(OAuth)清單與新增資料權限管理(cf-account-update)

# ─────────────────────────────────────────
# 安全性定義
# ─────────────────────────────────────────
components:
  securitySchemes:
    cookieAuth:
      type: apiKey
      in: cookie
      name: token

  parameters:
    LedgerId:
      in: path
      name: ledgerId
      required: true
      schema:
        type: string
    LedgerSelection:
      in: header
      name: X-Ledger-Id
      description: 目前帳本,省略時使用個人帳本;不可用此值冒用非成員帳本。
      schema:
        type: string

  schemas:
    Ledger:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        isShared: { type: boolean }
        isPersonal: { type: boolean }
        ownerUserId: { type: string }
        role: { type: string, enum: [owner, editor, viewer] }
        memberCount: { type: integer }
        createdAt: { type: integer }
    LedgerMemberRole:
      type: object
      required: [userId, role]
      properties:
        userId: { type: string }
        role: { type: string, enum: [editor, viewer] }
    LedgerMemberTarget:
      type: object
      required: [userId]
      properties:
        userId: { type: string }

    # ── 通用 ──
    IdObject:
      type: object
      properties:
        id:
          type: string

    ErrorResponse:
      type: object
      properties:
        error:
          type: string

    # ── 分類 ──
    Category:
      type: object
      properties:
        id:
          type: string
        name:
          type: string
        type:
          type: string
          enum: [income, expense]
        color:
          type: string
        parentId:
          type: string
        isHidden:
          type: boolean
        sortOrder:
          type: integer

    CategoryInput:
      type: object
      required: [name, type, color]
      properties:
        name:
          type: string
        type:
          type: string
          enum: [income, expense]
        color:
          type: string
        parentId:
          type: string
        sortOrder:
          type: integer

    # ── 帳戶 ──
    Account:
      type: object
      properties:
        id:
          type: string
        name:
          type: string
        icon:
          type: string
        currency:
          type: string
        accountType:
          type: string
        excludeFromTotal:
          type: boolean
        balance:
          type: number
        initialBalance:
          type: number
        linkedBankId:
          type: string

    AccountInput:
      type: object
      required: [name]
      properties:
        name:
          type: string
        initialBalance:
          type: number
        icon:
          type: string
        currency:
          type: string
        accountType:
          type: string
        excludeFromTotal:
          type: boolean
        linkedBankId:
          type: string

    # ── 交易 ──
    Transaction:
      type: object
      properties:
        id:
          type: string
        type:
          type: string
          enum: [income, expense, transfer_in, transfer_out]
        amount:
          type: number
        originalAmount:
          type: number
        currency:
          type: string
        fxRate:
          type: number
        date:
          type: string
          format: date
        categoryId:
          type: string
        accountId:
          type: string
        note:
          type: string
        excludeFromStats:
          type: boolean

    TransactionInput:
      type: object
      required: [type, amount, date, categoryId, accountId]
      properties:
        type:
          type: string
          enum: [income, expense]
        amount:
          type: number
        originalAmount:
          type: number
        currency:
          type: string
        fxRate:
          type: number
        date:
          type: string
        categoryId:
          type: string
        accountId:
          type: string
        note:
          type: string
        excludeFromStats:
          type: boolean
        fxFee:
          type: number
          description: 海外刷卡手續費(TWD)
        clientRef:
          type: string
          pattern: "^[a-f0-9]{32}$"
          description: |
            離線記帳的 idempotency key(007-pwa-offline-entry)。離線新增的交易由
            client 產生此值;伺服器以 (user_id, client_ref) 唯一索引去重,同一
            clientRef 重送不會產生重複交易,並回傳與新建相同的結果。線上直接新增時省略;
            格式不符時視為未提供。

    # ── 預算 ──
    BudgetInput:
      type: object
      required: [amount, yearMonth]
      properties:
        categoryId:
          type: string
        amount:
          type: number
        yearMonth:
          type: string

    # ── 目標儲蓄與還款計畫(issue #260)──
    SavingsGoalInput:
      type: object
      required: [name, targetAmount, targetDate]
      properties:
        name:
          type: string
          maxLength: 60
        targetAmount:
          type: integer
          minimum: 1
          maximum: 1000000000
          description: 目標金額(TWD)
        targetDate:
          type: string
          pattern: "^\\d{4}-\\d{2}-\\d{2}$"
        accountId:
          type: [string, "null"]
          description: 綁定帳戶(以帳戶餘額為已存);與 categoryId 擇一
        categoryId:
          type: [string, "null"]
          description: 綁定分類(以建立日起該分類支出累計為已存);與 accountId 擇一

    RepaymentPlanInput:
      type: object
      required: [name, principal, periods, startDate]
      properties:
        name:
          type: string
          maxLength: 60
        principal:
          type: integer
          minimum: 1
          maximum: 1000000000
        annualRatePercent:
          type: number
          minimum: 0
          maximum: 100
          description: 年利率(百分比,預設 0)
        periods:
          type: integer
          minimum: 1
          maximum: 600
          description: 期數(月)
        startDate:
          type: string
          pattern: "^\\d{4}-\\d{2}-\\d{2}$"
          description: 首次應繳日
        accountId:
          type: [string, "null"]
          description: 還款來源帳戶標記(不影響攤還計算)

    # ── 固定收支 ──
    RecurringInput:
      type: object
      required: [type, amount, categoryId, accountId, frequency, startDate]
      properties:
        type:
          type: string
          enum: [income, expense]
        amount:
          type: number
        currency:
          type: string
        fxRate:
          type: number
        categoryId:
          type: string
        accountId:
          type: string
        frequency:
          type: string
          enum: [daily, weekly, monthly, yearly]
        startDate:
          type: string
        note:
          type: string

    # ── 匯率 ──
    ExchangeRateItem:
      type: object
      required: [currency, rateToTwd]
      properties:
        currency:
          type: string
          minLength: 3
          maxLength: 3
        rateToTwd:
          type: number

    # ── 股票 ──
    StockInput:
      type: object
      required: [symbol, name]
      properties:
        market:
          type: string
          enum: [TW, US]
          default: TW
        symbol:
          type: string
        name:
          type: string
        stockType:
          type: string
          enum: [stock, etf, warrant]

    StockTransactionInput:
      type: object
      required: [stockId, date, type, shares, price]
      properties:
        stockId:
          type: string
        date:
          type: string
        type:
          type: string
          enum: [buy, sell]
        shares:
          type: number
        price:
          type: number
        fee:
          type: number
        tax:
          type: number
        dayTrade:
          type: boolean
          description: 現股當沖(僅 2017-04-28 至 2027-12-31 的台股一般股票賣出適用);需有同證券帳戶、同日、同標的且股數足夠的現款買進,證交稅依證券交易稅條例第 2 條之 2 以 0.15% 計算。
        accountId:
          type: string
        note:
          type: string

    StockDividendInput:
      type: object
      required: [stockId, date]
      properties:
        stockId:
          type: string
        date:
          type: string
        cashDividend:
          type: number
        stockDividendShares:
          type: number
        reinvest:
          type: boolean
          description: 股利再投資(DRIP):以現金股利再買入同一標的,自動產生買入紀錄並計入 FIFO 成本基礎。
        reinvestShares:
          type: number
          description: 再投資股數(reinvest = true 時必填且為正數)。
        reinvestPrice:
          type: number
          description: 再投資每股價格(reinvest = true 時必填且為正數);再投資股數 × 價格不得超過現金股利。
        accountId:
          type: string
        note:
          type: string

    # ── 管理員 ──
    AdminUserInput:
      type: object
      required: [email, displayName]
      properties:
        email:
          type: string
          format: email
        displayName:
          type: string
        isAdmin:
          type: boolean

    BatchIdsInput:
      type: object
      required: [ids]
      properties:
        ids:
          type: array
          items:
            type: string

    # ── MCP 新增資料權限(003-mcp-write-no-delete) ──
    McpCredential:
      type: object
      properties:
        id:
          type: string
        name:
          type: string
        status:
          type: string
          enum: [active, expired, revoked]
        allowCreate:
          type: boolean
          description: 是否允許此憑證透過 MCP 新增交易;預設 false。
        allowUpdateNote:
          type: boolean
          description: |
            是否允許此憑證透過 MCP 更新既有交易的備註;預設 false。
            與 allowCreate 彼此獨立,開啟其中一項不代表取得另一項的能力。
            本功能上線當下所有既有憑證(含 allowCreate 為 true 者)皆為 false。
        createdAt:
          type: string
          format: date-time
        lastUsedAt:
          # OpenAPI 3.1 起對齊 JSON Schema 2020-12,3.0 的 `nullable: true` 已移除,
          # 可空型別一律寫成型別陣列。
          type: [string, "null"]
          format: date-time
        expiresAt:
          type: [string, "null"]
          format: date-time
      required: [id, name, status, allowCreate, allowUpdateNote, createdAt]

    # 既有 McpCredentialAllowCreateUpdate 更名而來:本 body 現可攜帶兩種權限,
    # 原名稱已不再貼切(見 research.md 第 11 節)。屬性名稱與端點路徑皆不變。
    McpCredentialPermissionUpdate:
      type: object
      description: |
        兩個屬性皆為選填,但至少必須提供其中一個布林值;兩者皆未提供或型別非布林時回傳 400。
        僅提供其中一項時,另一項維持原狀不變。
      properties:
        allowCreate:
          type: boolean
          description: 新的「允許新增資料」狀態。
        allowUpdateNote:
          type: boolean
          description: 新的「允許更新備註」狀態。

    McpOAuthConnection:
      type: object
      properties:
        clientId:
          type: string
          description: 已授權的 OAuth client id(一個 AI 工具對應一個 client_id)。
        clientName:
          type: string
          description: 授權當下記錄的 client 顯示名稱快照。
        allowCreate:
          type: boolean
          description: 是否允許此連線透過 MCP 新增交易;預設 false。
        allowUpdateNote:
          type: boolean
          description: |
            是否允許此連線透過 MCP 更新既有交易的備註;預設 false。
            與 allowCreate 彼此獨立。此 AI 工具的授權全數失效後重新完成授權時,
            本欄位會與 allowCreate 一同重置為 false,須使用者再次主動開啟。
        firstConnectedAt:
          type: string
          format: date-time
        lastUsedAt:
          type: string
          format: date-time
      required:
        - clientId
        - clientName
        - allowCreate
        - allowUpdateNote
        - firstConnectedAt
        - lastUsedAt

    # 既有 McpOAuthConnectionAllowCreateUpdate 更名而來,理由同上。
    McpOAuthConnectionPermissionUpdate:
      type: object
      description: |
        兩個屬性皆為選填,但至少必須提供其中一個布林值;兩者皆未提供或型別非布林時回傳 400。
        僅提供其中一項時,另一項維持原狀不變。
      properties:
        allowCreate:
          type: boolean
        allowUpdateNote:
          type: boolean

    # ── 006 信用卡總金額還款 ──
    RepaymentRequest:
      type: object
      description: |
        totalAmount 與 repayments 至少擇一;兩者同時出現時以 totalAmount 為準(FR-019)。
      properties:
        fromAccountId:
          type: string
          description: 付款帳戶 id,不可為信用卡帳戶(FR-010)
        date:
          type: [string, "null"]
          description: 還款日期 YYYY-MM-DD;省略或無法解析時以使用者時區的今日為準(FR-014)
        totalAmount:
          type: integer
          minimum: 1
          description: 本次還款總金額(付款帳戶幣別),須為大於 0 的整數且不得小於納入卡片張數(FR-008、FR-008a)
        repayments:
          type: array
          description: 舊版行動 App 逐張卡片金額(過渡相容路徑);
            系統加總為一筆總金額後套用自動納入與等比例分配重算(FR-019a)
          items:
            type: object
            properties:
              cardId:
                type: string
              amount:
                type: number
      required: [fromAccountId]

    RepaymentAllocation:
      type: object
      properties:
        cardId:
          type: string
        cardName:
          type: string
        cardCurrency:
          type: string
        amount:
          type: integer
          minimum: 1
        amountInCardCurrency:
          type: number
        balanceAfter:
          type: number
          description: 還款後該卡餘額;大於 0 代表預繳(FR-004、FR-020)
      required:
        - cardId
        - cardName
        - cardCurrency
        - amount
        - amountInCardCurrency
        - balanceAfter

    RepaymentResponse:
      type: object
      properties:
        ok:
          type: boolean
        summaryId:
          type: string
        date:
          type: string
        fromAccountId:
          type: string
        currency:
          type: string
        totalAmount:
          type: integer
        allocations:
          type: array
          items:
            $ref: "#/components/schemas/RepaymentAllocation"
      required:
        - ok
        - summaryId
        - date
        - fromAccountId
        - currency
        - totalAmount
        - allocations

    PayableCard:
      type: object
      description: 本次納入還款的信用卡;陣列順序即標準順序,前端不得重排。
      properties:
        id:
          type: string
        name:
          type: string
        currency:
          type: string
        debt:
          type: integer
          minimum: 1
        debtInCardCurrency:
          type: number
      required: [id, name, currency, debt, debtInCardCurrency]

    RepaymentCardsResponse:
      type: object
      properties:
        fromAccount:
          type: object
          properties:
            id:
              type: string
            name:
              type: string
            currency:
              type: string
          required: [id, name, currency]
        cards:
          type: array
          items:
            $ref: "#/components/schemas/PayableCard"
        totalDebt:
          type: integer
        minTotalAmount:
          type: integer
        snapshotAt:
          type: string
          format: date-time
      required: [fromAccount, cards, totalDebt, minTotalAmount, snapshotAt]

    SummaryAllocation:
      type: object
      properties:
        cardId:
          type: string
        cardName:
          type: string
        cardCurrency:
          type: string
        amount:
          type: integer
        amountInCardCurrency:
          type: number
        status:
          type: string
          enum: [intact, modified, deleted]
      required:
        - cardId
        - cardName
        - cardCurrency
        - amount
        - amountInCardCurrency
        - status

    RepaymentSummaryResponse:
      type: object
      properties:
        id:
          type: string
        date:
          type: string
        fromAccount:
          type: object
          properties:
            id:
              type: string
            name:
              type: string
            currency:
              type: string
          required: [id, name, currency]
        totalAmount:
          type: integer
        inputMode:
          type: string
          enum: [total, legacy_items]
        createdAt:
          type: string
          format: date-time
        stale:
          type: boolean
        allocations:
          type: array
          items:
            $ref: "#/components/schemas/SummaryAllocation"
      required:
        - id
        - date
        - fromAccount
        - totalAmount
        - inputMode
        - createdAt
        - stale
        - allocations

# ─────────────────────────────────────────
# 預設安全性(全域)
# ─────────────────────────────────────────
security:
  - cookieAuth: []

# ─────────────────────────────────────────
# 路由定義
# ─────────────────────────────────────────
paths:
  /api/ledgers:
    get:
      tags: [Ledgers]
      summary: 列出目前登入者加入的帳本
      responses:
        "200":
          description: 個人與共享帳本清單(不包含其他使用者的帳本)
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Ledger" }
    post:
      tags: [Ledgers]
      summary: 建立空白共享帳本,不複製個人資料
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, minLength: 1, maxLength: 80 }
      responses:
        "201": { description: 已建立帳本,建立者為 owner }
        "400": { description: 帳本名稱無效 }

  /api/ledgers/{ledgerId}/members:
    parameters:
      - { $ref: "#/components/parameters/LedgerId" }
    get:
      tags: [Ledgers]
      summary: 成員可讀取此帳本成員與角色
      responses:
        "200": { description: 帳本資訊與成員清單 }
        "404": { description: 找不到帳本或非成員 }
    post:
      tags: [Ledgers]
      summary: owner 以 Email 邀請 editor 或 viewer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, role]
              properties:
                email: { type: string, format: email }
                role: { type: string, enum: [editor, viewer] }
      responses:
        "201": { description: 已寄送 7 天有效的單次邀請,不回傳原始 token }
        "403": { description: 非 owner }
        "409": { description: 已是成員 }
        "503": { description: 寄信服務未設定或寄送失敗,邀請已撤銷 }
    patch:
      tags: [Ledgers]
      summary: owner 修改其他成員的 editor/viewer 角色
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/LedgerMemberRole" }
      responses:
        "200": { description: 已更新角色 }
        "403": { description: 非 owner }
        "409": { description: owner 必須使用移交功能 }
    delete:
      tags: [Ledgers]
      summary: owner 移除非 owner 成員,資料仍歸帳本所有
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/LedgerMemberTarget" }
      responses:
        "200": { description: 已移除,新請求立即失去存取權 }
        "403": { description: 非 owner }
        "409": { description: 不可移除 owner }

  /api/ledgers/{ledgerId}/invitations:
    parameters:
      - { $ref: "#/components/parameters/LedgerId" }
    get:
      tags: [Ledgers]
      summary: owner 列出尚未接受且未到期的邀請(不含 token 雜湊)
      responses:
        "200": { description: 待處理邀請清單 }
        "403": { description: 非 owner }
    delete:
      tags: [Ledgers]
      summary: owner 撤銷邀請
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [invitationId]
              properties:
                invitationId: { type: string }
      responses:
        "200": { description: 已撤銷 }
        "404": { description: 找不到待處理邀請 }

  /api/ledgers/invitations/accept:
    post:
      tags: [Ledgers]
      summary: 使用受邀電子郵件帳號登入後接受邀請
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token]
              properties:
                token: { type: string, pattern: "^[a-fA-F0-9]{64}$" }
      responses:
        "200": { description: 已加入帳本,回傳 ledgerId 與角色 }
        "403": { description: 登入信箱不是受邀信箱 }
        "404": { description: 邀請無效、到期、撤銷或已使用 }
        "409": { description: 已是帳本成員 }

  /api/ledgers/{ledgerId}/transfer:
    parameters:
      - { $ref: "#/components/parameters/LedgerId" }
    post:
      tags: [Ledgers]
      summary: owner 移交給另一位有效成員,原 owner 變為 editor
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/LedgerMemberTarget" }
      responses:
        "200": { description: 已原子移交擁有權,財務列的歸屬不變 }
        "403": { description: 非 owner }
        "404": { description: 目標不是有效帳本成員 }

  /api/ledgers/{ledgerId}/leave:
    parameters:
      - { $ref: "#/components/parameters/LedgerId" }
    post:
      tags: [Ledgers]
      summary: editor 或 viewer 離開帳本,資料仍屬帳本
      responses:
        "200": { description: 已離開 }
        "409": { description: owner 必須先移交擁有權 }

  /api/ledgers/{ledgerId}/audit:
    parameters:
      - { $ref: "#/components/parameters/LedgerId" }
    get:
      tags: [Ledgers]
      summary: owner 查閱帳本操作稽核(含操作者 ID、Email、角色與結果)
      parameters:
        - in: query
          name: limit
          schema: { type: integer, minimum: 1, maximum: 200, default: 100 }
      responses:
        "200": { description: 最新稽核清單 }
        "403": { description: 非 owner }

  # ── 公開頁面 ──────────────────────────
  /api/config:
    get:
      operationId: getConfig
      tags: [Config]
      summary: 取得前端設定(Google Client ID 等)
      security: []
      responses:
        "200":
          description: 設定物件

  /api/changelog:
    get:
      operationId: getChangelog
      tags: [Config]
      summary: 取得版本更新紀錄
      security: []
      responses:
        "200":
          description: Changelog JSON

  # ── 認證 ──────────────────────────────
  /api/auth/logout:
    post:
      operationId: authLogout
      tags: [Auth]
      summary: 登出(清除 Cookie)
      security: []
      responses:
        "200":
          description: 登出成功

  /api/auth/me:
    get:
      operationId: authMe
      tags: [Auth]
      summary: 取得目前登入的使用者資訊
      responses:
        "200":
          description: 使用者資訊
        "401":
          description: 未認證

  /api/auth/google/state:
    get:
      operationId: authGoogleState
      tags: [Auth]
      summary: 取得 Google OAuth state nonce
      security: []
      responses:
        "200":
          description: State token

  /api/auth/google:
    post:
      operationId: authGoogle
      tags: [Auth]
      summary: Google SSO 登入 / 註冊
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [credential]
              properties:
                credential:
                  type: string
                state:
                  type: string
      responses:
        "200":
          description: SSO 登入成功
        "401":
          description: Token 驗證失敗

  # ── Passkey (WebAuthn) 認證 ─────────────
  /api/auth/passkey/challenge:
    get:
      operationId: getPasskeyChallenge
      tags: [Auth]
      summary: 取得 Passkey 登入用 challenge
      security: []
      responses:
        "200":
          description: 回傳 challenge 與 key
          content:
            application/json:
              schema:
                type: object
                properties:
                  key:
                    type: string
                  challenge:
                    type: string

  /api/auth/passkey/login:
    post:
      operationId: passkeyLogin
      tags: [Auth]
      summary: 使用 Passkey 進行無密碼登入
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [authentication, challengeKey]
              properties:
                authentication:
                  type: object
                  description: WebAuthn authentication response
                challengeKey:
                  type: string
      responses:
        "200":
          description: 登入成功
        "400":
          description: Challenge 過期或缺少資料
        "401":
          description: Passkey 驗證失敗

  # ── 帳號設定 ──────────────────────────
  /api/account/login-logs:
    get:
      operationId: getLoginLogs
      tags: [Account]
      summary: 取得目前使用者的登入紀錄
      responses:
        "200":
          description: 登入紀錄清單

  /api/account/theme:
    put:
      operationId: updateTheme
      tags: [Account]
      summary: 更新使用者佈景主題
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                theme:
                  type: string
                  enum: [light, dark, auto]
      responses:
        "200":
          description: 更新成功

  /api/account/display-name:
    put:
      operationId: updateDisplayName
      tags: [Account]
      summary: 更新顯示名稱
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                displayName:
                  type: string
      responses:
        "200":
          description: 更新成功

  /api/account/link-google:
    post:
      operationId: linkGoogle
      tags: [Account]
      summary: 綁定 Google 帳號
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [credential]
              properties:
                credential:
                  type: string
      responses:
        "200":
          description: 綁定成功

  /api/account/unlink-google:
    post:
      operationId: unlinkGoogle
      tags: [Account]
      summary: 解除綁定 Google 帳號
      responses:
        "200":
          description: 解除成功

  /api/account/delete:
    post:
      operationId: deleteAccount
      tags: [Account]
      summary: 永久刪除帳號及所有資料
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [confirmEmail]
              properties:
                confirmEmail:
                  type: string
                  format: email
      responses:
        "200":
          description: 帳號已刪除

  /api/account/passkey/challenge:
    get:
      operationId: getPasskeyRegisterChallenge
      tags: [Account]
      summary: 取得 Passkey 註冊用 challenge(需登入)
      responses:
        "200":
          description: 回傳 challenge 與 key

  /api/account/passkeys:
    get:
      operationId: listPasskeys
      tags: [Account]
      summary: 列出已註冊的 Passkey 清單
      responses:
        "200":
          description: Passkey 列表
          content:
            application/json:
              schema:
                type: object
                properties:
                  passkeys:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: string
                        deviceName:
                          type: string
                        createdAt:
                          type: string

  /api/account/passkey/register:
    post:
      operationId: registerPasskey
      tags: [Account]
      summary: 註冊新的 Passkey 憑證
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [registration, challengeKey]
              properties:
                registration:
                  type: object
                  description: WebAuthn registration response
                challengeKey:
                  type: string
                deviceName:
                  type: string
      responses:
        "200":
          description: 註冊成功
        "400":
          description: Challenge 過期或驗證失敗

  /api/account/recovery-codes:
    get:
      operationId: getRecoveryCodeStatus
      tags: [Account]
      summary: 取得帳號復原碼狀態(不含明文)
      responses:
        "200":
          description: 是否已產生與剩餘可用數量
    post:
      operationId: generateRecoveryCodes
      tags: [Account]
      summary: 產生或重新產生一次性復原碼(舊碼立即失效,明文僅回傳一次)
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                authentication:
                  type: object
                  description: 近期登入逾時時,以 Passkey(UV)重新驗證
                challengeKey:
                  type: string
      responses:
        "200":
          description: 回傳 codes(僅此一次)
        "403":
          description: 需要重新驗證(reauthRequired)

  /api/auth/recovery/verify:
    post:
      operationId: verifyRecoveryCode
      tags: [Auth]
      summary: 驗證復原碼並建立受限復原工作階段(不消耗復原碼,不可存取財務資料)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, code]
              properties:
                email:
                  type: string
                code:
                  type: string
      responses:
        "200":
          description: 設定 httpOnly recoveryToken Cookie(10 分鐘)
        "401":
          description: 帳號或復原碼不正確(不洩漏帳號是否存在)
        "429":
          description: 嘗試次數過多

  /api/auth/recovery/challenge:
    get:
      operationId: getRecoveryChallenge
      tags: [Auth]
      summary: 取得復原流程註冊新 Passkey 的 challenge
      responses:
        "200":
          description: key、challenge 與 user
        "401":
          description: 復原工作階段無效或過期

  /api/auth/recovery/complete:
    post:
      operationId: completeRecovery
      tags: [Auth]
      summary: 註冊新 Passkey 後消耗復原碼並登入(可選擇移除其他 Passkey,並登出所有既有工作階段)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [registration, challengeKey]
              properties:
                registration:
                  type: object
                challengeKey:
                  type: string
                deviceName:
                  type: string
                removeOtherPasskeys:
                  type: boolean
      responses:
        "200":
          description: 完成並登入
        "409":
          description: 復原碼已被使用

  /api/account/passkey/{id}:
    delete:
      operationId: deletePasskey
      tags: [Account]
      summary: 刪除已註冊的 Passkey
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: 刪除成功
        "404":
          description: 找不到此 Passkey
    put:
      operationId: renamePasskey
      tags: [Account]
      summary: 重新命名 Passkey
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [deviceName]
              properties:
                deviceName:
                  type: string
      responses:
        "200":
          description: 更新成功
        "404":
          description: 找不到此 Passkey

  # ── 分類 ──────────────────────────────
  /api/user/settings/smart-assist:
    get:
      operationId: getSmartAssistSettings
      tags: [Account]
      summary: 取得智慧輔助開關(分類建議/固定收支偵測,#252)
      responses:
        "200":
          description: 目前設定
          content:
            application/json:
              schema:
                type: object
                properties:
                  enabled:
                    type: boolean
    put:
      operationId: updateSmartAssistSettings
      tags: [Account]
      summary: 更新智慧輔助開關(關閉後兩個建議端點皆回傳空結果)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [enabled]
              properties:
                enabled:
                  type: boolean
      responses:
        "200":
          description: 更新成功

  /api/categories:
    get:
      operationId: getCategories
      tags: [Categories]
      summary: 取得分類清單
      responses:
        "200":
          description: 分類陣列
    post:
      operationId: createCategory
      tags: [Categories]
      summary: 新增分類
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CategoryInput"
      responses:
        "200":
          description: 新增成功

  /api/categories/{id}:
    put:
      operationId: updateCategory
      tags: [Categories]
      summary: 更新分類
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                color:
                  type: string
                isHidden:
                  type: boolean
                sortOrder:
                  type: integer
      responses:
        "200":
          description: 更新成功
        "404":
          description: 分類不存在
    delete:
      operationId: deleteCategory
      tags: [Categories]
      summary: 刪除分類
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: 刪除成功

  # ── 匯率 ──────────────────────────────
  /api/exchange-rates:
    get:
      operationId: getExchangeRates
      tags: [ExchangeRates]
      summary: 取得使用者匯率設定
      responses:
        "200":
          description: 匯率清單與設定
    put:
      operationId: setExchangeRates
      tags: [ExchangeRates]
      summary: 手動儲存匯率(僅寫入本使用者)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [rates]
              properties:
                rates:
                  type: array
                  items:
                    $ref: "#/components/schemas/ExchangeRateItem"
      responses:
        "200":
          description: 儲存成功

  /api/exchange-rates/settings:
    put:
      operationId: updateExchangeRateSettings
      tags: [ExchangeRates]
      summary: 更新匯率自動更新設定
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [autoUpdate]
              properties:
                autoUpdate:
                  type: boolean
      responses:
        "200":
          description: 設定已更新

  /api/exchange-rates/refresh:
    post:
      operationId: refreshExchangeRates
      tags: [ExchangeRates]
      summary: 從外部 API 自動取得最新匯率
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                currencies:
                  type: array
                  items:
                    type: string
      responses:
        "200":
          description: 更新成功

  # ── 帳戶管理 ──────────────────────────
  /api/accounts:
    get:
      operationId: getAccounts
      tags: [Accounts]
      summary: 取得帳戶清單(含餘額)
      responses:
        "200":
          description: 帳戶陣列
    post:
      operationId: createAccount
      tags: [Accounts]
      summary: 新增帳戶
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AccountInput"
      responses:
        "200":
          description: 新增成功

  /api/accounts/{id}:
    put:
      operationId: updateAccount
      tags: [Accounts]
      summary: 更新帳戶
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                initialBalance:
                  type: number
                icon:
                  type: string
                currency:
                  type: string
                accountType:
                  type: string
                excludeFromTotal:
                  type: boolean
                linkedBankId:
                  type: string
      responses:
        "200":
          description: 更新成功
    delete:
      operationId: deleteAccountEntry
      tags: [Accounts]
      summary: 刪除帳戶
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: 刪除成功

  /api/accounts/credit-card-repayment:
    post:
      operationId: creditCardRepayment
      tags: [Accounts]
      summary: 信用卡還款(輸入單一總金額,系統依欠款等比例分配到各張卡)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/RepaymentRequest"
      responses:
        "200":
          description: 還款成功,回傳本次實際寫入的分配結果
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RepaymentResponse"
        "400":
          description: 參數錯誤(InvalidTotalAmount/TotalAmountTooSmall/
            NoPayableCards)
        "500":
          description: 寫入失敗,本次全部未寫入(已回滾)

  /api/accounts/{id}/repayment-cards:
    get:
      operationId: getRepaymentCards
      tags: [Accounts]
      summary: 取得以此帳戶為付款來源時本次納入還款的信用卡欠款快照
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: 快照取得成功;cards 為空陣列代表本次無可還款卡片
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RepaymentCardsResponse"
        "400":
          description: 付款帳戶為信用卡(NotCreditCardPayer)
        "404":
          description: 帳戶不存在或不屬於本人

  /api/credit-card-repayment-summaries/{id}:
    get:
      operationId: getCreditCardRepaymentSummary
      tags: [Accounts]
      summary: 取得某次信用卡還款的分配摘要(含「已與現況不符」判定)
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: 摘要取得成功
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RepaymentSummaryResponse"
        "404":
          description: 摘要不存在或不屬於本人

  # ── 交易記錄 ──────────────────────────
  /api/transactions:
    get:
      operationId: getTransactions
      tags: [Transactions]
      summary: 取得交易記錄(支援篩選分頁)
      parameters:
        - name: dateFrom
          in: query
          schema:
            type: string
        - name: dateTo
          in: query
          schema:
            type: string
        - name: type
          in: query
          schema:
            type: string
        - name: categoryId
          in: query
          schema:
            type: string
        - name: accountId
          in: query
          schema:
            type: string
        - name: keyword
          in: query
          schema:
            type: string
        - name: page
          in: query
          schema:
            type: integer
        - name: limit
          in: query
          schema:
            type: integer
      responses:
        "200":
          description: |
            交易清單(含分頁資訊)。每筆交易含唯讀衍生欄位 repaymentSummaryId
            (非還款交易為空字串;非空代表該筆屬於某次信用卡還款摘要,可作為開啟摘要的入口)。
    post:
      operationId: createTransaction
      tags: [Transactions]
      summary: 新增交易
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/TransactionInput"
      responses:
        "200":
          description: 新增成功

  /api/transactions/{id}:
    put:
      operationId: updateTransaction
      tags: [Transactions]
      summary: 更新交易
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                type:
                  type: string
                  enum: [income, expense]
                amount:
                  type: number
                originalAmount:
                  type: number
                currency:
                  type: string
                fxRate:
                  type: number
                date:
                  type: string
                categoryId:
                  type: string
                accountId:
                  type: string
                note:
                  type: string
                excludeFromStats:
                  type: boolean
      responses:
        "200":
          description: 更新成功
    delete:
      operationId: deleteTransaction
      tags: [Transactions]
      summary: 刪除單筆交易
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: 刪除成功

  /api/transactions/{id}/restore-ai-created:
    post:
      operationId: restoreAiCreatedTransaction
      tags: [Transactions]
      summary: 還原(移除)一筆由 AI 透過 MCP 建立的交易
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: 已還原(交易與其連動對象皆已移除)
        "404":
          description: 找不到此交易,或不屬於目前使用者
        "409":
          description: 此交易並非 AI/MCP 建立(NotRestorable),未變更任何資料

  /api/transactions/{id}/restore-ai-note:
    get:
      operationId: previewAiNoteRestore
      tags: [Transactions]
      summary: 預覽「備註經 AI 修改」還原後的內容(唯讀)
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: 預覽內容
        "404":
          description: 找不到此交易,或不屬於目前使用者
    post:
      operationId: restoreAiNote
      tags: [Transactions]
      summary: 確認執行備註還原
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [expectedUpdatedAt]
              properties:
                expectedUpdatedAt:
                  type: integer
      responses:
        "200":
          description: 已還原
        "400":
          description: 缺少或格式錯誤的 expectedUpdatedAt
        "404":
          description: 找不到此交易,或不屬於目前使用者
        "409":
          description: NoteChanged(樂觀鎖衝突)或 NotRestorable(無可還原快照),皆不變更任何資料

  /api/transactions/transfer:
    post:
      operationId: createTransfer
      tags: [Transactions]
      summary: 帳戶間轉帳
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [fromId, toId, amount, date]
              properties:
                fromId:
                  type: string
                toId:
                  type: string
                amount:
                  type: number
                date:
                  type: string
                note:
                  type: string
                clientRef:
                  type: string
                  pattern: "^[a-f0-9]{32}$"
                  description: |
                    離線記帳的 idempotency key(007-pwa-offline-entry);同一 clientRef 重送
                    不產生重複的轉帳配對。線上直接新增時省略。
      responses:
        "200":
          description: 轉帳成功

  /api/transactions/suggestions:
    get:
      operationId: getTransactionCategorySuggestions
      tags: [Transactions]
      summary: 依摘要與歷史紀錄取得分類建議(Top-N 附信心度;僅提示,需使用者確認才寫入)
      parameters:
        - name: type
          in: query
          schema:
            type: string
            enum: [income, expense]
        - name: note
          in: query
          schema:
            type: string
            maxLength: 200
      responses:
        "200":
          description: 分類建議(enabled=false 代表使用者已關閉智慧輔助)
          content:
            application/json:
              schema:
                type: object
                properties:
                  enabled:
                    type: boolean
                  suggestions:
                    type: array
                    items:
                      type: object
                      properties:
                        categoryId:
                          type: string
                        categoryName:
                          type: string
                        parentName:
                          type: string
                          nullable: true
                        confidence:
                          type: number
                          minimum: 0
                          maximum: 1
                        matchedCount:
                          type: integer

  /api/transactions/batch-delete:
    post:
      operationId: batchDeleteTransactions
      tags: [Transactions]
      summary: 批次刪除交易
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/BatchIdsInput"
      responses:
        "200":
          description: 刪除成功

  /api/transactions/batch-update:
    post:
      operationId: batchUpdateTransactions
      tags: [Transactions]
      summary: 批次更新交易(分類 / 帳戶 / 日期)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ids, fields]
              properties:
                ids:
                  type: array
                  items:
                    type: string
                fields:
                  type: object
                  properties:
                    categoryId:
                      type: string
                    accountId:
                      type: string
                    date:
                      type: string
      responses:
        "200":
          description: 更新成功

  /api/transactions/import:
    post:
      operationId: importTransactions
      tags: [Transactions]
      summary: CSV 匯入交易記錄
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [rows]
              properties:
                rows:
                  type: array
                  items:
                    type: object
                    properties:
                      date:
                        type: string
                      type:
                        type: string
                      amount:
                        type: string
                      category:
                        type: string
                      account:
                        type: string
                      note:
                        type: string
      responses:
        "200":
          description: 匯入成功

  # ── 預算 ──────────────────────────────
  /api/budgets:
    get:
      operationId: getBudgets
      tags: [Budgets]
      summary: 取得預算清單(含月份使用量)
      parameters:
        - name: yearMonth
          in: query
          schema:
            type: string
      responses:
        "200":
          description: 預算清單
    post:
      operationId: createBudget
      tags: [Budgets]
      summary: 新增 / 更新預算
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/BudgetInput"
      responses:
        "200":
          description: 儲存成功

  /api/budgets/{id}:
    delete:
      operationId: deleteBudget
      tags: [Budgets]
      summary: 刪除預算
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: 刪除成功

  # ── 目標儲蓄與還款計畫(issue #260)──────────
  /api/goals:
    get:
      operationId: getSavingsGoals
      tags: [Goals]
      summary: 取得儲蓄目標清單(含進度與預估達成日)
      responses:
        "200":
          description: 儲蓄目標陣列
    post:
      operationId: createSavingsGoal
      tags: [Goals]
      summary: 新增儲蓄目標
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SavingsGoalInput"
      responses:
        "201":
          description: 建立成功,回傳含進度的目標
        "400":
          description: 驗證失敗

  /api/goals/{id}:
    get:
      operationId: getSavingsGoal
      tags: [Goals]
      summary: 取得單一儲蓄目標(含進度)
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: 目標詳細資料
        "404":
          description: 目標不存在或無權限
    put:
      operationId: updateSavingsGoal
      tags: [Goals]
      summary: 更新儲蓄目標(不變更起算日)
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SavingsGoalInput"
      responses:
        "200":
          description: 更新成功
        "400":
          description: 驗證失敗
        "404":
          description: 目標不存在或無權限
    delete:
      operationId: deleteSavingsGoal
      tags: [Goals]
      summary: 刪除儲蓄目標
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: 刪除成功
        "404":
          description: 目標不存在或無權限

  /api/repayment-plans:
    get:
      operationId: getRepaymentPlans
      tags: [Goals]
      summary: 取得還款計畫清單(摘要,不含完整攤還表)
      responses:
        "200":
          description: 還款計畫陣列
    post:
      operationId: createRepaymentPlan
      tags: [Goals]
      summary: 新增還款計畫
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/RepaymentPlanInput"
      responses:
        "201":
          description: 建立成功
        "400":
          description: 驗證失敗

  /api/repayment-plans/{id}:
    get:
      operationId: getRepaymentPlan
      tags: [Goals]
      summary: 取得單一還款計畫(含完整攤還表)
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: 還款計畫與攤還表
        "404":
          description: 計畫不存在或無權限
    put:
      operationId: updateRepaymentPlan
      tags: [Goals]
      summary: 更新還款計畫並重算攤還表
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/RepaymentPlanInput"
      responses:
        "200":
          description: 更新成功
        "400":
          description: 驗證失敗
        "404":
          description: 計畫不存在或無權限
    delete:
      operationId: deleteRepaymentPlan
      tags: [Goals]
      summary: 刪除還款計畫
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: 刪除成功
        "404":
          description: 計畫不存在或無權限

  # ── 固定收支 ──────────────────────────
  /api/recurring:
    get:
      operationId: getRecurring
      tags: [Recurring]
      summary: 取得固定收支清單
      responses:
        "200":
          description: 固定收支陣列
    post:
      operationId: createRecurring
      tags: [Recurring]
      summary: 新增固定收支
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/RecurringInput"
      responses:
        "200":
          description: 新增成功

  /api/recurring/{id}:
    put:
      operationId: updateRecurring
      tags: [Recurring]
      summary: 更新固定收支
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                type:
                  type: string
                  enum: [income, expense]
                amount:
                  type: number
                currency:
                  type: string
                fxRate:
                  type: number
                categoryId:
                  type: string
                accountId:
                  type: string
                frequency:
                  type: string
                  enum: [daily, weekly, monthly, yearly]
                startDate:
                  type: string
                note:
                  type: string
      responses:
        "200":
          description: 更新成功
    delete:
      operationId: deleteRecurring
      tags: [Recurring]
      summary: 刪除固定收支
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: 刪除成功

  /api/recurring/{id}/toggle:
    patch:
      operationId: toggleRecurring
      tags: [Recurring]
      summary: 啟用 / 停用固定收支
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: 狀態已切換

  /api/recurring/process:
    post:
      operationId: processRecurring
      tags: [Recurring]
      summary: 手動執行固定收支(產生到期交易)
      responses:
        "200":
          description: 執行結果

  /api/recurring/suggestions:
    get:
      operationId: getRecurringSuggestions
      tags: [Recurring]
      summary: 掃描交易找出疑似週期性群組(僅提示,需使用者確認才建立固定收支)
      responses:
        "200":
          description: 週期性建議(enabled=false 代表使用者已關閉智慧輔助)
          content:
            application/json:
              schema:
                type: object
                properties:
                  enabled:
                    type: boolean
                  suggestions:
                    type: array
                    items:
                      type: object
                      properties:
                        signature:
                          type: string
                        type:
                          type: string
                          enum: [income, expense]
                        amount:
                          type: number
                          description: 最新交易的 TWD 金額(供顯示與群組參考)
                        suggestedAmount:
                          type: number
                          description: 最近一筆交易的原幣金額(供新增表單預填)
                        currency:
                          type: string
                        fxRate:
                          type: string
                        frequency:
                          type: string
                          enum: [daily, weekly, monthly, yearly]
                        confidence:
                          type: number
                          minimum: 0
                          maximum: 1
                        occurrences:
                          type: integer
                        categoryId:
                          type: string
                          nullable: true
                        accountId:
                          type: string
                          nullable: true
                        firstDate:
                          type: string
                        lastDate:
                          type: string
                        suggestedStartDate:
                          type: string

  /api/recurring/suggestions/dismiss:
    post:
      operationId: dismissRecurringSuggestion
      tags: [Recurring]
      summary: 忽略某組週期性建議(僅影響提示,不改動任何交易或固定收支)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [signature]
              properties:
                signature:
                  type: string
      responses:
        "200":
          description: 已記錄忽略

  # ── 儀表板 / 報表 ─────────────────────
  /api/dashboard:
    get:
      operationId: getDashboard
      tags: [Dashboard]
      summary: 取得儀表板資料
      responses:
        "200":
          description: 儀表板統計資料

  /api/reports:
    get:
      operationId: getReports
      tags: [Dashboard]
      summary: 取得統計報表
      parameters:
        - name: type
          in: query
          schema:
            type: string
            enum: [income, expense]
        - name: from
          in: query
          schema:
            type: string
            format: date
        - name: to
          in: query
          schema:
            type: string
            format: date
        - name: baseCurrency
          in: query
          description: ISO 4217 基準幣別;未提供時沿用既有 TWD 等值行為
          schema:
            type: string
            pattern: '^[A-Za-z]{3}$'
            default: TWD
      responses:
        "200":
          description: 報表資料;指定非 TWD 基準時另含匯率來源、ISO 8601 UTC 時間戳與可用幣別
        "400":
          description: 日期區間無效、基準幣別不在 ISO 4217 白名單或匯率不可用

  /api/reports/export:
    get:
      operationId: exportReportsCsv
      tags: [Dashboard]
      summary: 匯出統計報表 CSV
      parameters:
        - name: type
          in: query
          schema:
            type: string
            enum: [income, expense]
            default: expense
        - name: from
          in: query
          schema:
            type: string
            format: date
        - name: to
          in: query
          schema:
            type: string
            format: date
        - name: baseCurrency
          in: query
          description: ISO 4217 基準幣別;未提供時沿用既有 TWD 等值行為
          schema:
            type: string
            pattern: '^[A-Za-z]{3}$'
            default: TWD
      responses:
        "200":
          description: UTF-8 BOM CSV,含所選基準幣別、匯率來源與時間戳
          content:
            text/csv:
              schema:
                type: string
        "400":
          description: 日期區間無效、基準幣別不在 ISO 4217 白名單或匯率不可用

  # ── TWSE 台股 API ─────────────────────
  /api/twse/stock/{symbol}:
    get:
      operationId: getTwseStock
      tags: [TWSE]
      summary: 查詢台股股票即時 / 收盤價與基本資料
      parameters:
        - name: symbol
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: 股票資料
        "404":
          description: 股票不存在

  /api/twse/search:
    get:
      operationId: searchTwseStock
      tags: [TWSE]
      summary: 搜尋台股股票代號或名稱
      parameters:
        - name: q
          in: query
          required: true
          schema:
            type: string
      responses:
        "200":
          description: 搜尋結果清單

  # ── 股票清單 ──────────────────────────
  /api/stocks:
    get:
      operationId: getStocks
      tags: [Stocks]
      summary: 取得持股清單(含損益)
      responses:
        "200":
          description: 股票陣列
    post:
      operationId: createStock
      tags: [Stocks]
      summary: 新增股票
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/StockInput"
      responses:
        "200":
          description: 新增成功

  /api/stocks/{id}/history:
    get:
      operationId: getStockPriceHistory
      tags: [Stocks]
      summary: 取得台股歷史日 K 線與目前使用者的交易點位
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
        - name: from
          in: query
          description: 起始日期(預設為一年前)
          schema:
            type: string
            format: date
        - name: to
          in: query
          description: 結束日期(預設為今天)
          schema:
            type: string
            format: date
      responses:
        "200":
          description: 歷史股價與個人交易點位
          content:
            application/json:
              schema:
                type: object
                properties:
                  symbol:
                    type: string
                  name:
                    type: string
                  market:
                    type: string
                    enum: [TW]
                  from:
                    type: string
                    format: date
                  to:
                    type: string
                    format: date
                  candles:
                    type: array
                    items:
                      type: object
                      properties:
                        date:
                          type: string
                          format: date
                        open:
                          type: number
                        high:
                          type: number
                        low:
                          type: number
                        close:
                          type: number
                        volume:
                          type: number
                          description: 成交股數(櫃買成交張數已換算為股)
                  transactions:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: string
                        date:
                          type: string
                          format: date
                        type:
                          type: string
                          enum: [buy, sell]
                        shares:
                          type: number
                        price:
                          type: number
        "400":
          description: 日期無效、查詢期間過長或不支援的市場
        "404":
          description: 股票不存在或不屬於目前使用者
        "503":
          description: 歷史股價服務暫時無法回應

  /api/stocks/{id}:
    put:
      operationId: updateStock
      tags: [Stocks]
      summary: 更新股票基本資料
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                symbol:
                  type: string
                name:
                  type: string
                stockType:
                  type: string
                  enum: [stock, etf, warrant]
      responses:
        "200":
          description: 更新成功
    delete:
      operationId: deleteStock
      tags: [Stocks]
      summary: 刪除股票
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: 刪除成功

  /api/stocks/batch-price:
    post:
      operationId: batchUpdateStockPrice
      tags: [Stocks]
      summary: 批次更新股票現價
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [symbols]
              properties:
                symbols:
                  type: array
                  items:
                    type: string
      responses:
        "200":
          description: 更新結果

  /api/stocks/cleanup:
    post:
      operationId: cleanupStocks
      tags: [Stocks]
      summary: 清除無持倉且無交易紀錄的股票
      responses:
        "200":
          description: 清除結果

  # ── 股票交易紀錄 ──────────────────────
  /api/stock-transactions:
    get:
      operationId: getStockTransactions
      tags: [StockTransactions]
      summary: 取得股票交易紀錄(支援篩選分頁)
      parameters:
        - name: stockId
          in: query
          schema:
            type: string
        - name: dateFrom
          in: query
          schema:
            type: string
        - name: dateTo
          in: query
          schema:
            type: string
        - name: page
          in: query
          schema:
            type: integer
        - name: pageSize
          in: query
          schema:
            type: integer
        - name: sortBy
          in: query
          schema:
            type: string
        - name: sortDir
          in: query
          schema:
            type: string
            enum: [asc, desc]
      responses:
        "200":
          description: 交易紀錄清單
    post:
      operationId: createStockTransaction
      tags: [StockTransactions]
      summary: 新增股票買賣交易
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/StockTransactionInput"
      responses:
        "200":
          description: 新增成功

  /api/stock-transactions/{id}:
    put:
      operationId: updateStockTransaction
      tags: [StockTransactions]
      summary: 更新股票交易
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                stockId:
                  type: string
                date:
                  type: string
                type:
                  type: string
                  enum: [buy, sell]
                shares:
                  type: number
                price:
                  type: number
                fee:
                  type: number
                tax:
                  type: number
                dayTrade:
                  type: boolean
                  description: 現股當沖(僅 2017-04-28 至 2027-12-31 的台股一般股票賣出適用);需有同證券帳戶、同日、同標的且股數足夠的現款買進,證交稅依證券交易稅條例第 2 條之 2 以 0.15% 計算。
                accountId:
                  type: string
                note:
                  type: string
      responses:
        "200":
          description: 更新成功
    delete:
      operationId: deleteStockTransaction
      tags: [StockTransactions]
      summary: 刪除股票交易
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: 刪除成功

  /api/stock-transactions/batch-delete:
    post:
      operationId: batchDeleteStockTransactions
      tags: [StockTransactions]
      summary: 批次刪除股票交易
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/BatchIdsInput"
      responses:
        "200":
          description: 刪除成功

  /api/stock-transactions/import:
    post:
      operationId: importStockTransactions
      tags: [StockTransactions]
      summary: CSV 匯入股票交易紀錄
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [rows]
              properties:
                rows:
                  type: array
                  items:
                    type: object
                    properties:
                      date:
                        type: string
                      symbol:
                        type: string
                      name:
                        type: string
                      type:
                        type: string
                      shares:
                        type: string
                      price:
                        type: string
                      fee:
                        type: string
                      tax:
                        type: string
                      account:
                        type: string
                      note:
                        type: string
      responses:
        "200":
          description: 匯入成功

  # ── 股利紀錄 ──────────────────────────
  /api/stock-dividends:
    get:
      operationId: getStockDividends
      tags: [StockDividends]
      summary: 取得股利紀錄
      parameters:
        - name: stockId
          in: query
          schema:
            type: string
        - name: page
          in: query
          schema:
            type: integer
        - name: pageSize
          in: query
          schema:
            type: integer
      responses:
        "200":
          description: 股利清單
    post:
      operationId: createStockDividend
      tags: [StockDividends]
      summary: 新增股利紀錄
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/StockDividendInput"
      responses:
        "200":
          description: 新增成功

  /api/stock-dividends/{id}:
    put:
      operationId: updateStockDividend
      tags: [StockDividends]
      summary: 更新股利紀錄
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                stockId:
                  type: string
                date:
                  type: string
                cashDividend:
                  type: number
                stockDividendShares:
                  type: number
                reinvest:
                  type: boolean
                  description: 股利再投資(DRIP),以現金股利再買入同一標的並計入 FIFO 成本基礎。
                reinvestShares:
                  type: number
                  description: 再投資股數(reinvest = true 時需為正數;台股必須為整數)。
                reinvestPrice:
                  type: number
                  description: 再投資每股價格(reinvest = true 時需為正數)。
                accountId:
                  type: string
                note:
                  type: string
      responses:
        "200":
          description: 更新成功
    delete:
      operationId: deleteStockDividend
      tags: [StockDividends]
      summary: 刪除股利紀錄
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: 刪除成功

  /api/stock-dividends/batch-delete:
    post:
      operationId: batchDeleteStockDividends
      tags: [StockDividends]
      summary: 批次刪除股利紀錄
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/BatchIdsInput"
      responses:
        "200":
          description: 刪除成功

  /api/stock-dividends/import:
    post:
      operationId: importStockDividends
      tags: [StockDividends]
      summary: CSV 匯入股利紀錄
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [rows]
              properties:
                rows:
                  type: array
                  items:
                    type: object
                    properties:
                      date:
                        type: string
                      symbol:
                        type: string
                      name:
                        type: string
                      cashDividend:
                        type: string
                      stockDividendShares:
                        type: string
                      note:
                        type: string
      responses:
        "200":
          description: 匯入成功

  /api/stock-dividends/sync:
    post:
      operationId: syncStockDividends
      tags: [StockDividends]
      summary: 從 TWSE 自動同步除權息股利紀錄
      responses:
        "200":
          description: 同步結果

  # ── 實現損益 ──────────────────────────
  /api/stock-realized:
    get:
      operationId: getStockRealized
      tags: [StockRealized]
      summary: 取得已實現損益(FIFO 計算)
      parameters:
        - name: stockId
          in: query
          schema:
            type: string
        - name: dateFrom
          in: query
          schema:
            type: string
        - name: dateTo
          in: query
          schema:
            type: string
      responses:
        "200":
          description: 損益清單與彙總

  # ── 股票設定 ──────────────────────────
  /api/stock-settings:
    get:
      operationId: getStockSettings
      tags: [StockSettings]
      summary: 取得股票手續費 / 稅率設定
      responses:
        "200":
          description: 設定物件
    put:
      operationId: updateStockSettings
      tags: [StockSettings]
      summary: 更新股票手續費 / 稅率設定
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                feeRate:
                  type: number
                feeDiscount:
                  type: number
                feeMinLot:
                  type: integer
                feeMinOdd:
                  type: integer
                sellTaxRateStock:
                  type: number
                sellTaxRateEtf:
                  type: number
                sellTaxRateWarrant:
                  type: number
                sellTaxMin:
                  type: integer
      responses:
        "200":
          description: 設定已更新

  # ── 股票固定扣款 ──────────────────────
  /api/stock-recurring:
    get:
      operationId: getStockRecurring
      tags: [StockRecurring]
      summary: 取得定期定額清單
      responses:
        "200":
          description: 定期定額清單
    post:
      operationId: createStockRecurring
      tags: [StockRecurring]
      summary: 新增定期定額
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [stockId, amount, frequency, startDate]
              properties:
                stockId:
                  type: string
                amount:
                  type: number
                frequency:
                  type: string
                  enum: [weekly, monthly]
                startDate:
                  type: string
                accountId:
                  type: string
                note:
                  type: string
      responses:
        "200":
          description: 新增成功

  /api/stock-recurring/{id}:
    put:
      operationId: updateStockRecurring
      tags: [StockRecurring]
      summary: 更新定期定額
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                amount:
                  type: number
                frequency:
                  type: string
                startDate:
                  type: string
                accountId:
                  type: string
                note:
                  type: string
      responses:
        "200":
          description: 更新成功
    delete:
      operationId: deleteStockRecurring
      tags: [StockRecurring]
      summary: 刪除定期定額
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: 刪除成功

  /api/stock-recurring/{id}/toggle:
    patch:
      operationId: toggleStockRecurring
      tags: [StockRecurring]
      summary: 啟用 / 停用定期定額
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: 狀態已切換

  /api/stock-recurring/process:
    post:
      operationId: processStockRecurring
      tags: [StockRecurring]
      summary: 手動執行定期定額
      responses:
        "200":
          description: 執行結果

  # ── MCP 寫入權限(新增資料/更新備註,003-mcp-write-no-delete +
  # 004-mcp-update-notes-only) ─────────────────────────────────────────────
  /api/user/mcp-credentials/{id}:
    patch:
      operationId: setMcpCredentialPermissions
      tags: [McpCredentials]
      summary: 開啟或關閉指定 PAT 憑證的 MCP 寫入權限(新增資料/更新備註)
      description: |
        變更即時生效於後續 MCP 連線,不影響此憑證先前已成功建立的交易或已更新的備註。
        兩種權限彼此獨立,可分別開關;未於 body 提供的權限維持原狀。
        僅能操作使用者本人名下的憑證,找不到時回傳 404。
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/McpCredentialPermissionUpdate"
      responses:
        "200":
          description: 已更新
          content:
            application/json:
              schema:
                type: object
                properties:
                  credential:
                    $ref: "#/components/schemas/McpCredential"
        "400":
          description: 請求格式錯誤(未提供任何權限欄位,或欄位非布林值)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: 找不到此憑證
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /api/user/mcp-oauth-connections:
    get:
      operationId: listMcpOAuthConnections
      tags: [McpOAuthConnections]
      summary: 列出使用者目前已連接的 AI 工具(依 OAuth client 分組)
      description: |
        每一列代表使用者目前仍持有有效授權的 AI 工具(以 client_id 為單位)——判定為「存在至少
        一項未撤銷且未過期的 token」。授權被撤銷或全數過期者不會出現在此清單;其權限設定仍保留在
        伺服器端,但日後重新授權時會重置為關閉。清單本身獨立於 access/refresh token 的核發與輪替週期。
      responses:
        "200":
          description: 已連接的 AI 工具清單
          content:
            application/json:
              schema:
                type: object
                properties:
                  connections:
                    type: array
                    items:
                      $ref: "#/components/schemas/McpOAuthConnection"

  /api/user/mcp-oauth-connections/{clientId}:
    patch:
      operationId: setMcpOAuthConnectionPermissions
      tags: [McpOAuthConnections]
      summary: 開啟或關閉指定已連接 AI 工具的 MCP 寫入權限(新增資料/更新備註)
      description: |
        變更立即生效,不需使用者重新走一次 OAuth 授權流程。兩種權限彼此獨立,可分別開關;
        未於 body 提供的權限維持原狀。僅能操作使用者本人名下的連線,找不到時回傳 404。
      parameters:
        - name: clientId
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/McpOAuthConnectionPermissionUpdate"
      responses:
        "200":
          description: 已更新
          content:
            application/json:
              schema:
                type: object
                properties:
                  connection:
                    $ref: "#/components/schemas/McpOAuthConnection"
        "400":
          description: 請求格式錯誤(未提供任何權限欄位,或欄位非布林值)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: 找不到此連線
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /api/transactions/receipt-ocr:
    post:
      operationId: recognizeReceipt
      tags: [Transactions]
      summary: 收據影像辨識(草稿預填,不寫入資料庫)
      description: |
        以設定的 OCR 供應商辨識收據影像,回傳金額/日期/店家草稿。
        結果僅供前端預填,使用者確認後才由 POST /api/transactions 儲存。
        未設定供應商或辨識失敗時回傳 `status: unavailable` / `failed`,前端降級為手動輸入。
        讀取既有附件時沿用 photoCrypto 解密與 S3 存取控制,直接上傳的影像不落地儲存。
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: |
                兩種來源擇一:(a) 同時提供 imageBase64 與 mimeType;
                (b) 同時提供 transactionId 與 attachmentId 以讀取既有附件(MIME type 從附件資料取得)。
              oneOf:
                - required: [imageBase64, mimeType]
                - required: [transactionId, attachmentId]
              properties:
                imageBase64:
                  type: string
                  description: "收據影像的 base64(不含 data URI 前綴)"
                mimeType:
                  type: string
                  description: "影像 MIME type(搭配 imageBase64 使用)"
                transactionId:
                  type: string
                  description: "既有交易 id(改讀已加密存放的附件)"
                attachmentId:
                  type: string
                  description: "既有附件 id(需與 transactionId 同時提供)"
      responses:
        "200":
          description: 辨識結果(草稿;即使失敗亦回 200 並帶 status)
          content:
            application/json:
              schema:
                type: object
                required: [status, provider, draft, warnings]
                properties:
                  status:
                    type: string
                    enum: [ok, unavailable, failed]
                  provider:
                    type: string
                    enum: [none, http]
                  draft:
                    type: object
                    nullable: true
                    properties:
                      amount:
                        type: number
                        nullable: true
                      currency:
                        type: string
                        nullable: true
                      date:
                        type: string
                        nullable: true
                        description: 嚴格 ISO `YYYY-MM-DD`
                      merchant:
                        type: string
                        nullable: true
                  warnings:
                    type: array
                    items:
                      type: string
                  message:
                    type: string
        "400":
          description: 請求內容格式無效或未提供影像
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: 找不到該附件(或非本人所有)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "413":
          description: 影像超過 OCR 大小上限
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  # ── 管理員 / 系統 / 資料庫備份(不經過 Cloudflare,不納入 API Shield) ──
  # /api/admin/*        — 管理員設定、使用者管理、登入紀錄、憑證管理
  # /api/system/*       — 系統更新
  # /api/database/*     — 資料庫匯出匯入備份
  #
  # 以上端點僅供內部管理使用,詳見 server.js。

# (EOF) 管理員端點不納入此 Schema