openapi: 3.0.3
info:
  title: Okitour B2B API
  version: 1.0.0
  description: |
    Net-rate booking API for approved Okitour travel-agency partners.

    ## Basics
    - Base URL: `https://b2b.okitour.com/api/b2b` (the web site and the API use the same endpoints)
    - All amounts are **JPY integers (net rates, tax included)**, rounded to 1 yen.
    - Response envelope: `{ "success": true, "data": ... }` or `{ "success": false, "error": { "code", "message" } }`
    - `lang` (query or body): `kr` | `jp` | `en` | `tw` | `cn`. Only products sold in that language are returned.

    ## Authentication
    Issue an API key on **Company → API keys** and send both headers on every request:
    ```
    X-API-Key: okb2b_xxxxxxxxxxxxxxxxxxxx
    X-API-Secret: <secret shown once at issue time>
    ```
    Keys can be restricted to an IP allow-list.

    ## Booking flow
    1. `GET /products` or `POST /rentacar/search` to find inventory
    2. `GET /products/{id}/availability` to check dates
    3. `POST /quotes` to price the exact selection (optional but recommended)
    4. `POST /bookings` with an `Idempotency-Key` header. Deposit payments are confirmed immediately.
       Card payments (+3%) return `redirect_url` for 3-D Secure; the traveller's browser must open it.
    5. Receive webhooks or poll `GET /bookings/{id}`

    ## Idempotency
    Send a unique `Idempotency-Key` (max 100 chars) on `POST /bookings`. Repeating the same key returns the
    original booking with `"replay": true` instead of creating a duplicate.

    ## Webhooks
    Set a https URL on **Company → Webhook**. Events: `booking.confirmed`, `booking.cancel_requested`, `booking.canceled`.
    Body: `{ "event", "created_at", "data": Booking }`. Headers: `X-Okitour-Event`,
    `X-Okitour-Signature: sha256=<hex HMAC-SHA256 of the raw body using your webhook secret>`.

    ## Cancellation
    `POST /bookings/{id}/cancel` creates a cancellation request; Okitour confirms it and refunds
    (net total − cancellation fee) to your deposit. Card fees are not refunded.
servers:
  - url: https://b2b.okitour.com/api/b2b
security:
  - ApiKey: []
    ApiSecret: []
tags:
  - name: Catalog
  - name: Rent-a-car
  - name: Booking
  - name: Deposit
  - name: Account
paths:
  /auth/me:
    get:
      tags: [Account]
      summary: Current account
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - properties:
                      data:
                        type: object
                        properties:
                          user: { type: object }
                          account: { $ref: '#/components/schemas/Account' }
  /categories:
    get:
      tags: [Catalog]
      summary: Categories available in a language
      parameters:
        - $ref: '#/components/parameters/Lang'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - properties:
                      data:
                        type: array
                        items:
                          type: object
                          properties:
                            uid: { type: string, example: marine }
                            title: { type: string }
                            count: { type: integer }
  /products:
    get:
      tags: [Catalog]
      summary: Search products
      description: Keyword search across titles and descriptions in all languages (matches in the requested language rank first).
      parameters:
        - $ref: '#/components/parameters/Lang'
        - { name: q, in: query, schema: { type: string }, description: 'Keywords (space separated, all must match)' }
        - { name: category, in: query, schema: { type: string }, description: 'Category uid from /categories' }
        - { name: area, in: query, schema: { type: string, example: okinawa } }
        - { name: sort, in: query, schema: { type: string, enum: [recommended, price_asc, price_desc, popular] } }
        - { name: page, in: query, schema: { type: integer, default: 1 } }
        - { name: size, in: query, schema: { type: integer, default: 24, maximum: 100 } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - properties:
                      data:
                        type: object
                        properties:
                          items: { type: array, items: { $ref: '#/components/schemas/ProductSummary' } }
                          total: { type: integer }
                          page: { type: integer }
                          size: { type: integer }
                          facets:
                            type: object
                            properties:
                              categories: { type: object, additionalProperties: { type: integer } }
                              areas: { type: object, additionalProperties: { type: integer } }
  /products/suggest:
    get:
      tags: [Catalog]
      summary: Title suggestions (autocomplete)
      parameters:
        - $ref: '#/components/parameters/Lang'
        - { name: q, in: query, required: true, schema: { type: string } }
      responses:
        '200': { description: 'Up to 8 { id, title, category }' }
  /products/{id}:
    get:
      tags: [Catalog]
      summary: Product detail with net prices and options
      parameters:
        - $ref: '#/components/parameters/Lang'
        - { name: id, in: path, required: true, schema: { type: integer } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - properties:
                      data: { $ref: '#/components/schemas/ProductDetail' }
        '404': { description: product_not_found (not sold in this language or not open for B2B) }
  /products/{id}/availability:
    get:
      tags: [Catalog]
      summary: Bookable dates and lowest net price per date (max 93 days per call)
      parameters:
        - $ref: '#/components/parameters/Lang'
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { name: from, in: query, schema: { type: string, format: date } }
        - { name: to, in: query, schema: { type: string, format: date } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - properties:
                      data:
                        type: array
                        items:
                          type: object
                          properties:
                            date: { type: string, format: date }
                            available: { type: boolean }
                            net_price_from: { type: integer }
  /rentacar/areas:
    get:
      tags: [Rent-a-car]
      summary: Pick-up / return areas
      parameters:
        - $ref: '#/components/parameters/Lang'
      responses:
        '200': { description: 'Array of { id, uid, title }' }
  /rentacar/search:
    post:
      tags: [Rent-a-car]
      summary: Search cars with net prices per insurance plan
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/RentacarCondition' }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - properties:
                      data:
                        type: object
                        properties:
                          total: { type: integer }
                          items: { type: array, items: { $ref: '#/components/schemas/Car' } }
  /quotes:
    post:
      tags: [Booking]
      summary: Price a selection without booking
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf:
                - $ref: '#/components/schemas/ProductSelection'
                - $ref: '#/components/schemas/RentacarSelection'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - properties:
                      data: { $ref: '#/components/schemas/Quote' }
        '400': { description: 'Validation error (deadline_passed, closed, option_required, car_not_available, ...)' }
  /bookings:
    get:
      tags: [Booking]
      summary: List bookings
      parameters:
        - { name: status, in: query, schema: { type: string, enum: [pending_payment, confirmed, cancel_requested, canceled] } }
        - { name: type, in: query, schema: { type: string, enum: [product, rentacar] } }
        - { name: from, in: query, schema: { type: string, format: date }, description: Service date from }
        - { name: to, in: query, schema: { type: string, format: date } }
        - { name: q, in: query, schema: { type: string }, description: 'Booking no., order no., traveller name or agency_ref' }
        - { name: page, in: query, schema: { type: integer } }
        - { name: size, in: query, schema: { type: integer, maximum: 100 } }
      responses:
        '200': { description: '{ items: Booking[], total, page, size }' }
    post:
      tags: [Booking]
      summary: Create a booking
      parameters:
        - { name: Idempotency-Key, in: header, required: false, schema: { type: string, maxLength: 100 }, description: Strongly recommended }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - oneOf:
                    - $ref: '#/components/schemas/ProductSelection'
                    - $ref: '#/components/schemas/RentacarSelection'
                - $ref: '#/components/schemas/BookingExtra'
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - properties:
                      replay: { type: boolean }
                      data:
                        allOf:
                          - $ref: '#/components/schemas/Booking'
                          - properties:
                              redirect_url: { type: string, description: 'Card payments only: open in the browser for 3-D Secure' }
        '200': { description: Idempotent replay of an existing booking }
        '400': { description: 'insufficient_balance, customer_required, card_failed, ...' }
  /bookings/{id}:
    get:
      tags: [Booking]
      summary: Booking detail
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      responses:
        '200': { description: Booking }
  /bookings/{id}/cancel-quote:
    get:
      tags: [Booking]
      summary: Cancellation fee if cancelled now
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      responses:
        '200': { description: '{ fee_rate, fee, refund, has_policy, card_fee_refundable }' }
  /bookings/{id}/cancel:
    post:
      tags: [Booking]
      summary: Request cancellation
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                reason: { type: string }
      responses:
        '200': { description: Booking with status cancel_requested }
  /balance:
    get:
      tags: [Deposit]
      summary: Deposit balance and bank account for top-ups
      responses:
        '200': { description: '{ balance, credit_limit, currency, deposit_reference, bank }' }
  /ledger:
    get:
      tags: [Deposit]
      summary: Deposit transactions
      parameters:
        - { name: page, in: query, schema: { type: integer } }
        - { name: size, in: query, schema: { type: integer } }
      responses:
        '200': { description: '{ items: [{ id, type, amount, balance_after, booking_id, order_id, description, created_at }], total }' }
  /deposits:
    get:
      tags: [Deposit]
      summary: Reported transfers
      responses:
        '200': { description: OK }
    post:
      tags: [Deposit]
      summary: Report a Wise transfer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [amount, transfer_date, sender_name]
              properties:
                amount: { type: integer, minimum: 1000 }
                transfer_date: { type: string, format: date }
                sender_name: { type: string }
                wise_transfer_id: { type: string }
                memo: { type: string }
      responses:
        '200': { description: '{ id, status: pending }' }
components:
  securitySchemes:
    ApiKey: { type: apiKey, in: header, name: X-API-Key }
    ApiSecret: { type: apiKey, in: header, name: X-API-Secret }
  parameters:
    Lang:
      name: lang
      in: query
      schema: { type: string, enum: [kr, jp, en, tw, cn], default: en }
  schemas:
    Envelope:
      type: object
      properties:
        success: { type: boolean }
        data: {}
    Error:
      type: object
      properties:
        success: { type: boolean, example: false }
        error:
          type: object
          properties:
            code: { type: string, example: insufficient_balance }
            message: { type: string }
    Account:
      type: object
      properties:
        id: { type: integer }
        status: { type: string, enum: [pending, approved, rejected, suspended] }
        tier: { type: string }
        tier_name: { type: string }
        company: { type: object }
        contact: { type: object }
        balance: { type: integer }
        credit_limit: { type: integer }
        deposit_reference: { type: string, example: OKB1001 }
    ProductSummary:
      type: object
      properties:
        id: { type: integer }
        title: { type: string }
        title_sub: { type: string }
        category: { type: string }
        category_title: { type: string }
        area: { type: string }
        supplier: { type: string }
        image: { type: string }
        net_price_from: { type: integer }
        languages: { type: array, items: { type: string } }
    ProductDetail:
      allOf:
        - $ref: '#/components/schemas/ProductSummary'
        - type: object
          properties:
            content: { type: string, description: HTML }
            detail_image: { type: string }
            files: { type: array, items: { type: object, properties: { src: { type: string }, thumbnail: { type: string } } } }
            meeting_place: { type: string }
            address: { type: string }
            before_days: { type: integer, description: Booking cut-off in days before the service date }
            guest_least_limit: { type: integer }
            cancel_policies:
              type: array
              items: { type: object, properties: { from: { type: integer }, to: { type: integer }, fee_rate: { type: number } } }
            prices:
              type: array
              items:
                type: object
                properties:
                  name: { type: string, example: 'Adult (12+)' }
                  age_range: { type: string }
                  times: { type: string, example: '09:00,13:30' }
                  net_price: { type: integer }
            options:
              type: array
              items:
                type: object
                properties:
                  priority: { type: integer }
                  name: { type: string }
                  is_input: { type: boolean, description: 'Free-text option' }
                  items:
                    type: array
                    items: { type: object, properties: { priority: { type: integer }, name: { type: string }, net_price: { type: integer, description: 'Added per guest' } } }
    ProductSelection:
      type: object
      required: [type, product_id, date, items]
      properties:
        type: { type: string, enum: [product] }
        lang: { type: string }
        product_id: { type: integer }
        date: { type: string, format: date }
        time: { type: string, example: '09:00', description: 'Required when price rows have times' }
        items:
          type: array
          items:
            type: object
            properties:
              name: { type: string, description: 'Price row name from /products/{id}' }
              index: { type: integer, description: 'Alternatively the price row index' }
              quantity: { type: integer }
        options:
          type: object
          description: 'Map of option group priority → option item priority (or free text for is_input groups)'
          additionalProperties: { type: string }
          example: { '1': '2' }
    RentacarCondition:
      type: object
      required: [pickup_car_area, reservation_date, reservation_time, return_date, return_time]
      properties:
        lang: { type: string }
        pickup_car_area: { type: integer }
        return_car_area: { type: integer }
        reservation_date: { type: string, format: date }
        reservation_time: { type: string, example: '10:00' }
        return_date: { type: string, format: date }
        return_time: { type: string, example: '10:00' }
    RentacarSelection:
      allOf:
        - $ref: '#/components/schemas/RentacarCondition'
        - type: object
          required: [type, car_id]
          properties:
            type: { type: string, enum: [rentacar] }
            car_id: { type: integer }
            class: { type: string }
            insurance: { type: string, enum: [none, basic, premium] }
    Car:
      type: object
      properties:
        car_id: { type: integer }
        company_uid: { type: string }
        company_name: { type: string }
        class: { type: string }
        name: { type: string }
        image: { type: string }
        days: { type: integer }
        hours: { type: integer }
        instant_confirm: { type: boolean }
        net_price_from: { type: integer }
        insurances:
          type: array
          items:
            type: object
            properties:
              key: { type: string, enum: [none, basic, premium] }
              tier_name: { type: string }
              note: { type: string }
              net_total: { type: integer }
    BookingExtra:
      type: object
      required: [customer, payment_method]
      properties:
        payment_method: { type: string, enum: [deposit, card] }
        card_token: { type: string, description: 'GMO Multipayment token (card only)' }
        customer:
          type: object
          required: [name_family, name_first]
          properties:
            name_family: { type: string, example: HONG }
            name_first: { type: string, example: GILDONG }
            phone: { type: string }
            email: { type: string }
        agency_ref: { type: string, maxLength: 60 }
        memo: { type: string }
        hotel_name: { type: string, description: Product only }
        count: { type: object, description: 'Rent-a-car only: { adults, children, infants }' }
        flight_arrival: { type: string }
        flight_departure: { type: string }
    Quote:
      type: object
      properties:
        type: { type: string }
        title: { type: string }
        lines:
          type: array
          items: { type: object, properties: { name: { type: string }, quantity: { type: integer }, net_unit: { type: integer }, net_amount: { type: integer } } }
        net_total: { type: integer }
        card_fee: { type: integer }
        card_fee_rate: { type: number }
        charge_total: { type: integer }
        balance: { type: integer }
        credit_limit: { type: integer }
    Booking:
      type: object
      properties:
        id: { type: integer }
        order_id: { type: integer }
        type: { type: string, enum: [product, rentacar] }
        status: { type: string, enum: [pending_payment, confirmed, cancel_requested, canceled, failed] }
        order_status: { type: string, description: 'Supplier-side status (payment_complete = arranging, auto_confirmed = confirmed, ready = awaiting confirmation)' }
        title: { type: string }
        agency_ref: { type: string }
        reservation_date: { type: string, format: date }
        reservation_time: { type: string }
        return_date: { type: string }
        return_time: { type: string }
        net_total: { type: integer }
        card_fee: { type: integer }
        charge_total: { type: integer }
        payment_method: { type: string }
        payment_status: { type: string }
        confirm_no: { type: string }
        lines: { type: array, items: { type: object } }
        customer: { type: object }
        cancel: { type: object, nullable: true }
        created_at: { type: string, format: date-time }
