openapi: 3.0.3
info:
  title: duofund-server API
  version: "1.0.0"
  description: |
    duofund 가계부 앱 서버 API 초안.
    기준 문서: docs/api-spec.md
servers:
  - url: /api/v1
    description: API v1
tags:
  - name: Auth
  - name: Group
  - name: Account
  - name: Category
  - name: Transaction
  - name: Budget
  - name: Allowance
  - name: Event
  - name: Snapshot
  - name: Stats
security:
  - BearerAuth: []
paths:
  /auth/signup:
    post:
      tags: [Auth]
      summary: 회원가입
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SignupRequest'
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UserResponse'
        '400':
          $ref: '#/components/responses/BadRequest'

  /auth/login:
    post:
      tags: [Auth]
      summary: 로그인
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/LoginRequest'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TokenPairResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /auth/refresh:
    post:
      tags: [Auth]
      summary: 토큰 재발급
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RefreshTokenRequest'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AccessTokenResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /groups:
    post:
      tags: [Group]
      summary: 그룹 생성
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateGroupRequest'
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/GroupResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /groups/join:
    post:
      tags: [Group]
      summary: 초대코드로 그룹 참여
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/JoinGroupRequest'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/JoinGroupResponse'
        '404':
          $ref: '#/components/responses/NotFound'

  /groups/{groupId}/members:
    get:
      tags: [Group]
      summary: 그룹 멤버 조회
      parameters:
        - $ref: '#/components/parameters/GroupId'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/GroupMembersResponse'
        '403':
          $ref: '#/components/responses/Forbidden'

  /groups/{groupId}/accounts:
    post:
      tags: [Account]
      summary: 계좌 생성
      parameters:
        - $ref: '#/components/parameters/GroupId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateAccountRequest'
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AccountResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
    get:
      tags: [Account]
      summary: 계좌 목록 조회
      parameters:
        - $ref: '#/components/parameters/GroupId'
        - name: ownerType
          in: query
          required: false
          schema:
            $ref: '#/components/schemas/AccountOwnerType'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AccountListResponse'

  /groups/{groupId}/categories:
    post:
      tags: [Category]
      summary: 카테고리 생성
      parameters:
        - $ref: '#/components/parameters/GroupId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateCategoryRequest'
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CategoryResponse'
    get:
      tags: [Category]
      summary: 카테고리 목록 조회
      parameters:
        - $ref: '#/components/parameters/GroupId'
        - name: transactionType
          in: query
          required: false
          schema:
            $ref: '#/components/schemas/TransactionType'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CategoryListResponse'

  /groups/{groupId}/transactions:
    post:
      tags: [Transaction]
      summary: 거래 생성
      parameters:
        - $ref: '#/components/parameters/GroupId'
        - name: Idempotency-Key
          in: header
          required: false
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateTransactionRequest'
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TransactionCreatedResponse'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
    get:
      tags: [Transaction]
      summary: 거래 조회/검색
      parameters:
        - $ref: '#/components/parameters/GroupId'
        - $ref: '#/components/parameters/FromDate'
        - $ref: '#/components/parameters/ToDate'
        - name: type
          in: query
          required: false
          schema:
            $ref: '#/components/schemas/TransactionType'
        - name: categoryId
          in: query
          required: false
          schema:
            type: string
            format: uuid
        - name: accountId
          in: query
          required: false
          schema:
            type: string
            format: uuid
        - name: q
          in: query
          required: false
          schema:
            type: string
        - $ref: '#/components/parameters/Page'
        - $ref: '#/components/parameters/Size'
        - $ref: '#/components/parameters/Sort'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TransactionPageResponse'

  /groups/{groupId}/budgets/{yearMonth}:
    put:
      tags: [Budget]
      summary: 월 예산 저장(생성/수정)
      parameters:
        - $ref: '#/components/parameters/GroupId'
        - $ref: '#/components/parameters/YearMonth'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpsertBudgetRequest'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BudgetResponse'
        '409':
          $ref: '#/components/responses/Conflict'
    get:
      tags: [Budget]
      summary: 월 예산 조회
      parameters:
        - $ref: '#/components/parameters/GroupId'
        - $ref: '#/components/parameters/YearMonth'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BudgetResponse'

  /groups/{groupId}/allowances/{memberId}/{yearMonth}:
    put:
      tags: [Allowance]
      summary: 멤버 월 용돈 저장
      parameters:
        - $ref: '#/components/parameters/GroupId'
        - $ref: '#/components/parameters/MemberId'
        - $ref: '#/components/parameters/YearMonth'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpsertAllowanceRequest'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AllowanceResponse'

  /groups/{groupId}/allowances:
    get:
      tags: [Allowance]
      summary: 월 용돈 목록 조회
      parameters:
        - $ref: '#/components/parameters/GroupId'
        - $ref: '#/components/parameters/YearMonthQuery'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AllowanceListResponse'

  /groups/{groupId}/events:
    post:
      tags: [Event]
      summary: 이벤트 생성
      parameters:
        - $ref: '#/components/parameters/GroupId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateEventRequest'
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EventResponse'
    get:
      tags: [Event]
      summary: 이벤트 목록 조회
      parameters:
        - $ref: '#/components/parameters/GroupId'
        - $ref: '#/components/parameters/FromDate'
        - $ref: '#/components/parameters/ToDate'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EventListResponse'

  /groups/{groupId}/snapshots/{yearMonth}:
    get:
      tags: [Snapshot]
      summary: 월 스냅샷 조회
      parameters:
        - $ref: '#/components/parameters/GroupId'
        - $ref: '#/components/parameters/YearMonth'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MonthlySnapshotResponse'

  /groups/{groupId}/stats/categories:
    get:
      tags: [Stats]
      summary: 월 카테고리 통계 조회
      parameters:
        - $ref: '#/components/parameters/GroupId'
        - $ref: '#/components/parameters/YearMonthQuery'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CategoryStatsResponse'

components:
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT

  parameters:
    GroupId:
      name: groupId
      in: path
      required: true
      schema:
        type: string
        format: uuid
    MemberId:
      name: memberId
      in: path
      required: true
      schema:
        type: string
        format: uuid
    YearMonth:
      name: yearMonth
      in: path
      required: true
      schema:
        type: string
        pattern: '^\\d{4}-(0[1-9]|1[0-2])$'
    YearMonthQuery:
      name: yearMonth
      in: query
      required: true
      schema:
        type: string
        pattern: '^\\d{4}-(0[1-9]|1[0-2])$'
    FromDate:
      name: from
      in: query
      required: false
      schema:
        type: string
        format: date
    ToDate:
      name: to
      in: query
      required: false
      schema:
        type: string
        format: date
    Page:
      name: page
      in: query
      required: false
      schema:
        type: integer
        minimum: 0
        default: 0
    Size:
      name: size
      in: query
      required: false
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 20
    Sort:
      name: sort
      in: query
      required: false
      schema:
        type: string
        example: occurredAt,desc

  responses:
    BadRequest:
      description: Bad Request
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    Unauthorized:
      description: Unauthorized
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    Forbidden:
      description: Forbidden
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    NotFound:
      description: Not Found
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    Conflict:
      description: Conflict
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    UnprocessableEntity:
      description: Unprocessable Entity
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'

  schemas:
    ErrorResponse:
      type: object
      properties:
        code:
          type: string
          example: VALIDATION_ERROR
        message:
          type: string
          example: 요청 값이 올바르지 않습니다.
        traceId:
          type: string
          example: f3b9ab7f7c1447d8
        timestamp:
          type: string
          format: date-time
      required: [code, message, traceId, timestamp]

    AccountOwnerType:
      type: string
      enum: [GROUP, USER]
    AccountType:
      type: string
      enum: [CHECKING, SAVINGS, INVESTMENT, ALLOWANCE]
    TransactionType:
      type: string
      enum: [INCOME, EXPENSE, TRANSFER]
    GroupRole:
      type: string
      enum: [OWNER, MEMBER]
    EventType:
      type: string
      enum: [BIRTHDAY, ANNIVERSARY, CEREMONY, ETC]

    SignupRequest:
      type: object
      properties:
        email:
          type: string
          format: email
        password:
          type: string
          minLength: 8
        nickname:
          type: string
          maxLength: 80
      required: [email, password, nickname]

    UserResponse:
      type: object
      properties:
        userId:
          type: string
          format: uuid
        email:
          type: string
          format: email
        nickname:
          type: string
      required: [userId, email, nickname]

    LoginRequest:
      type: object
      properties:
        email:
          type: string
          format: email
        password:
          type: string
      required: [email, password]

    RefreshTokenRequest:
      type: object
      properties:
        refreshToken:
          type: string
      required: [refreshToken]

    TokenPairResponse:
      type: object
      properties:
        accessToken:
          type: string
        refreshToken:
          type: string
        expiresIn:
          type: integer
          example: 3600
      required: [accessToken, refreshToken, expiresIn]

    AccessTokenResponse:
      type: object
      properties:
        accessToken:
          type: string
        expiresIn:
          type: integer
      required: [accessToken, expiresIn]

    CreateGroupRequest:
      type: object
      properties:
        name:
          type: string
          maxLength: 120
      required: [name]

    GroupResponse:
      type: object
      properties:
        groupId:
          type: string
          format: uuid
        name:
          type: string
        inviteCode:
          type: string
        myRole:
          $ref: '#/components/schemas/GroupRole'
      required: [groupId, name, inviteCode, myRole]

    JoinGroupRequest:
      type: object
      properties:
        inviteCode:
          type: string
      required: [inviteCode]

    JoinGroupResponse:
      type: object
      properties:
        groupId:
          type: string
          format: uuid
        myRole:
          $ref: '#/components/schemas/GroupRole'
      required: [groupId, myRole]

    GroupMemberItem:
      type: object
      properties:
        userId:
          type: string
          format: uuid
        nickname:
          type: string
        role:
          $ref: '#/components/schemas/GroupRole'
      required: [userId, nickname, role]

    GroupMembersResponse:
      type: object
      properties:
        members:
          type: array
          items:
            $ref: '#/components/schemas/GroupMemberItem'
      required: [members]

    CreateAccountRequest:
      type: object
      properties:
        name:
          type: string
          maxLength: 80
        type:
          $ref: '#/components/schemas/AccountType'
        ownerType:
          $ref: '#/components/schemas/AccountOwnerType'
        ownerUserId:
          type: string
          format: uuid
          nullable: true
        openingBalance:
          type: integer
          format: int64
          minimum: 0
      required: [name, type, ownerType, openingBalance]

    AccountResponse:
      type: object
      properties:
        accountId:
          type: string
          format: uuid
        name:
          type: string
        ownerType:
          $ref: '#/components/schemas/AccountOwnerType'
        balance:
          type: integer
          format: int64
        version:
          type: integer
          format: int64
      required: [accountId, name, ownerType, balance, version]

    AccountListResponse:
      type: object
      properties:
        accounts:
          type: array
          items:
            $ref: '#/components/schemas/AccountResponse'
      required: [accounts]

    CreateCategoryRequest:
      type: object
      properties:
        transactionType:
          $ref: '#/components/schemas/TransactionType'
        name:
          type: string
          maxLength: 80
      required: [transactionType, name]

    CategoryResponse:
      type: object
      properties:
        categoryId:
          type: string
          format: uuid
        transactionType:
          $ref: '#/components/schemas/TransactionType'
        name:
          type: string
      required: [categoryId, transactionType, name]

    CategoryListResponse:
      type: object
      properties:
        categories:
          type: array
          items:
            $ref: '#/components/schemas/CategoryResponse'
      required: [categories]

    TransactionSplitRequest:
      type: object
      properties:
        beneficiaryUserId:
          type: string
          format: uuid
        shareAmount:
          type: integer
          format: int64
          minimum: 1
      required: [beneficiaryUserId, shareAmount]

    CreateTransactionRequest:
      type: object
      properties:
        type:
          $ref: '#/components/schemas/TransactionType'
        accountId:
          type: string
          format: uuid
        counterpartyAccountId:
          type: string
          format: uuid
          nullable: true
        categoryId:
          type: string
          format: uuid
          nullable: true
        amount:
          type: integer
          format: int64
          minimum: 1
        occurredAt:
          type: string
          format: date-time
        memo:
          type: string
          maxLength: 500
          nullable: true
        eventId:
          type: string
          format: uuid
          nullable: true
        splits:
          type: array
          items:
            $ref: '#/components/schemas/TransactionSplitRequest'
      required: [type, accountId, amount, occurredAt]

    TransactionCreatedResponse:
      type: object
      properties:
        transactionId:
          type: string
          format: uuid
        type:
          $ref: '#/components/schemas/TransactionType'
        amount:
          type: integer
          format: int64
        accountBalanceAfter:
          type: integer
          format: int64
        createdAt:
          type: string
          format: date-time
      required: [transactionId, type, amount, accountBalanceAfter, createdAt]

    TransactionItem:
      type: object
      properties:
        transactionId:
          type: string
          format: uuid
        type:
          $ref: '#/components/schemas/TransactionType'
        amount:
          type: integer
          format: int64
        categoryName:
          type: string
          nullable: true
        occurredAt:
          type: string
          format: date-time
        memo:
          type: string
          nullable: true
      required: [transactionId, type, amount, occurredAt]

    TransactionPageResponse:
      type: object
      properties:
        content:
          type: array
          items:
            $ref: '#/components/schemas/TransactionItem'
        page:
          type: integer
        size:
          type: integer
        totalElements:
          type: integer
          format: int64
        totalPages:
          type: integer
      required: [content, page, size, totalElements, totalPages]

    BudgetItemRequest:
      type: object
      properties:
        categoryId:
          type: string
          format: uuid
        limitAmount:
          type: integer
          format: int64
          minimum: 0
      required: [categoryId, limitAmount]

    UpsertBudgetRequest:
      type: object
      properties:
        totalAmount:
          type: integer
          format: int64
          minimum: 0
        version:
          type: integer
          format: int64
        items:
          type: array
          items:
            $ref: '#/components/schemas/BudgetItemRequest'
      required: [totalAmount, version]

    BudgetResponse:
      type: object
      properties:
        groupId:
          type: string
          format: uuid
        yearMonth:
          type: string
        totalAmount:
          type: integer
          format: int64
        usedAmount:
          type: integer
          format: int64
        remainingAmount:
          type: integer
          format: int64
        version:
          type: integer
          format: int64
      required: [groupId, yearMonth, totalAmount, usedAmount, remainingAmount, version]

    UpsertAllowanceRequest:
      type: object
      properties:
        limitAmount:
          type: integer
          format: int64
          minimum: 0
        version:
          type: integer
          format: int64
      required: [limitAmount, version]

    AllowanceResponse:
      type: object
      properties:
        memberId:
          type: string
          format: uuid
        yearMonth:
          type: string
        limitAmount:
          type: integer
          format: int64
        usedAmount:
          type: integer
          format: int64
        isExceeded:
          type: boolean
        version:
          type: integer
          format: int64
      required: [memberId, yearMonth, limitAmount, usedAmount, isExceeded, version]

    AllowanceListResponse:
      type: object
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/AllowanceResponse'
      required: [items]

    CreateEventRequest:
      type: object
      properties:
        type:
          $ref: '#/components/schemas/EventType'
        title:
          type: string
          maxLength: 120
        eventDate:
          type: string
          format: date
        budgetAmount:
          type: integer
          format: int64
          minimum: 0
          nullable: true
        memo:
          type: string
          maxLength: 500
          nullable: true
      required: [type, title, eventDate]

    EventResponse:
      type: object
      properties:
        eventId:
          type: string
          format: uuid
        type:
          $ref: '#/components/schemas/EventType'
        title:
          type: string
        eventDate:
          type: string
          format: date
      required: [eventId, type, title, eventDate]

    EventListResponse:
      type: object
      properties:
        events:
          type: array
          items:
            $ref: '#/components/schemas/EventResponse'
      required: [events]

    SnapshotAccountItem:
      type: object
      properties:
        accountId:
          type: string
          format: uuid
        name:
          type: string
        balance:
          type: integer
          format: int64
      required: [accountId, name, balance]

    MonthlySnapshotResponse:
      type: object
      properties:
        groupId:
          type: string
          format: uuid
        yearMonth:
          type: string
        totalAsset:
          type: integer
          format: int64
        totalDebt:
          type: integer
          format: int64
        netAsset:
          type: integer
          format: int64
        accounts:
          type: array
          items:
            $ref: '#/components/schemas/SnapshotAccountItem'
        generatedAt:
          type: string
          format: date-time
      required: [groupId, yearMonth, totalAsset, totalDebt, netAsset, accounts, generatedAt]

    CategoryStatItem:
      type: object
      properties:
        categoryId:
          type: string
          format: uuid
        categoryName:
          type: string
        totalExpense:
          type: integer
          format: int64
      required: [categoryId, categoryName, totalExpense]

    CategoryStatsResponse:
      type: object
      properties:
        yearMonth:
          type: string
        items:
          type: array
          items:
            $ref: '#/components/schemas/CategoryStatItem'
      required: [yearMonth, items]
