Amadeus 환불(Refund) API 심층 분석

1. API 개요

1.1 주요 기능

  • 환불 금액 계산: 실제 환불 없이 예상 환불 금액 조회
  • 실제 환불 처리: 3단계 프로세스 (Init → Update → Process)
  • Waiver 처리: 무료 환불 조건 (OSI/SSR/REMARK/AUTH_CODE)
  • 결제 수단별 환불: 카드/현금 혼합 결제 처리
  • 환불 수수료 계산: Waiver 여부에 따른 수수료 차등 적용

1.2 관련 파일 구조

supplier/amadeus/
├── application/
│   ├── AmadeusRefundService.kt       # 환불 서비스 메인 로직
│   └── AmadeusRetrieveService.kt     # PNR/티켓 조회 서비스
├── infrastructure/
│   └── AmadeusClient.kt               # SOAP API 클라이언트
├── domain/repository/
│   └── AmadeusInitRefundRepository.kt # InitRefund 캐싱 저장소
└── support/
    ├── model/
    │   ├── WaiverModel.kt             # Waiver 모델
    │   ├── InitRefund.kt              # 초기 환불 정보
    │   └── Refund.kt                  # 최종 환불 결과
    └── enums/
        └── WaiverType.kt              # Waiver 타입 (OSI/SSR/AUTH_CODE/REMARK)

2. 핵심 비즈니스 로직

2.1 환불 프로세스 플로우

flowchart TD
    A[환불 요청] --> B{조회 or 실행?}
    B -->|조회| C[refundCalculate]
    B -->|실행| D[refund]

    C --> E[PNR 정보 조회]
    E --> F[티켓 문서 조회]
    F --> G[병렬 initRefund 호출]
    G --> H[환불 금액 계산]
    H --> I[환불 예상 정보 반환]

    D --> J[PNR 정보 조회]
    J --> K[티켓 문서 조회]
    K --> L{Waiver 존재?}
    L -->|Yes| M[Waiver 저장]
    L -->|No| N[티켓별 환불 처리]
    M --> N

    N --> O[initRefund]
    O --> P{수정 필요?}
    P -->|Yes| Q[updateRefund]
    P -->|No| R[processRefund]
    Q --> R

    R --> S[환불 상태 검증]
    S --> T[완료]

    style L fill:#E8B4B8,stroke:#333,stroke-width:2px,color:#000
    style P fill:#F39C9C,stroke:#333,stroke-width:2px,color:#000
    style S fill:#A8D5BA,stroke:#333,stroke-width:2px,color:#000

2.2 환불 금액 계산 (refundCalculate)

2.2.1 조회 전용 로직

// AmadeusRefundService.kt:32-88
fun refundCalculate(pnr: String, isWaiverRefund: Boolean): List<Refund> {
    return stateful {
        try {
            // 1. PNR 정보 조회 및 검증
            val pnrInfo = start {
                amadeusClient.getPnrInfo(pnr = pnr, validateScheduleStatus = true, statefulBuilder = this)
                    .also { validateRefundableStatus(pnrInfo = it, isWaiverRefund = isWaiverRefund) }
            }
 
            // 2. 티켓 문서 조회 및 검증
            val tickets = inSeries {
                retrieveService.getPnrTicketDocuments(...)
                    .also { validateRefundableTickets(pnr = pnr, tickets = it) }
            }
 
            // 3. 세션 종료
            end { amadeusClient.signOut(statefulBuilder = this) }
 
            // 4. 병렬 initRefund 호출 (캐싱 활용)
            val initRefunds = withBlocking(Dispatchers.IO) {
                tickets.associateBy { it.passengerType }.toList().pmap { (passengerType, ticket) ->
                    val ticketNumber = ticket.ticketNumber
                    val key = "$pnr$ticketNumber"
                    passengerType to (amadeusInitRefundRepository.find(key = key)
                        ?: amadeusClient.initRefund(ticketNumber).also {
                            // 당일 자정까지 캐싱
                            amadeusInitRefundRepository.save(key = key, value = it, ttl = ...)
                        })
                }
            }.getOrThrow()
 
            // 5. 환불 정보 반환
            tickets.map { ticket ->
                val initRefund = initRefunds.first { (passengerType, _) -> passengerType == ticket.passengerType }.second
                Refund.of(ticket = ticket, initRefund = initRefund, isWaiverRefund = isWaiverRefund)
            }
        } catch (e: Exception) {
            // 세션 정리
            if (session?.transactionStatusCode == TransactionStatusCode.InSeries) {
                end { amadeusClient.signOut(statefulBuilder = this) }
            }
            throw e
        }
    }
}

2.2.2 캐싱 전략

  • 캐시 키: {pnr}{ticketNumber}
  • TTL: 당일 자정까지
  • 저장소: Redis (AmadeusInitRefundRepository)
  • 목적: 동일 티켓에 대한 반복 조회 방지

2.3 실제 환불 처리 (refund)

2.3.1 메인 환불 로직

// AmadeusRefundService.kt:90-165
fun refund(pnr: String, waivers: List<WaiverModel>?): List<Refund> {
    return stateful {
        val isWaiverRefund = !waivers.isNullOrEmpty()
        try {
            val pnrInfo = start { ... }
            val ticketDocuments = inSeries { ... }
 
            // Waiver 처리
            if (isWaiverRefund) saveWaiverRefunds(waivers = waivers, pnrInfo = pnrInfo)
 
            // 티켓별 환불 처리
            val refundedTickets = mutableListOf<String>()
            try {
                ticketDocuments.map { ticketDocument ->
                    refund(
                        validatingCarrier = pnrInfo.validatingCarrier ?: pnrInfo.schedules!!.first().marketingCarrier,
                        ticket = ticketDocument,
                        isWaiverRefund = isWaiverRefund,
                        waivers = waivers,
                        statefulBuilder = this
                    ).also { refundedTickets.add(it.ticketNumber) }
                }
            } catch (e: Exception) {
                // 부분 실패 시 Slack 알림
                slackService.sendAllTicketRefundFail(...)
                throw InternationalAdapterException(ErrorMessage.REFUND_FAILED, "All ticket is not refunded")
            }.also {
                // 환불 상태 검증
                inSeries {
                    retrieveService.getPnrTicketDocuments(...)
                        .filter { it.status != TicketStatus.REFUND }
                        .takeIf { it.isNotEmpty() }
                        ?.run {
                            throw InternationalAdapterException(ErrorMessage.REFUND_FAILED, ...)
                        }
                }
                end { amadeusClient.signOut(statefulBuilder = this) }
            }
        } catch (e: Exception) {
            if (session?.transactionStatusCode == TransactionStatusCode.InSeries) {
                end { amadeusClient.signOut(statefulBuilder = this) }
            }
            throw e
        }
    }
}

3. Waiver 처리

3.1 Waiver 타입

// WaiverType.kt:3-8
enum class WaiverType {
    OSI,        // Other Service Information
    SSR,        // Special Service Request
    AUTH_CODE,  // Authorization Code (환불 승인 코드)
    REMARK,     // Remark
}

3.2 Waiver 저장 및 검증

// AmadeusRefundService.kt:167-200
private fun StatefulBuilder.saveWaiverRefunds(waivers: List<WaiverModel>, pnrInfo: PnrInfo) {
    val filteredWaivers = waivers.filter { it.type != WaiverType.AUTH_CODE }  // AUTH_CODE는 별도 처리
    if (filteredWaivers.isNotEmpty()) {
        // 1. Waiver 정보 저장
        inSeries {
            amadeusClient.saveWaiverRefunds(
                waivers = filteredWaivers,
                validatingCarrier = pnrInfo.validatingCarrier,
                statefulBuilder = this
            )
        }
 
        // 2. 저장 결과 조회
        inSeries {
            amadeusClient.savePnrWithRetrieve(statefulBuilder = this)
        }.also { savedPnrInfo ->
            // 3. 저장 검증
            filteredWaivers.forEach { waiver ->
                val elements = when (waiver.type) {
                    WaiverType.OSI -> savedPnrInfo.reference.osiElements
                    WaiverType.SSR -> savedPnrInfo.reference.ssrElements
                    WaiverType.REMARK -> savedPnrInfo.reference.remarkElements
                    else -> emptyList()
                }
 
                if (elements.none { it.texts?.contains(waiver.text) == true }) {
                    throw InternationalAdapterException(
                        ErrorMessage.REFUND_FAILED,
                        "Not Found ${waiver.type} waiver code: ${waiver.text}",
                        pnrInfo.pnr ?: "",
                        pnrInfo.validatingCarrier ?: ""
                    )
                }
            }
        }
    }
}

3.3 Waiver 처리 흐름

flowchart TD
    A[Waiver 존재?] --> B{AUTH_CODE 제외 필터링}
    B --> C[Waiver 저장 요청]
    C --> D[PNR 저장 및 조회]
    D --> E{타입별 검증}

    E -->|OSI| F[osiElements 확인]
    E -->|SSR| G[ssrElements 확인]
    E -->|REMARK| H[remarkElements 확인]

    F --> I{텍스트 포함?}
    G --> I
    H --> I

    I -->|No| J[예외 발생]
    I -->|Yes| K[검증 완료]

    style E fill:#E8B4B8,stroke:#333,stroke-width:2px,color:#000
    style I fill:#F39C9C,stroke:#333,stroke-width:2px,color:#000

4. 환불 수수료 계산

4.1 수정 필요 조건

// AmadeusRefundService.kt:227-233
private fun shouldUpdateRefund(isWaiverRefund: Boolean, initRefund: InitRefund): Boolean {
    return when {
        isWaiverRefund && initRefund.refundFee > BigDecimal.ZERO -> true  // Waiver 환불인데 수수료 있음
        initRefund.fopGroups.size > 1 && initRefund.noneRefundAmount > BigDecimal.ZERO -> true  // 혼합 결제
        else -> false
    }
}

4.2 결제 수단별 환불 금액 계산

4.2.1 원본 결제 금액 계산

// AmadeusRefundService.kt:250-269
val (cardAmount, cashAmount) = when {
    // 1. 결제 수단이 2개 (카드 + 현금)
    initRefund.fopGroups.size > 1 -> {
        initRefund.cardAmount to initRefund.cashAmount
    }
 
    // 2. 카드만 결제 (환불 불가 금액 역산)
    initRefund.fopGroups.size == 1 && initRefund.cashAmount == BigDecimal.ZERO -> {
        initRefund.cardAmount + initRefund.noneRefundAmount to BigDecimal.ZERO
    }
 
    // 3. 현금만 결제 (환불 불가 금액 역산)
    initRefund.fopGroups.size == 1 && initRefund.cardAmount == BigDecimal.ZERO -> {
        BigDecimal.ZERO to initRefund.cashAmount + initRefund.noneRefundAmount
    }
 
    else -> {
        BigDecimal.ZERO to BigDecimal.ZERO
    }
}

4.2.2 환불 금액 계산 (카드 우선 차감)

// AmadeusRefundService.kt:271-278
val refundFee = if (isWaiverRefund) BigDecimal.ZERO else initRefund.refundFee
val noneRefundAmount = initRefund.usedAirPrice + initRefund.usedTax + refundFee
 
val (overrideCardAmount, overrideCashAmount) = if (cardAmount > noneRefundAmount) {
    // 카드 금액으로 수수료 충분히 커버 가능
    cardAmount - noneRefundAmount to cashAmount
} else {
    // 카드 금액 부족, 현금에서 추가 차감
    BigDecimal.ZERO to cashAmount - (noneRefundAmount - cardAmount)
}

4.2.3 환불 금액 계산 플로우

flowchart TD
    A[원본 결제 금액] --> B{결제 수단 개수}
    B -->|2개| C[카드 + 현금]
    B -->|1개 카드| D[카드 + noneRefundAmount]
    B -->|1개 현금| E[현금 + noneRefundAmount]

    C --> F[환불 수수료 계산]
    D --> F
    E --> F

    F --> G{Waiver?}
    G -->|Yes| H[수수료 = 0]
    G -->|No| I[수수료 = refundFee]

    H --> J[차감할 금액 계산]
    I --> J

    J --> K[usedAirPrice + usedTax + 수수료]
    K --> L{카드 금액 충분?}

    L -->|Yes| M[카드에서만 차감]
    L -->|No| N[카드 + 현금에서 차감]

    M --> O[FOP 그룹 생성]
    N --> O

    style G fill:#E8B4B8,stroke:#333,stroke-width:2px,color:#000
    style L fill:#F39C9C,stroke:#333,stroke-width:2px,color:#000

4.3 FOP 그룹 재구성

// AmadeusRefundService.kt:280-294
val overrideFopGroups = initRefund.fopGroups.mapNotNull { fopGroup ->
    when (fopGroup.formOfPaymentInformation.formOfPayment.type) {
        "CC" -> overrideCardAmount  // Credit Card
        "CA" -> overrideCashAmount  // Cash
        else -> null
    }?.takeIf {
        it > BigDecimal.ZERO  // 0보다 큰 경우만 포함
    }?.let {
        fopGroup.copy(
            formOfPaymentInformation = FormOfPaymentInformation(
                formOfPayment = fopGroup.formOfPaymentInformation.formOfPayment.copy(amount = it)
            )
        )
    }
}

5. 3단계 환불 프로세스

5.1 단계별 API 호출

// AmadeusRefundService.kt:235-312
private fun refund(
    validatingCarrier: String,
    ticket: PnrTicketDocument,
    isWaiverRefund: Boolean,
    waivers: List<WaiverModel>?,
    statefulBuilder: StatefulBuilder,
): Refund {
    // Step 1: Init Refund (환불 초기화)
    val initRefund = statefulBuilder.inSeries {
        amadeusClient.initRefund(ticketNumber = ticket.ticketNumber, statefulBuilder = this)
    }
 
    // Step 2: Update Refund (필요 시 수수료/금액 수정)
    if (shouldUpdateRefund(isWaiverRefund = isWaiverRefund, initRefund = initRefund)) {
        val (cardAmount, cashAmount) = ... // 금액 계산
        val overrideFopGroups = ... // FOP 재구성
 
        statefulBuilder.inSeries {
            amadeusClient.updateRefund(
                initRefund = initRefund,
                overrideCancelFee = refundFee,
                overrideFopGroups = overrideFopGroups,
                waiverCode = waivers?.find { it.type == WaiverType.AUTH_CODE }?.text,
                statefulBuilder = this
            )
        }
    }
 
    // Step 3: Process Refund (환불 처리 실행)
    statefulBuilder.inSeries {
        amadeusClient.processRefund(validatingCarrier = validatingCarrier, statefulBuilder = this)
    }
 
    return Refund.of(ticket = ticket, initRefund = initRefund, isWaiverRefund = isWaiverRefund)
}

5.2 3단계 프로세스 플로우

sequenceDiagram
    participant S as RefundService
    participant AC as AmadeusClient
    participant A as Amadeus API

    S->>AC: initRefund(ticketNumber)
    AC->>A: DocRefund_InitRefund
    A-->>AC: InitRefund 정보 반환
    AC-->>S: InitRefund

    alt 수정 필요
        S->>S: 환불 금액 계산
        S->>S: FOP 그룹 재구성
        S->>AC: updateRefund(initRefund, overrideCancelFee, overrideFopGroups, waiverCode)
        AC->>A: DocRefund_UpdateRefund
        A-->>AC: 업데이트 완료
        AC-->>S: UpdateRefundReply
    end

    S->>AC: processRefund(validatingCarrier)
    AC->>A: DocRefund_ProcessRefund
    A-->>AC: 환불 처리 완료
    AC-->>S: 완료

    S->>AC: getPnrTicketDocuments (검증)
    AC->>A: Ticket_DisplayTST
    A-->>AC: 티켓 상태
    AC-->>S: 환불 상태 확인

6. API 클라이언트 구현

6.1 Init Refund

// AmadeusClient.kt:1234-1255
fun initRefund(ticketNumber: String, statefulBuilder: StatefulBuilder? = null): InitRefund {
    val amadeusApiProperties = amadeusProperties.getApiProperties()
    val request = DocRefundInitRefund.of(ticketNumber = ticketNumber).withSession(statefulBuilder?.session)
    return amadeusApiProperties.endpoint
        .post(request)
        .header(getHeaderMap(request))
        .requestBodyConvert(soapRequestBodyConverter(amadeusApiProperties))
        .execute<AmadeusResponse<DocRefundInitRefundReply>>(soapBodyDeserializerOf(logger, objectMapper))
        .fold(
            success = { response ->
                statefulBuilder?.receiveSession(response.session)
                response.body.checkError(ticketNumber = ticketNumber)
                response.body.toInitRefund()
            },
            failure = { throw it.handleSoapFaultException(ErrorMessage.CALCULATE_CANCEL_FEE_FAILED) }
        )
}

6.2 Update Refund

// AmadeusClient.kt:1258-1294
fun updateRefund(
    initRefund: InitRefund,
    overrideCancelFee: BigDecimal? = null,
    overrideFopGroups: List<FopGroup>? = null,
    waiverCode: String? = null,
    statefulBuilder: StatefulBuilder? = null,
): DocRefundUpdateRefundReply {
    val amadeusApiProperties = amadeusProperties.getApiProperties()
    val request = DocRefundUpdateRefund.of(
        initRefund = initRefund,
        overrideCancelFee = overrideCancelFee,
        overrideFopGroups = overrideFopGroups,
        waiverCode = waiverCode,
    ).withSession(statefulBuilder?.session)
 
    return amadeusApiProperties.endpoint.post(request)...
}

6.3 Process Refund

// AmadeusClient.kt:1297-1318
fun processRefund(validatingCarrier: String, statefulBuilder: StatefulBuilder? = null) {
    val amadeusApiProperties = amadeusProperties.getApiProperties()
    val request = DocRefundProcessRefund.of(validatingCarrier = validatingCarrier).withSession(statefulBuilder?.session)
    return amadeusApiProperties.endpoint
        .post(request)
        .header(getHeaderMap(request))
        .requestBodyConvert(soapRequestBodyConverter(amadeusApiProperties))
        .execute<AmadeusResponse<DocRefundProcessRefundReply>>(soapBodyDeserializerOf(logger, objectMapper))
        .fold(
            success = { response ->
                statefulBuilder?.receiveSession(response.session)
                response.body.checkError()
            },
            failure = { throw it.handleSoapFaultException(ErrorMessage.REFUND_FAILED) }
        )
}

6.4 Save Waiver Refunds

// AmadeusClient.kt:409-430
fun saveWaiverRefunds(waivers: List<WaiverModel>?, validatingCarrier: String?, statefulBuilder: StatefulBuilder? = null) {
    val request = PnrAddMultiElements.ofWaiverRefunds(waivers = waivers, validatingCarrier = validatingCarrier)
    val amadeusApiProperties = amadeusProperties.getApiProperties()
    amadeusApiProperties.endpoint
        .post(request.withSession(statefulBuilder?.session))
        .header(getHeaderMap(request))
        .requestBodyConvert(soapRequestBodyConverter(amadeusApiProperties))
        .execute<AmadeusResponse<PnrReply>>(soapBodyDeserializerOf(logger, objectMapper))
        .fold(
            success = { response ->
                statefulBuilder?.receiveSession(response.session)
                response.body.checkError { (code, message) ->
                    throw InternationalAdapterException(ErrorMessage.SAVE_FAILED, code, message)
                }
            },
            failure = { throw it.handleSoapFaultException(ErrorMessage.SAVE_FAILED) }
        )
}

7. 검증 로직

7.1 환불 가능 상태 검증

// AmadeusRefundService.kt:212-225
private fun validateRefundableStatus(pnrInfo: PnrInfo, isWaiverRefund: Boolean) {
    // 1. EMD 티켓 체크
    pnrInfo.tickets.forEach {
        if (it.type == TicketType.EMD) {
            throw InternationalAdapterException(ErrorMessage.CANCEL_UNABLE, pnrInfo.pnr!!, "EMD Ticket Exists")
        }
    }
 
    // 2. 스케줄 확정 여부 체크 (Waiver 아닌 경우만)
    if (!isWaiverRefund && pnrInfo.schedules?.all { it.confirmed } == false) {
        throw InternationalAdapterException(
            ErrorMessage.CANCEL_UNABLE_BY_SCHEDULE_STATUS,
            pnrInfo.pnr!!,
            pnrInfo.schedules.joinToString { it.status }
        )
    }
}

7.2 티켓 상태 검증

// AmadeusRefundService.kt:202-210
private fun validateRefundableTickets(pnr: String, tickets: List<PnrTicketDocument>) {
    tickets.forEach {
        if (it.status == TicketStatus.CHECKIN) {
            throw InternationalAdapterException(ErrorMessage.CANCEL_UNABLE_BY_ALREADY_CHECK_IN, pnr)
        } else if (it.status != TicketStatus.ISSUE && it.status != TicketStatus.AIRPORT_CONTROL) {
            throw InternationalAdapterException(ErrorMessage.CANCEL_UNABLE, pnr, it.status)
        }
    }
}

8. 성능 최적화

8.1 병렬 처리

// AmadeusRefundService.kt:54-74
val initRefunds = withBlocking(Dispatchers.IO) {
    tickets.associateBy { it.passengerType }.toList().pmap { (passengerType, ticket) ->
        // pmap: 병렬 map 처리
        val ticketNumber = ticket.ticketNumber
        val key = "$pnr$ticketNumber"
        passengerType to (amadeusInitRefundRepository.find(key = key)
            ?: amadeusClient.initRefund(ticketNumber).also {
                amadeusInitRefundRepository.save(key = key, value = it, ttl = ...)
            })
    }
}.getOrThrow()

8.2 캐싱 전략

  • 대상: InitRefund 결과
  • : {pnr}{ticketNumber}
  • TTL: 당일 자정까지
  • 목적: 동일 티켓 반복 조회 방지
  • 효과: 조회 API 부하 감소, 응답 시간 단축

9. 예외 처리 및 알림

9.1 부분 실패 처리

// AmadeusRefundService.kt:114-139
val refundedTickets = mutableListOf<String>()
try {
    ticketDocuments.map { ticketDocument ->
        refund(...).also { refundedTickets.add(it.ticketNumber) }
    }
} catch (e: Exception) {
    val targetTickets = ticketDocuments.map { it.ticketNumber }
    slackService.sendAllTicketRefundFail(
        supplier = Supplier.AMADEUS,
        pnr = pnr,
        targetTickets = targetTickets,
        failTickets = targetTickets - refundedTickets.toSet()  // 실패한 티켓만
    )
    throw InternationalAdapterException(ErrorMessage.REFUND_FAILED, "All ticket is not refunded")
}

9.2 환불 상태 재검증

// AmadeusRefundService.kt:140-155
.also {
    inSeries {
        retrieveService.getPnrTicketDocuments(pnr = pnr, tickets = pnrInfo.tickets, statefulBuilder = this)
            .filter { it.status != TicketStatus.REFUND }  // 환불 완료되지 않은 티켓
            .takeIf { it.isNotEmpty() }
            ?.run {
                throw InternationalAdapterException(
                    ErrorMessage.REFUND_FAILED,
                    pnr,
                    this.joinToString { "${it.ticketNumber}:${it.status.name}" }
                )
            }
    }
    end { amadeusClient.signOut(statefulBuilder = this) }
}

10. 모니터링 및 로깅

10.1 주요 모니터링 포인트

  • InitRefund API 응답 시간
  • UpdateRefund 호출 빈도
  • ProcessRefund 성공률
  • 캐시 히트율 (InitRefund)
  • 부분 실패 발생 빈도

10.2 Slack 알림

  • 전체 티켓 환불 실패: 일부 티켓만 환불되고 나머지 실패 시
  • 알림 정보: PNR, 대상 티켓 목록, 실패 티켓 목록

11. 개선 권장사항

11.1 현재 이슈

  1. 동기적 티켓별 처리: 티켓이 많을수록 처리 시간 증가
  2. 부분 실패 롤백 부재: 일부 티켓만 환불된 경우 수동 처리 필요
  3. 복잡한 금액 계산 로직: 가독성 및 유지보수성 저하

11.2 개선 방안

  1. 병렬 환불 처리: 티켓별 독립적 환불 처리로 성능 향상
  2. 트랜잭션 관리: 전체 성공/실패 보장 메커니즘 추가
  3. 금액 계산 리팩터링: Strategy Pattern 또는 별도 계산기 클래스 분리
  4. 재시도 메커니즘: 일시적 오류에 대한 자동 재시도

12. 참고사항

12.1 주요 에러 코드

  • CALCULATE_CANCEL_FEE_FAILED: 환불 수수료 계산 실패
  • REFUND_FAILED: 환불 처리 실패
  • SAVE_FAILED: Waiver 저장 실패
  • CANCEL_UNABLE: 환불 불가능 (EMD, 스케줄 상태 등)
  • CANCEL_UNABLE_BY_ALREADY_CHECK_IN: 체크인 완료로 환불 불가
  • CANCEL_UNABLE_BY_SCHEDULE_STATUS: 스케줄 상태로 환불 불가

12.2 Waiver 타입별 용도

  • OSI: Other Service Information (항공사 공지사항)
  • SSR: Special Service Request (특별 서비스 요청)
  • AUTH_CODE: Authorization Code (환불 승인 코드, UpdateRefund에서 사용)
  • REMARK: Remark (비고)

12.3 관련 문서