supplier/sabre/
├── application/
│ └── SabreCancelService.kt # 환불 서비스 메인 로직
├── infrastructure/
│ ├── rest/
│ │ ├── SabreRestClient.kt # REST API 클라이언트
│ │ ├── request/bookingmanagement/
│ │ │ ├── RefundTicketsRequest.kt # 환불 요청
│ │ │ ├── RefundFlightTicket.kt # 티켓별 환불 정보
│ │ │ └── RefundQualifier.kt # 환불 조건
│ │ └── response/bookingmanagement/
│ │ ├── RefundTicketsResponse.kt # 환불 응답
│ │ └── CheckTicketsResponse.kt # 환불 가능 여부 응답
│ └── soap/
│ └── SabreClient.kt # SOAP API (Waiver 저장)
└── support/
└── model/
├── WaiverModel.kt # Waiver 모델
├── Refund.kt # 최종 환불 결과
├── Ticket.kt # 티켓 모델
├── CancellableTicket.kt # 환불 가능 티켓
└── RefundablePrice.kt # 환불 가능 금액
1.3 Amadeus vs Sabre 환불 API 비교
항목
Amadeus
Sabre
프로토콜
SOAP
REST
프로세스
3단계 (Init → Update → Process)
단일 호출 (refundFlightTickets)
조회 API
initRefund (세션 필요)
checkFlightTickets (세션 불필요)
캐싱
InitRefund 결과 (당일 자정까지)
없음
Waiver 저장
PnrAddMultiElements (SOAP)
SpecialServiceRQ + SaveRemark (SOAP)
Waiver AUTH_CODE
UpdateRefund에 포함
RefundQualifier.waiverCode에 포함
환불 금액 계산
복잡 (카드 우선 차감)
단순 (현금 우선 차감)
수수료 처리
Update 단계에서 override
overrideCancelFee 파라미터
FOP 관리
FOP 그룹 재구성 필요
splitRefundAmounts로 자동 처리
재시도 메커니즘
없음
없음 (단일 호출)
세션 관리
Stateful (Start → InSeries → End)
Token 기반 (getSessionToken → closeSessionToken)
2. 핵심 비즈니스 로직
2.1 환불 프로세스 플로우
flowchart TD
A[환불 요청] --> B{조회 or 실행?}
B -->|조회| C[cancelable]
B -->|실행| D[cancel]
C --> E[getBooking]
E --> F{Voidable?}
F -->|Yes| G[CancelActionType.VOID 반환]
F -->|No| H[checkCancellableTickets]
H --> I[findRefunds]
I --> J[환불 예상 정보 반환]
D --> K[getBooking]
K --> L{ticketHistories 존재?}
L -->|No| M[handleEmptyTicketHistories]
L -->|Yes| N{Voidable?}
N -->|Yes| O[voidTickets]
N -->|No| P{Waiver or AutoRefundable?}
P -->|No| Q[CANCEL_UNABLE 예외]
P -->|Yes| R[validateRefundableTickets]
R --> S{Waiver 존재?}
S -->|Yes| T[saveWaiverRefunds]
S -->|No| U[checkCancellableTickets]
T --> U
U --> V{isRefundable?}
V -->|No| W[CANCEL_UNABLE 예외]
V -->|Yes| X[refundTickets]
X --> Y[pnrCancelAsync]
O --> Y
M --> Y
Y --> Z[완료]
style F fill:#E8B4B8,stroke:#333,stroke-width:2px,color:#000
style N fill:#F39C9C,stroke:#333,stroke-width:2px,color:#000
style P fill:#E8B4B8,stroke:#333,stroke-width:2px,color:#000
style S fill:#F39C9C,stroke:#333,stroke-width:2px,color:#000
style V fill:#A8D5BA,stroke:#333,stroke-width:2px,color:#000
// SabreCancelService.kt:516-540private fun saveWaiverRefunds(waivers: List<WaiverModel>, token: String, booking: Booking) { val filteredWaivers = waivers.filter { it.type != WaiverType.AUTH_CODE } if (filteredWaivers.isEmpty()) return // 1. OSI/SSR 타입 저장 (SpecialServiceRQ) val specialServiceWaivers = filteredWaivers.filter { it.type == WaiverType.OSI || it.type == WaiverType.SSR } if (specialServiceWaivers.isNotEmpty()) { sabreClient.saveWaiverRefunds( token = token, waivers = specialServiceWaivers, validatingCarrier = booking.validatingCarrier ) } // 2. REMARK 타입 개별 저장 (SaveRemark) val remarkWaivers = filteredWaivers.filter { it.type == WaiverType.REMARK } if (remarkWaivers.isNotEmpty()) { remarkWaivers.forEach { waiver -> sabreClient.saveRemark( token = token, remarkText = waiver.text, ) } } // 3. 트랜잭션 종료 (저장 확정) sabreClient.endTransaction(token = token)}
3.2 Waiver 처리 흐름
flowchart TD
A[Waiver 존재?] --> B{AUTH_CODE 제외 필터링}
B --> C{OSI/SSR 존재?}
C -->|Yes| D[saveWaiverRefunds<br/>SpecialServiceRQ]
C -->|No| E{REMARK 존재?}
D --> E
E -->|Yes| F[saveRemark 반복 호출]
E -->|No| G[endTransaction]
F --> G
G --> H[AUTH_CODE는 별도 처리]
H --> I[RefundQualifier.waiverCode에 포함]
style C fill:#E8B4B8,stroke:#333,stroke-width:2px,color:#000
style E fill:#F39C9C,stroke:#333,stroke-width:2px,color:#000
// Refund.kt:15-36fun of( passenger: Passenger, cancellableTicket: CancellableTicketInfo, waiverRefundable: Boolean,): Refund { // net발권때문에 ticket을 기본으로 한다. (EMD 티켓을 걸렀기 때문에 eTicket으로 티켓을 매칭) val airPrice = passenger.eTicket?.airPrice ?: 0 val tax = passenger.eTicket?.tax ?: 0 return Refund( passengerType = passenger.type, identificationKey = passenger.identificationKey!!, ticketNumber = cancellableTicket.ticketNumber, // refundFee 의 경계가 애매하여 usedAirPrice를 발라낼수 없음 (usedAirPrice는 없을 것으로 간주 모두 refundFee로 처리) refundFee = if (waiverRefundable) { 0L } else { airPrice - (cancellableTicket.refundablePrice?.totalAirPrice ?: 0) }, usedTax = tax - (cancellableTicket.refundablePrice?.totalTaxPrice ?: 0) )}
4.2 결제 수단별 환불 금액 계산
4.2.1 Waiver 환불 금액 계산 (세금만 차감)
// RefundFlightTicket.kt:59-69private fun calculateWaiverRefundAmounts( ticket: Ticket, cancellableTicket: CancellableTicketInfo): Pair<Long, Long> { val tax = if (ticket.type == TicketType.TICKET) ticket.tax else 0 val usedTax = tax - (cancellableTicket.refundablePrice?.totalTaxPrice ?: 0) // 카드 우선 차감, 부족하면 현금에서 차감 val cardDeduction = min(ticket.cardPrice, usedTax) val cashDeduction = min(ticket.cashPrice, usedTax - cardDeduction) return (ticket.cardPrice - cardDeduction) to (ticket.cashPrice - cashDeduction)}
4.2.2 일반 환불 금액 계산 (현금 우선 차감)
// RefundFlightTicket.kt:71-79private fun calculateRefundAmounts( ticket: Ticket, cancellableTicket: CancellableTicketInfo): Pair<Long, Long> { val totalRefundPrice = cancellableTicket.refundablePrice?.totalRefundPrice ?: 0 // 현금 우선 차감, 부족하면 카드에서 차감 val cashPrice = min(totalRefundPrice, ticket.cashPrice) val cardPrice = totalRefundPrice - cashPrice return cardPrice to cashPrice}
4.2.3 환불 금액 계산 플로우
flowchart TD
A[원본 결제 정보] --> B{Waiver?}
B -->|Yes| C[calculateWaiverRefundAmounts]
B -->|No| D[calculateRefundAmounts]
C --> E[usedTax 계산]
E --> F[tax - refundablePrice.totalTaxPrice]
F --> G{카드 금액으로 충분?}
G -->|Yes| H[카드에서만 차감]
G -->|No| I[카드 + 현금에서 차감]
D --> J[totalRefundPrice 계산]
J --> K{현금으로 충분?}
K -->|Yes| L[현금에서만 차감]
K -->|No| M[현금 전체 + 카드에서 차감]
H --> N[splitRefundAmounts 생성]
I --> N
L --> N
M --> N
N --> O[RefundFlightTicket 생성]
style B fill:#E8B4B8,stroke:#333,stroke-width:2px,color:#000
style G fill:#F39C9C,stroke:#333,stroke-width:2px,color:#000
style K fill:#A8D5BA,stroke:#333,stroke-width:2px,color:#000
4.3 RefundQualifier 구성
// RefundFlightTicket.kt:34-57private fun calculateRefundQualifiers( ticket: Ticket, cancellableTicket: CancellableTicketInfo, waivers: List<WaiverModel>?,): RefundQualifier { // 1. 혼합 결제인 경우 splitRefundAmounts 계산 val splitRefundAmounts = if (ticket.mixedPayment) { val (refundCardPrice, refundCashPrice) = if (waivers?.isNotEmpty() == true) { calculateWaiverRefundAmounts(ticket = ticket, cancellableTicket = cancellableTicket) } else { calculateRefundAmounts(ticket = ticket, cancellableTicket = cancellableTicket) } listOf( SplitRefundAmount(amount = refundCardPrice.toString()), SplitRefundAmount(amount = refundCashPrice.toString()) ) } else null return RefundQualifier( splitRefundAmounts = splitRefundAmounts, overrideCancelFee = if (waivers?.isNotEmpty() == true) "0" else null, waiverCode = waivers?.find { it.type == WaiverType.AUTH_CODE }?.text, )}
5. 환불 실행 프로세스
5.1 REST API 호출
// SabreCancelService.kt:334-380private fun refundTickets( token: String, booking: Booking, cancellableTicket: CancellableTicket, waivers: List<WaiverModel>? = null,): List<Refund> { val (isAllRefunded, refundedTickets) = try { sabreRestClient.refundTickets( token = token, booking = booking, cancellableTicket = cancellableTicket, waivers = waivers, ) } catch (e: Exception) { // 예외별 Slack 알림 when (e) { is SocketTimeoutException, is SocketException, is IOException -> slackService.sendCancelFailTimeout(supplier = Supplier.SABRE, pnr = booking.pnr!!) else -> slackService.sendRefundFail(supplier = Supplier.SABRE, pnr = booking.pnr!!) } if (e is InternationalAdapterException) { throw e } else { throw InternationalAdapterException(ErrorMessage.REFUND_FAILED, e.message!!) } } // 부분 실패 감지 if (isAllRefunded.not()) { val targetTickets = booking.passengers.map { it.eTicket!!.ticketNumber } val failTickets = targetTickets - refundedTickets.map { it.ticketNumber }.toSet() slackService.sendAllTicketRefundFail( supplier = Supplier.SABRE, pnr = booking.pnr!!, targetTickets = targetTickets, failTickets = failTickets ) throw InternationalAdapterException( ErrorMessage.REFUND_FAILED, "All ticket is not refunded" ).capture() } return refundedTickets}
sequenceDiagram
participant CS as CancelService
participant RC as RestClient
participant SA as Sabre REST API
CS->>RC: refundTickets(token, booking, cancellableTicket, waivers)
RC->>RC: RefundTicketsRequest 생성
RC->>SA: POST /v1/trip/orders/refundFlightTickets
alt 환불 성공
SA-->>RC: RefundTicketsResponse
RC->>RC: isAllRefunded 체크
RC->>RC: Refund 모델 생성
RC-->>CS: (isAllRefunded, refundedTickets)
alt 부분 실패
CS->>CS: Slack 알림 발송
CS-->>CS: REFUND_FAILED 예외
end
else 환불 실패
SA-->>RC: Error Response
RC-->>CS: InternationalAdapterException
CS->>CS: Slack 알림 발송
CS-->>CS: 예외 재발생
end
// SabreCancelService.kt:502-514private fun getCancellableTickets(token: String, pnr: String): CancellableTicket { return try { sabreRestClient.checkCancellableTickets(token = token, pnr = pnr) } catch (e: Exception) { // 자동환불 여부 및 환불예상금 조회 에러 시 추가 slackService.sendCheckFlightTicketsFail( supplier = Supplier.SABRE, pnr = pnr, reason = e.message ) throw e }}
6.3 CancellableTicket 모델
// Ticket.kt:81-89data class CancellableTicket( val cancellableTicketInfos: List<CancellableTicketInfo>,) { val isVoidable: Boolean get() = cancellableTicketInfos.all { it.isVoidable } val isRefundable: Boolean get() = cancellableTicketInfos.all { !it.isVoidable && it.isRefundable && it.isAutomatedRefundable }}
// Ticket.kt:91-97data class CancellableTicketInfo( val ticketNumber: String, val isVoidable: Boolean, val isRefundable: Boolean, val isAutomatedRefundable: Boolean, val refundablePrice: RefundablePrice?,)