generated from pricelees/issue-pr-template
Compare commits
No commits in common. "b636ac926ef866af68d301dd139444e4de50f7da" and "d0ee55be955b4e0d6797c80320de6881b8b4b067" have entirely different histories.
b636ac926e
...
d0ee55be95
@ -0,0 +1,60 @@
|
|||||||
|
package com.sangdol.roomescape.order.business
|
||||||
|
|
||||||
|
import com.sangdol.common.persistence.IDGenerator
|
||||||
|
import com.sangdol.common.persistence.TransactionExecutionUtil
|
||||||
|
import com.sangdol.roomescape.order.infrastructure.persistence.PostOrderTaskEntity
|
||||||
|
import com.sangdol.roomescape.order.infrastructure.persistence.PostOrderTaskRepository
|
||||||
|
import com.sangdol.roomescape.payment.business.PaymentService
|
||||||
|
import com.sangdol.roomescape.payment.dto.PaymentGatewayResponse
|
||||||
|
import com.sangdol.roomescape.reservation.business.ReservationService
|
||||||
|
import io.github.oshai.kotlinlogging.KLogger
|
||||||
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import org.springframework.transaction.annotation.Propagation
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
|
private val log: KLogger = KotlinLogging.logger {}
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class OrderPostProcessorService(
|
||||||
|
private val idGenerator: IDGenerator,
|
||||||
|
private val reservationService: ReservationService,
|
||||||
|
private val paymentService: PaymentService,
|
||||||
|
private val postOrderTaskRepository: PostOrderTaskRepository,
|
||||||
|
private val transactionExecutionUtil: TransactionExecutionUtil
|
||||||
|
) {
|
||||||
|
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||||
|
fun processAfterPaymentConfirmation(
|
||||||
|
reservationId: Long,
|
||||||
|
paymentResponse: PaymentGatewayResponse
|
||||||
|
) {
|
||||||
|
val paymentKey = paymentResponse.paymentKey
|
||||||
|
try {
|
||||||
|
log.info { "[processAfterPaymentConfirmation] 결제 정보 저장 및 예약 확정 처리 시작: reservationId=${reservationId}, paymentKey=${paymentKey}" }
|
||||||
|
|
||||||
|
val paymentCreateResponse = paymentService.savePayment(reservationId, paymentResponse)
|
||||||
|
reservationService.confirmReservation(reservationId)
|
||||||
|
|
||||||
|
log.info {
|
||||||
|
"[processAfterPaymentConfirmation] 결제 정보 저장 및 예약 확정 처리 완료: reservationId=${reservationId}, paymentKey=${paymentKey}, paymentId=${paymentCreateResponse.paymentId}, paymentDetailId=${paymentCreateResponse.detailId}"
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log.warn(e) { "[processAfterPaymentConfirmation] 결제 정보 저장 및 예약 확정 처리 실패. 작업 저장 시작: reservationId=${reservationId}, paymentKey=$paymentKey}" }
|
||||||
|
|
||||||
|
transactionExecutionUtil.withNewTransaction(isReadOnly = false) {
|
||||||
|
PostOrderTaskEntity(
|
||||||
|
id = idGenerator.create(),
|
||||||
|
reservationId = reservationId,
|
||||||
|
paymentKey = paymentKey,
|
||||||
|
trial = 1,
|
||||||
|
nextRetryAt = Instant.now().plusSeconds(30),
|
||||||
|
).also {
|
||||||
|
postOrderTaskRepository.save(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info { "[processAfterPaymentConfirmation] 작업 저장 완료" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,69 +1,146 @@
|
|||||||
package com.sangdol.roomescape.order.business
|
package com.sangdol.roomescape.order.business
|
||||||
|
|
||||||
|
import com.sangdol.common.persistence.IDGenerator
|
||||||
import com.sangdol.common.persistence.TransactionExecutionUtil
|
import com.sangdol.common.persistence.TransactionExecutionUtil
|
||||||
import com.sangdol.common.types.exception.ErrorCode
|
import com.sangdol.common.types.exception.ErrorCode
|
||||||
import com.sangdol.common.types.exception.RoomescapeException
|
import com.sangdol.common.types.exception.RoomescapeException
|
||||||
import com.sangdol.roomescape.order.exception.OrderErrorCode
|
import com.sangdol.roomescape.order.exception.OrderErrorCode
|
||||||
import com.sangdol.roomescape.order.exception.OrderException
|
import com.sangdol.roomescape.order.exception.OrderException
|
||||||
|
import com.sangdol.roomescape.order.infrastructure.persistence.AttemptResult
|
||||||
|
import com.sangdol.roomescape.order.infrastructure.persistence.PaymentAttemptEntity
|
||||||
|
import com.sangdol.roomescape.order.infrastructure.persistence.PaymentAttemptRepository
|
||||||
import com.sangdol.roomescape.payment.business.PaymentService
|
import com.sangdol.roomescape.payment.business.PaymentService
|
||||||
import com.sangdol.roomescape.payment.dto.PaymentConfirmRequest
|
import com.sangdol.roomescape.payment.dto.PaymentConfirmRequest
|
||||||
|
import com.sangdol.roomescape.payment.dto.PaymentGatewayResponse
|
||||||
|
import com.sangdol.roomescape.payment.exception.PaymentException
|
||||||
import com.sangdol.roomescape.reservation.business.ReservationService
|
import com.sangdol.roomescape.reservation.business.ReservationService
|
||||||
import com.sangdol.roomescape.reservation.business.event.ReservationConfirmEvent
|
|
||||||
import com.sangdol.roomescape.reservation.dto.ReservationStateResponse
|
import com.sangdol.roomescape.reservation.dto.ReservationStateResponse
|
||||||
import com.sangdol.roomescape.schedule.business.ScheduleService
|
import com.sangdol.roomescape.schedule.business.ScheduleService
|
||||||
import com.sangdol.roomescape.schedule.dto.ScheduleStateResponse
|
import com.sangdol.roomescape.schedule.dto.ScheduleStateResponse
|
||||||
import io.github.oshai.kotlinlogging.KLogger
|
import io.github.oshai.kotlinlogging.KLogger
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
import org.springframework.context.ApplicationEventPublisher
|
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
|
|
||||||
private val log: KLogger = KotlinLogging.logger {}
|
private val log: KLogger = KotlinLogging.logger {}
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
class OrderService(
|
class OrderService(
|
||||||
|
private val idGenerator: IDGenerator,
|
||||||
private val reservationService: ReservationService,
|
private val reservationService: ReservationService,
|
||||||
private val scheduleService: ScheduleService,
|
private val scheduleService: ScheduleService,
|
||||||
private val paymentService: PaymentService,
|
private val paymentService: PaymentService,
|
||||||
private val transactionExecutionUtil: TransactionExecutionUtil,
|
private val transactionExecutionUtil: TransactionExecutionUtil,
|
||||||
private val orderValidator: OrderValidator,
|
private val orderValidator: OrderValidator,
|
||||||
private val eventPublisher: ApplicationEventPublisher
|
private val paymentAttemptRepository: PaymentAttemptRepository,
|
||||||
|
private val orderPostProcessorService: OrderPostProcessorService
|
||||||
) {
|
) {
|
||||||
|
|
||||||
fun confirm(reservationId: Long, paymentConfirmRequest: PaymentConfirmRequest) {
|
fun confirm(reservationId: Long, paymentConfirmRequest: PaymentConfirmRequest) {
|
||||||
|
var trial: Long = 0
|
||||||
val paymentKey = paymentConfirmRequest.paymentKey
|
val paymentKey = paymentConfirmRequest.paymentKey
|
||||||
|
|
||||||
log.info { "[confirm] 결제 및 예약 확정 시작: reservationId=${reservationId}, paymentKey=${paymentKey}" }
|
log.info { "[confirm] 결제 및 예약 확정 시작: reservationId=${reservationId}, paymentKey=${paymentKey}" }
|
||||||
try {
|
try {
|
||||||
transactionExecutionUtil.withNewTransaction(isReadOnly = false) {
|
trial = transactionExecutionUtil.withNewTransaction(isReadOnly = false) {
|
||||||
validateCanConfirm(reservationId)
|
getTrialAfterValidateCanConfirm(reservationId).also {
|
||||||
reservationService.markInProgress(reservationId)
|
reservationService.markInProgress(reservationId)
|
||||||
}
|
}
|
||||||
|
} ?: run {
|
||||||
|
log.warn { "[confirm] 모든 paymentAttempts 조회 과정에서의 예상치 못한 null 응답: reservationId=${reservationId}" }
|
||||||
|
throw OrderException(OrderErrorCode.BOOKING_UNEXPECTED_ERROR)
|
||||||
|
}
|
||||||
|
|
||||||
paymentService.requestConfirm(reservationId, paymentConfirmRequest)
|
val paymentClientResponse: PaymentGatewayResponse =
|
||||||
eventPublisher.publishEvent(ReservationConfirmEvent(reservationId))
|
requestConfirmPayment(reservationId, paymentConfirmRequest)
|
||||||
|
|
||||||
log.info { "[confirm] 결제 처리 및 예약 확정 이벤트 발행 완료" }
|
orderPostProcessorService.processAfterPaymentConfirmation(reservationId, paymentClientResponse)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
val errorCode: ErrorCode = if (e is RoomescapeException) {
|
val errorCode: ErrorCode = if (e is RoomescapeException) {
|
||||||
e.errorCode
|
e.errorCode
|
||||||
} else {
|
} else {
|
||||||
OrderErrorCode.ORDER_UNEXPECTED_ERROR
|
OrderErrorCode.BOOKING_UNEXPECTED_ERROR
|
||||||
}
|
}
|
||||||
|
|
||||||
throw OrderException(errorCode, e.message ?: errorCode.message)
|
throw OrderException(errorCode, e.message ?: errorCode.message, trial)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun validateCanConfirm(reservationId: Long) {
|
private fun getTrialAfterValidateCanConfirm(reservationId: Long): Long {
|
||||||
log.info { "[validateAndMarkInProgress] 예약 확정 가능 여부 검증 시작: reservationId=${reservationId}" }
|
log.info { "[validateAndMarkInProgress] 예약 확정 가능 여부 검증 시작: reservationId=${reservationId}" }
|
||||||
val reservation: ReservationStateResponse = reservationService.findStatusWithLock(reservationId)
|
val reservation: ReservationStateResponse = reservationService.findStatusWithLock(reservationId)
|
||||||
val schedule: ScheduleStateResponse = scheduleService.findStateWithLock(reservation.scheduleId)
|
val schedule: ScheduleStateResponse = scheduleService.findStateWithLock(reservation.scheduleId)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
orderValidator.validateCanConfirm(reservation, schedule)
|
orderValidator.validateCanConfirm(reservation, schedule)
|
||||||
|
|
||||||
|
return getTrialIfSuccessAttemptNotExists(reservationId).also {
|
||||||
|
log.info { "[validateAndMarkInProgress] 예약 확정 가능 여부 검증 완료: reservationId=${reservationId}" }
|
||||||
|
}
|
||||||
} catch (e: OrderException) {
|
} catch (e: OrderException) {
|
||||||
val errorCode = OrderErrorCode.NOT_CONFIRMABLE
|
val errorCode = OrderErrorCode.NOT_CONFIRMABLE
|
||||||
throw OrderException(errorCode, e.message)
|
throw OrderException(errorCode, e.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun getTrialIfSuccessAttemptNotExists(reservationId: Long): Long {
|
||||||
|
val paymentAttempts: List<PaymentAttemptEntity> = paymentAttemptRepository.findAllByReservationId(reservationId)
|
||||||
|
|
||||||
|
if (paymentAttempts.any { it.result == AttemptResult.SUCCESS }) {
|
||||||
|
log.info { "[validateCanConfirm] 이미 결제 완료된 예약: id=${reservationId}" }
|
||||||
|
throw OrderException(OrderErrorCode.BOOKING_ALREADY_COMPLETED)
|
||||||
|
}
|
||||||
|
|
||||||
|
return paymentAttempts.size.toLong()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requestConfirmPayment(
|
||||||
|
reservationId: Long,
|
||||||
|
paymentConfirmRequest: PaymentConfirmRequest
|
||||||
|
): PaymentGatewayResponse {
|
||||||
|
log.info { "[requestConfirmPayment] 결제 및 이력 저장 시작: reservationId=${reservationId}, paymentKey=${paymentConfirmRequest.paymentKey}" }
|
||||||
|
val paymentResponse: PaymentGatewayResponse
|
||||||
|
var attempt: PaymentAttemptEntity? = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
paymentResponse = paymentService.requestConfirm(paymentConfirmRequest)
|
||||||
|
|
||||||
|
attempt = PaymentAttemptEntity(
|
||||||
|
id = idGenerator.create(),
|
||||||
|
reservationId = reservationId,
|
||||||
|
result = AttemptResult.SUCCESS,
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
val errorCode: String = if (e is PaymentException) {
|
||||||
|
log.info { "[requestConfirmPayment] 결제 요청 실패: reservationId=${reservationId}, paymentKey=${paymentConfirmRequest.paymentKey}" }
|
||||||
|
e.errorCode.name
|
||||||
|
} else {
|
||||||
|
log.warn {
|
||||||
|
"[requestConfirmPayment] 결제 요청 실패: reservationId=${reservationId}, paymentKey=${paymentConfirmRequest.paymentKey}"
|
||||||
|
}
|
||||||
|
OrderErrorCode.BOOKING_UNEXPECTED_ERROR.name
|
||||||
|
}
|
||||||
|
|
||||||
|
attempt = PaymentAttemptEntity(
|
||||||
|
id = idGenerator.create(),
|
||||||
|
reservationId = reservationId,
|
||||||
|
result = AttemptResult.FAILED,
|
||||||
|
errorCode = errorCode,
|
||||||
|
message = e.message
|
||||||
|
)
|
||||||
|
|
||||||
|
throw e
|
||||||
|
} finally {
|
||||||
|
val savedAttempt: PaymentAttemptEntity? = attempt?.let {
|
||||||
|
log.info { "[requestPayment] 결제 요청 이력 저장 시작: id=${it.id}, reservationId=${it.reservationId}, result=${it.result}, errorCode=${it.errorCode}, message=${it.message}" }
|
||||||
|
paymentAttemptRepository.save(it)
|
||||||
|
}
|
||||||
|
savedAttempt?.also {
|
||||||
|
log.info { "[requestPayment] 결제 요청 이력 저장 완료: id=${savedAttempt.id}" }
|
||||||
|
} ?: run {
|
||||||
|
log.info { "[requestPayment] 결제 요청 이력 저장 실패: reservationId=${reservationId}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return paymentResponse
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,7 @@ package com.sangdol.roomescape.order.business
|
|||||||
import com.sangdol.common.utils.KoreaDateTime
|
import com.sangdol.common.utils.KoreaDateTime
|
||||||
import com.sangdol.roomescape.order.exception.OrderErrorCode
|
import com.sangdol.roomescape.order.exception.OrderErrorCode
|
||||||
import com.sangdol.roomescape.order.exception.OrderException
|
import com.sangdol.roomescape.order.exception.OrderException
|
||||||
|
import com.sangdol.roomescape.order.infrastructure.persistence.PaymentAttemptRepository
|
||||||
import com.sangdol.roomescape.reservation.dto.ReservationStateResponse
|
import com.sangdol.roomescape.reservation.dto.ReservationStateResponse
|
||||||
import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationStatus
|
import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationStatus
|
||||||
import com.sangdol.roomescape.schedule.dto.ScheduleStateResponse
|
import com.sangdol.roomescape.schedule.dto.ScheduleStateResponse
|
||||||
@ -28,7 +29,7 @@ class OrderValidator {
|
|||||||
when (reservation.status) {
|
when (reservation.status) {
|
||||||
ReservationStatus.CONFIRMED -> {
|
ReservationStatus.CONFIRMED -> {
|
||||||
log.info { "[validateCanConfirm] 이미 확정된 예약: id=${reservation.id}" }
|
log.info { "[validateCanConfirm] 이미 확정된 예약: id=${reservation.id}" }
|
||||||
throw OrderException(OrderErrorCode.ORDER_ALREADY_CONFIRMED)
|
throw OrderException(OrderErrorCode.BOOKING_ALREADY_COMPLETED)
|
||||||
}
|
}
|
||||||
ReservationStatus.EXPIRED -> {
|
ReservationStatus.EXPIRED -> {
|
||||||
log.info { "[validateCanConfirm] 만료된 예약: id=${reservation.id}" }
|
log.info { "[validateCanConfirm] 만료된 예약: id=${reservation.id}" }
|
||||||
|
|||||||
@ -9,11 +9,11 @@ enum class OrderErrorCode(
|
|||||||
override val message: String
|
override val message: String
|
||||||
) : ErrorCode {
|
) : ErrorCode {
|
||||||
NOT_CONFIRMABLE(HttpStatus.CONFLICT, "B000", "예약을 확정할 수 없어요."),
|
NOT_CONFIRMABLE(HttpStatus.CONFLICT, "B000", "예약을 확정할 수 없어요."),
|
||||||
ORDER_ALREADY_CONFIRMED(HttpStatus.CONFLICT, "B001", "이미 완료된 예약이에요."),
|
BOOKING_ALREADY_COMPLETED(HttpStatus.CONFLICT, "B001", "이미 완료된 예약이에요."),
|
||||||
EXPIRED_RESERVATION(HttpStatus.CONFLICT, "B002", "결제 가능 시간이 지나 만료된 예약이에요. 처음부터 다시 시도해주세요."),
|
EXPIRED_RESERVATION(HttpStatus.CONFLICT, "B002", "결제 가능 시간이 지나 만료된 예약이에요. 처음부터 다시 시도해주세요."),
|
||||||
CANCELED_RESERVATION(HttpStatus.CONFLICT, "B003", "이미 취소된 예약이에요. 본인이 취소하지 않았다면 매장에 문의해주세요."),
|
CANCELED_RESERVATION(HttpStatus.CONFLICT, "B003", "이미 취소된 예약이에요. 본인이 취소하지 않았다면 매장에 문의해주세요."),
|
||||||
PAST_SCHEDULE(HttpStatus.CONFLICT, "B004", "지난 일정은 예약할 수 없어요."),
|
PAST_SCHEDULE(HttpStatus.CONFLICT, "B004", "지난 일정은 예약할 수 없어요."),
|
||||||
|
|
||||||
ORDER_UNEXPECTED_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "B999", "예상치 못한 예외가 발생했어요. 잠시 후 다시 시도해주세요.")
|
BOOKING_UNEXPECTED_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "B999", "예상치 못한 예외가 발생했어요. 잠시 후 다시 시도해주세요.")
|
||||||
;
|
;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,4 +6,11 @@ import com.sangdol.common.types.exception.RoomescapeException
|
|||||||
class OrderException(
|
class OrderException(
|
||||||
override val errorCode: ErrorCode,
|
override val errorCode: ErrorCode,
|
||||||
override val message: String = errorCode.message,
|
override val message: String = errorCode.message,
|
||||||
|
var trial: Long = 0
|
||||||
) : RoomescapeException(errorCode, message)
|
) : RoomescapeException(errorCode, message)
|
||||||
|
|
||||||
|
class OrderErrorResponse(
|
||||||
|
val code: String,
|
||||||
|
val message: String,
|
||||||
|
val trial: Long
|
||||||
|
)
|
||||||
|
|||||||
@ -0,0 +1,45 @@
|
|||||||
|
package com.sangdol.roomescape.order.exception
|
||||||
|
|
||||||
|
import com.sangdol.common.types.exception.ErrorCode
|
||||||
|
import com.sangdol.common.types.web.HttpStatus
|
||||||
|
import com.sangdol.common.web.support.log.WebLogMessageConverter
|
||||||
|
import io.github.oshai.kotlinlogging.KLogger
|
||||||
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
|
import jakarta.servlet.http.HttpServletRequest
|
||||||
|
import org.springframework.http.ResponseEntity
|
||||||
|
import org.springframework.web.bind.annotation.ExceptionHandler
|
||||||
|
import org.springframework.web.bind.annotation.RestControllerAdvice
|
||||||
|
|
||||||
|
private val log: KLogger = KotlinLogging.logger {}
|
||||||
|
|
||||||
|
@RestControllerAdvice
|
||||||
|
class OrderExceptionHandler(
|
||||||
|
private val messageConverter: WebLogMessageConverter
|
||||||
|
) {
|
||||||
|
@ExceptionHandler(OrderException::class)
|
||||||
|
fun handleOrderException(
|
||||||
|
servletRequest: HttpServletRequest,
|
||||||
|
e: OrderException
|
||||||
|
): ResponseEntity<OrderErrorResponse> {
|
||||||
|
val errorCode: ErrorCode = e.errorCode
|
||||||
|
val httpStatus: HttpStatus = errorCode.httpStatus
|
||||||
|
val errorResponse = OrderErrorResponse(
|
||||||
|
code = errorCode.errorCode,
|
||||||
|
message = if (httpStatus.isClientError()) e.message else errorCode.message,
|
||||||
|
trial = e.trial
|
||||||
|
)
|
||||||
|
|
||||||
|
log.info {
|
||||||
|
messageConverter.convertToErrorResponseMessage(
|
||||||
|
servletRequest = servletRequest,
|
||||||
|
httpStatus = httpStatus,
|
||||||
|
responseBody = errorResponse,
|
||||||
|
exception = if (errorCode.message == e.message) null else e
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseEntity
|
||||||
|
.status(httpStatus.value())
|
||||||
|
.body(errorResponse)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,38 @@
|
|||||||
|
package com.sangdol.roomescape.order.infrastructure.persistence
|
||||||
|
|
||||||
|
import com.sangdol.common.persistence.PersistableBaseEntity
|
||||||
|
import jakarta.persistence.*
|
||||||
|
import org.springframework.data.annotation.CreatedBy
|
||||||
|
import org.springframework.data.annotation.CreatedDate
|
||||||
|
import org.springframework.data.jpa.domain.support.AuditingEntityListener
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@EntityListeners(AuditingEntityListener::class)
|
||||||
|
@Table(name = "payment_attempts")
|
||||||
|
class PaymentAttemptEntity(
|
||||||
|
id: Long,
|
||||||
|
|
||||||
|
val reservationId: Long,
|
||||||
|
|
||||||
|
@Enumerated(value = EnumType.STRING)
|
||||||
|
val result: AttemptResult,
|
||||||
|
|
||||||
|
@Column(columnDefinition = "VARCHAR(50)")
|
||||||
|
val errorCode: String? = null,
|
||||||
|
|
||||||
|
@Column(columnDefinition = "TEXT")
|
||||||
|
val message: String? = null,
|
||||||
|
) : PersistableBaseEntity(id) {
|
||||||
|
@Column(updatable = false)
|
||||||
|
@CreatedDate
|
||||||
|
lateinit var createdAt: Instant
|
||||||
|
|
||||||
|
@Column(updatable = false)
|
||||||
|
@CreatedBy
|
||||||
|
var createdBy: Long = 0L
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class AttemptResult {
|
||||||
|
SUCCESS, FAILED
|
||||||
|
}
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
package com.sangdol.roomescape.order.infrastructure.persistence
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository
|
||||||
|
import org.springframework.data.jpa.repository.Query
|
||||||
|
|
||||||
|
interface PaymentAttemptRepository: JpaRepository<PaymentAttemptEntity, Long> {
|
||||||
|
|
||||||
|
fun countByReservationId(reservationId: Long): Long
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
CASE
|
||||||
|
WHEN COUNT(pa) > 0
|
||||||
|
THEN TRUE
|
||||||
|
ELSE FALSE
|
||||||
|
END
|
||||||
|
FROM
|
||||||
|
PaymentAttemptEntity pa
|
||||||
|
WHERE
|
||||||
|
pa.reservationId = :reservationId
|
||||||
|
AND pa.result = com.sangdol.roomescape.order.infrastructure.persistence.AttemptResult.SUCCESS
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
fun isSuccessAttemptExists(reservationId: Long): Boolean
|
||||||
|
|
||||||
|
fun findAllByReservationId(reservationId: Long): List<PaymentAttemptEntity>
|
||||||
|
}
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
package com.sangdol.roomescape.order.infrastructure.persistence
|
||||||
|
|
||||||
|
import com.sangdol.common.persistence.PersistableBaseEntity
|
||||||
|
import jakarta.persistence.Entity
|
||||||
|
import jakarta.persistence.Table
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "post_order_tasks")
|
||||||
|
class PostOrderTaskEntity(
|
||||||
|
id: Long,
|
||||||
|
val reservationId: Long,
|
||||||
|
val paymentKey: String,
|
||||||
|
val trial: Int,
|
||||||
|
val nextRetryAt: Instant
|
||||||
|
) : PersistableBaseEntity(id)
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
package com.sangdol.roomescape.order.infrastructure.persistence
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository
|
||||||
|
|
||||||
|
interface PostOrderTaskRepository : JpaRepository<PostOrderTaskEntity, Long> {
|
||||||
|
}
|
||||||
@ -1,6 +1,5 @@
|
|||||||
package com.sangdol.roomescape.payment.business
|
package com.sangdol.roomescape.payment.business
|
||||||
|
|
||||||
import com.sangdol.common.persistence.IDGenerator
|
|
||||||
import com.sangdol.common.persistence.TransactionExecutionUtil
|
import com.sangdol.common.persistence.TransactionExecutionUtil
|
||||||
import com.sangdol.roomescape.payment.business.domain.UserFacingPaymentErrorCode
|
import com.sangdol.roomescape.payment.business.domain.UserFacingPaymentErrorCode
|
||||||
import com.sangdol.roomescape.payment.dto.*
|
import com.sangdol.roomescape.payment.dto.*
|
||||||
@ -9,12 +8,9 @@ import com.sangdol.roomescape.payment.exception.PaymentErrorCode
|
|||||||
import com.sangdol.roomescape.payment.exception.PaymentException
|
import com.sangdol.roomescape.payment.exception.PaymentException
|
||||||
import com.sangdol.roomescape.payment.infrastructure.client.TosspayClient
|
import com.sangdol.roomescape.payment.infrastructure.client.TosspayClient
|
||||||
import com.sangdol.roomescape.payment.infrastructure.persistence.*
|
import com.sangdol.roomescape.payment.infrastructure.persistence.*
|
||||||
import com.sangdol.roomescape.payment.mapper.toEntity
|
|
||||||
import com.sangdol.roomescape.payment.mapper.toEvent
|
|
||||||
import com.sangdol.roomescape.payment.mapper.toResponse
|
import com.sangdol.roomescape.payment.mapper.toResponse
|
||||||
import io.github.oshai.kotlinlogging.KLogger
|
import io.github.oshai.kotlinlogging.KLogger
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
import org.springframework.context.ApplicationEventPublisher
|
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
|
||||||
@ -22,20 +18,18 @@ private val log: KLogger = KotlinLogging.logger {}
|
|||||||
|
|
||||||
@Service
|
@Service
|
||||||
class PaymentService(
|
class PaymentService(
|
||||||
private val idGenerator: IDGenerator,
|
|
||||||
private val paymentClient: TosspayClient,
|
private val paymentClient: TosspayClient,
|
||||||
private val paymentRepository: PaymentRepository,
|
private val paymentRepository: PaymentRepository,
|
||||||
private val paymentDetailRepository: PaymentDetailRepository,
|
private val paymentDetailRepository: PaymentDetailRepository,
|
||||||
private val canceledPaymentRepository: CanceledPaymentRepository,
|
private val canceledPaymentRepository: CanceledPaymentRepository,
|
||||||
|
private val paymentWriter: PaymentWriter,
|
||||||
private val transactionExecutionUtil: TransactionExecutionUtil,
|
private val transactionExecutionUtil: TransactionExecutionUtil,
|
||||||
private val eventPublisher: ApplicationEventPublisher
|
|
||||||
) {
|
) {
|
||||||
fun requestConfirm(reservationId: Long, request: PaymentConfirmRequest): PaymentGatewayResponse {
|
fun requestConfirm(request: PaymentConfirmRequest): PaymentGatewayResponse {
|
||||||
log.info { "[requestConfirm] 결제 요청 시작: paymentKey=${request.paymentKey}" }
|
log.info { "[requestConfirm] 결제 요청 시작: paymentKey=${request.paymentKey}" }
|
||||||
try {
|
try {
|
||||||
return paymentClient.confirm(request.paymentKey, request.orderId, request.amount).also {
|
return paymentClient.confirm(request.paymentKey, request.orderId, request.amount).also {
|
||||||
eventPublisher.publishEvent(it.toEvent(reservationId))
|
log.info { "[requestConfirm] 결제 완료: paymentKey=${request.paymentKey}" }
|
||||||
log.info { "[requestConfirm] 결제 및 이벤트 발행 완료: paymentKey=${request.paymentKey}" }
|
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
when(e) {
|
when(e) {
|
||||||
@ -62,6 +56,19 @@ class PaymentService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun savePayment(
|
||||||
|
reservationId: Long,
|
||||||
|
paymentGatewayResponse: PaymentGatewayResponse
|
||||||
|
): PaymentCreateResponse {
|
||||||
|
val payment: PaymentEntity = paymentWriter.createPayment(
|
||||||
|
reservationId = reservationId,
|
||||||
|
paymentGatewayResponse = paymentGatewayResponse
|
||||||
|
)
|
||||||
|
val detail: PaymentDetailEntity = paymentWriter.createDetail(paymentGatewayResponse, payment.id)
|
||||||
|
|
||||||
|
return PaymentCreateResponse(paymentId = payment.id, detailId = detail.id)
|
||||||
|
}
|
||||||
|
|
||||||
fun cancel(userId: Long, request: PaymentCancelRequest) {
|
fun cancel(userId: Long, request: PaymentCancelRequest) {
|
||||||
val payment: PaymentEntity = findByReservationIdOrThrow(request.reservationId)
|
val payment: PaymentEntity = findByReservationIdOrThrow(request.reservationId)
|
||||||
|
|
||||||
@ -72,17 +79,12 @@ class PaymentService(
|
|||||||
)
|
)
|
||||||
|
|
||||||
transactionExecutionUtil.withNewTransaction(isReadOnly = false) {
|
transactionExecutionUtil.withNewTransaction(isReadOnly = false) {
|
||||||
val payment = findByReservationIdOrThrow(request.reservationId).apply { this.cancel() }
|
paymentWriter.cancel(
|
||||||
|
userId = userId,
|
||||||
clientCancelResponse.cancels.toEntity(
|
payment = payment,
|
||||||
id = idGenerator.create(),
|
requestedAt = request.requestedAt,
|
||||||
paymentId = payment.id,
|
cancelResponse = clientCancelResponse
|
||||||
cancelRequestedAt = request.requestedAt,
|
)
|
||||||
canceledBy = userId
|
|
||||||
).also {
|
|
||||||
canceledPaymentRepository.save(it)
|
|
||||||
log.debug { "[cancel] 결제 취소 정보 저장 완료: payment.id=${payment.id}" }
|
|
||||||
}
|
|
||||||
}.also {
|
}.also {
|
||||||
log.info { "[cancel] 결제 취소 완료: paymentId=${payment.id}" }
|
log.info { "[cancel] 결제 취소 완료: paymentId=${payment.id}" }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,80 @@
|
|||||||
|
package com.sangdol.roomescape.payment.business
|
||||||
|
|
||||||
|
import com.sangdol.common.persistence.IDGenerator
|
||||||
|
import com.sangdol.roomescape.payment.exception.PaymentErrorCode
|
||||||
|
import com.sangdol.roomescape.payment.exception.PaymentException
|
||||||
|
import com.sangdol.roomescape.payment.business.domain.PaymentMethod
|
||||||
|
import com.sangdol.roomescape.payment.dto.PaymentGatewayCancelResponse
|
||||||
|
import com.sangdol.roomescape.payment.dto.PaymentGatewayResponse
|
||||||
|
import com.sangdol.roomescape.payment.mapper.toCardDetailEntity
|
||||||
|
import com.sangdol.roomescape.payment.mapper.toEasypayPrepaidDetailEntity
|
||||||
|
import com.sangdol.roomescape.payment.mapper.toEntity
|
||||||
|
import com.sangdol.roomescape.payment.mapper.toTransferDetailEntity
|
||||||
|
import com.sangdol.roomescape.payment.infrastructure.persistence.*
|
||||||
|
import io.github.oshai.kotlinlogging.KLogger
|
||||||
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
|
import org.springframework.stereotype.Component
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
|
private val log: KLogger = KotlinLogging.logger {}
|
||||||
|
|
||||||
|
@Component
|
||||||
|
class PaymentWriter(
|
||||||
|
private val paymentRepository: PaymentRepository,
|
||||||
|
private val paymentDetailRepository: PaymentDetailRepository,
|
||||||
|
private val canceledPaymentRepository: CanceledPaymentRepository,
|
||||||
|
private val idGenerator: IDGenerator,
|
||||||
|
) {
|
||||||
|
|
||||||
|
fun createPayment(
|
||||||
|
reservationId: Long,
|
||||||
|
paymentGatewayResponse: PaymentGatewayResponse
|
||||||
|
): PaymentEntity {
|
||||||
|
log.info { "[PaymentWriterV2.createPayment] 결제 승인 및 결제 정보 저장 시작: reservationId=${reservationId}, paymentKey=${paymentGatewayResponse.paymentKey}" }
|
||||||
|
|
||||||
|
return paymentGatewayResponse.toEntity(id = idGenerator.create(), reservationId = reservationId).also {
|
||||||
|
paymentRepository.save(it)
|
||||||
|
log.info { "[PaymentWriterV2.createPayment] 결제 승인 및 결제 정보 저장 완료: reservationId=${reservationId}, payment.id=${it.id}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createDetail(
|
||||||
|
paymentGatewayResponse: PaymentGatewayResponse,
|
||||||
|
paymentId: Long,
|
||||||
|
): PaymentDetailEntity {
|
||||||
|
val method: PaymentMethod = paymentGatewayResponse.method
|
||||||
|
val id = idGenerator.create()
|
||||||
|
|
||||||
|
if (method == PaymentMethod.TRANSFER) {
|
||||||
|
return paymentDetailRepository.save(paymentGatewayResponse.toTransferDetailEntity(id, paymentId))
|
||||||
|
}
|
||||||
|
if (method == PaymentMethod.EASY_PAY && paymentGatewayResponse.card == null) {
|
||||||
|
return paymentDetailRepository.save(paymentGatewayResponse.toEasypayPrepaidDetailEntity(id, paymentId))
|
||||||
|
}
|
||||||
|
if (paymentGatewayResponse.card != null) {
|
||||||
|
return paymentDetailRepository.save(paymentGatewayResponse.toCardDetailEntity(id, paymentId))
|
||||||
|
}
|
||||||
|
throw PaymentException(PaymentErrorCode.NOT_SUPPORTED_PAYMENT_TYPE)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancel(
|
||||||
|
userId: Long,
|
||||||
|
payment: PaymentEntity,
|
||||||
|
requestedAt: Instant,
|
||||||
|
cancelResponse: PaymentGatewayCancelResponse
|
||||||
|
): CanceledPaymentEntity {
|
||||||
|
log.debug { "[PaymentWriterV2.cancelPayment] 결제 취소 정보 저장 시작: payment.id=${payment.id}" }
|
||||||
|
|
||||||
|
paymentRepository.save(payment.apply { this.cancel() })
|
||||||
|
|
||||||
|
return cancelResponse.cancels.toEntity(
|
||||||
|
id = idGenerator.create(),
|
||||||
|
paymentId = payment.id,
|
||||||
|
cancelRequestedAt = requestedAt,
|
||||||
|
canceledBy = userId
|
||||||
|
).also {
|
||||||
|
canceledPaymentRepository.save(it)
|
||||||
|
log.debug { "[PaymentWriterV2.cancelPayment] 결제 취소 정보 저장 완료: payment.id=${payment.id}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,48 +0,0 @@
|
|||||||
package com.sangdol.roomescape.payment.business.event
|
|
||||||
|
|
||||||
import com.sangdol.common.persistence.IDGenerator
|
|
||||||
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentDetailEntity
|
|
||||||
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentDetailRepository
|
|
||||||
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentEntity
|
|
||||||
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentRepository
|
|
||||||
import com.sangdol.roomescape.payment.mapper.toDetailEntity
|
|
||||||
import com.sangdol.roomescape.payment.mapper.toEntity
|
|
||||||
import io.github.oshai.kotlinlogging.KLogger
|
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
|
||||||
import org.springframework.context.event.EventListener
|
|
||||||
import org.springframework.scheduling.annotation.Async
|
|
||||||
import org.springframework.stereotype.Component
|
|
||||||
import org.springframework.transaction.annotation.Transactional
|
|
||||||
|
|
||||||
private val log: KLogger = KotlinLogging.logger {}
|
|
||||||
|
|
||||||
@Component
|
|
||||||
class PaymentEventListener(
|
|
||||||
private val idGenerator: IDGenerator,
|
|
||||||
private val paymentRepository: PaymentRepository,
|
|
||||||
private val paymentDetailRepository: PaymentDetailRepository
|
|
||||||
) {
|
|
||||||
|
|
||||||
@Async
|
|
||||||
@EventListener
|
|
||||||
@Transactional
|
|
||||||
fun handlePaymentEvent(event: PaymentEvent) {
|
|
||||||
val reservationId = event.reservationId
|
|
||||||
|
|
||||||
log.info { "[handlePaymentEvent] 결제 정보 저장 이벤트 수신: reservationId=${reservationId}, paymentKey=${event.paymentKey}" }
|
|
||||||
|
|
||||||
val paymentId = idGenerator.create()
|
|
||||||
val paymentEntity: PaymentEntity = event.toEntity(paymentId)
|
|
||||||
paymentRepository.save(paymentEntity).also {
|
|
||||||
log.info { "[handlePaymentEvent] 결제 정보 저장 완료: paymentId=${paymentId}" }
|
|
||||||
}
|
|
||||||
|
|
||||||
val paymentDetailId = idGenerator.create()
|
|
||||||
val paymentDetailEntity: PaymentDetailEntity = event.toDetailEntity(id = paymentDetailId, paymentId = paymentId)
|
|
||||||
paymentDetailRepository.save(paymentDetailEntity).also {
|
|
||||||
log.info { "[handlePaymentEvent] 결제 상세 저장 완료: paymentDetailId=${paymentDetailId}" }
|
|
||||||
}
|
|
||||||
|
|
||||||
log.info { "[handlePaymentEvent] 결제 정보 저장 이벤트 처리 완료" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -2,8 +2,11 @@ package com.sangdol.roomescape.payment.docs
|
|||||||
|
|
||||||
import com.sangdol.common.types.web.CommonApiResponse
|
import com.sangdol.common.types.web.CommonApiResponse
|
||||||
import com.sangdol.roomescape.auth.web.support.User
|
import com.sangdol.roomescape.auth.web.support.User
|
||||||
|
import com.sangdol.roomescape.auth.web.support.UserOnly
|
||||||
import com.sangdol.roomescape.common.types.CurrentUserContext
|
import com.sangdol.roomescape.common.types.CurrentUserContext
|
||||||
|
import com.sangdol.roomescape.payment.dto.PaymentGatewayResponse
|
||||||
import com.sangdol.roomescape.payment.dto.PaymentCancelRequest
|
import com.sangdol.roomescape.payment.dto.PaymentCancelRequest
|
||||||
|
import com.sangdol.roomescape.payment.dto.PaymentConfirmRequest
|
||||||
import io.swagger.v3.oas.annotations.Operation
|
import io.swagger.v3.oas.annotations.Operation
|
||||||
import io.swagger.v3.oas.annotations.responses.ApiResponse
|
import io.swagger.v3.oas.annotations.responses.ApiResponse
|
||||||
import io.swagger.v3.oas.annotations.responses.ApiResponses
|
import io.swagger.v3.oas.annotations.responses.ApiResponses
|
||||||
@ -13,6 +16,13 @@ import org.springframework.web.bind.annotation.RequestBody
|
|||||||
|
|
||||||
interface PaymentAPI {
|
interface PaymentAPI {
|
||||||
|
|
||||||
|
@UserOnly
|
||||||
|
@Operation(summary = "결제 승인")
|
||||||
|
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
|
||||||
|
fun confirmPayment(
|
||||||
|
@Valid @RequestBody request: PaymentConfirmRequest
|
||||||
|
): ResponseEntity<CommonApiResponse<PaymentGatewayResponse>>
|
||||||
|
|
||||||
@Operation(summary = "결제 취소")
|
@Operation(summary = "결제 취소")
|
||||||
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
|
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
|
||||||
fun cancelPayment(
|
fun cancelPayment(
|
||||||
|
|||||||
@ -8,6 +8,11 @@ data class PaymentConfirmRequest(
|
|||||||
val amount: Int,
|
val amount: Int,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class PaymentCreateResponse(
|
||||||
|
val paymentId: Long,
|
||||||
|
val detailId: Long
|
||||||
|
)
|
||||||
|
|
||||||
data class PaymentCancelRequest(
|
data class PaymentCancelRequest(
|
||||||
val reservationId: Long,
|
val reservationId: Long,
|
||||||
val cancelReason: String,
|
val cancelReason: String,
|
||||||
|
|||||||
@ -1,14 +1,82 @@
|
|||||||
package com.sangdol.roomescape.payment.mapper
|
package com.sangdol.roomescape.payment.mapper
|
||||||
|
|
||||||
import com.sangdol.roomescape.payment.business.domain.*
|
|
||||||
import com.sangdol.roomescape.payment.business.event.PaymentEvent
|
|
||||||
import com.sangdol.roomescape.payment.dto.CancelDetail
|
import com.sangdol.roomescape.payment.dto.CancelDetail
|
||||||
import com.sangdol.roomescape.payment.dto.PaymentGatewayResponse
|
import com.sangdol.roomescape.payment.dto.PaymentGatewayResponse
|
||||||
import com.sangdol.roomescape.payment.exception.PaymentErrorCode
|
import com.sangdol.roomescape.payment.exception.PaymentErrorCode
|
||||||
import com.sangdol.roomescape.payment.exception.PaymentException
|
import com.sangdol.roomescape.payment.exception.PaymentException
|
||||||
import com.sangdol.roomescape.payment.infrastructure.persistence.CanceledPaymentEntity
|
import com.sangdol.roomescape.payment.infrastructure.persistence.*
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
|
|
||||||
|
fun PaymentGatewayResponse.toEntity(
|
||||||
|
id: Long,
|
||||||
|
reservationId: Long,
|
||||||
|
) = PaymentEntity(
|
||||||
|
id = id,
|
||||||
|
reservationId = reservationId,
|
||||||
|
paymentKey = this.paymentKey,
|
||||||
|
orderId = this.orderId,
|
||||||
|
totalAmount = this.totalAmount,
|
||||||
|
requestedAt = this.requestedAt.toInstant(),
|
||||||
|
approvedAt = this.approvedAt.toInstant(),
|
||||||
|
type = this.type,
|
||||||
|
method = this.method,
|
||||||
|
status = this.status,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun PaymentGatewayResponse.toCardDetailEntity(id: Long, paymentId: Long): PaymentCardDetailEntity {
|
||||||
|
val cardDetail = this.card ?: throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR)
|
||||||
|
|
||||||
|
return PaymentCardDetailEntity(
|
||||||
|
id = id,
|
||||||
|
paymentId = paymentId,
|
||||||
|
suppliedAmount = this.suppliedAmount,
|
||||||
|
vat = this.vat,
|
||||||
|
issuerCode = cardDetail.issuerCode,
|
||||||
|
cardType = cardDetail.cardType,
|
||||||
|
ownerType = cardDetail.ownerType,
|
||||||
|
amount = cardDetail.amount,
|
||||||
|
cardNumber = cardDetail.number,
|
||||||
|
approvalNumber = cardDetail.approveNo,
|
||||||
|
installmentPlanMonths = cardDetail.installmentPlanMonths,
|
||||||
|
isInterestFree = cardDetail.isInterestFree,
|
||||||
|
easypayProviderCode = this.easyPay?.provider,
|
||||||
|
easypayDiscountAmount = this.easyPay?.discountAmount,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun PaymentGatewayResponse.toEasypayPrepaidDetailEntity(
|
||||||
|
id: Long,
|
||||||
|
paymentId: Long
|
||||||
|
): PaymentEasypayPrepaidDetailEntity {
|
||||||
|
val easyPayDetail = this.easyPay ?: throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR)
|
||||||
|
|
||||||
|
return PaymentEasypayPrepaidDetailEntity(
|
||||||
|
id = id,
|
||||||
|
paymentId = paymentId,
|
||||||
|
suppliedAmount = this.suppliedAmount,
|
||||||
|
vat = this.vat,
|
||||||
|
easypayProviderCode = easyPayDetail.provider,
|
||||||
|
amount = easyPayDetail.amount,
|
||||||
|
discountAmount = easyPayDetail.discountAmount
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun PaymentGatewayResponse.toTransferDetailEntity(
|
||||||
|
id: Long,
|
||||||
|
paymentId: Long
|
||||||
|
): PaymentBankTransferDetailEntity {
|
||||||
|
val transferDetail = this.transfer ?: throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR)
|
||||||
|
|
||||||
|
return PaymentBankTransferDetailEntity(
|
||||||
|
id = id,
|
||||||
|
paymentId = paymentId,
|
||||||
|
suppliedAmount = this.suppliedAmount,
|
||||||
|
vat = this.vat,
|
||||||
|
bankCode = transferDetail.bankCode,
|
||||||
|
settlementStatus = transferDetail.settlementStatus
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fun CancelDetail.toEntity(
|
fun CancelDetail.toEntity(
|
||||||
id: Long,
|
id: Long,
|
||||||
paymentId: Long,
|
paymentId: Long,
|
||||||
@ -26,88 +94,3 @@ fun CancelDetail.toEntity(
|
|||||||
transferDiscountAmount = this.transferDiscountAmount,
|
transferDiscountAmount = this.transferDiscountAmount,
|
||||||
easypayDiscountAmount = this.easyPayDiscountAmount
|
easypayDiscountAmount = this.easyPayDiscountAmount
|
||||||
)
|
)
|
||||||
|
|
||||||
fun PaymentGatewayResponse.toEvent(reservationId: Long): PaymentEvent {
|
|
||||||
return PaymentEvent(
|
|
||||||
reservationId = reservationId,
|
|
||||||
paymentKey = this.paymentKey,
|
|
||||||
orderId = this.orderId,
|
|
||||||
type = this.type,
|
|
||||||
status = this.status,
|
|
||||||
totalAmount = this.totalAmount,
|
|
||||||
vat = this.vat,
|
|
||||||
suppliedAmount = this.suppliedAmount,
|
|
||||||
method = this.method,
|
|
||||||
requestedAt = this.requestedAt.toInstant(),
|
|
||||||
approvedAt = this.approvedAt.toInstant(),
|
|
||||||
detail = this.toDetail()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun PaymentGatewayResponse.toDetail(): PaymentDetail {
|
|
||||||
return when (this.method) {
|
|
||||||
PaymentMethod.TRANSFER -> this.toBankTransferDetail()
|
|
||||||
PaymentMethod.CARD -> this.toCardDetail()
|
|
||||||
PaymentMethod.EASY_PAY -> {
|
|
||||||
if (this.card != null) {
|
|
||||||
this.toEasypayCardDetail()
|
|
||||||
} else {
|
|
||||||
this.toEasypayPrepaidDetail()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> throw PaymentException(PaymentErrorCode.NOT_SUPPORTED_PAYMENT_TYPE)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun PaymentGatewayResponse.toBankTransferDetail(): BankTransferPaymentDetail {
|
|
||||||
val bankTransfer = this.transfer ?: throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR)
|
|
||||||
|
|
||||||
return BankTransferPaymentDetail(
|
|
||||||
bankCode = bankTransfer.bankCode,
|
|
||||||
settlementStatus = bankTransfer.settlementStatus
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun PaymentGatewayResponse.toCardDetail(): CardPaymentDetail {
|
|
||||||
val cardDetail = this.card ?: throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR)
|
|
||||||
|
|
||||||
return CardPaymentDetail(
|
|
||||||
issuerCode = cardDetail.issuerCode,
|
|
||||||
number = cardDetail.number,
|
|
||||||
amount = cardDetail.amount,
|
|
||||||
cardType = cardDetail.cardType,
|
|
||||||
ownerType = cardDetail.ownerType,
|
|
||||||
isInterestFree = cardDetail.isInterestFree,
|
|
||||||
approveNo = cardDetail.approveNo,
|
|
||||||
installmentPlanMonths = cardDetail.installmentPlanMonths
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun PaymentGatewayResponse.toEasypayCardDetail(): EasypayCardPaymentDetail {
|
|
||||||
val cardDetail = this.card ?: throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR)
|
|
||||||
val easypay = this.easyPay ?: throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR)
|
|
||||||
|
|
||||||
return EasypayCardPaymentDetail(
|
|
||||||
issuerCode = cardDetail.issuerCode,
|
|
||||||
number = cardDetail.number,
|
|
||||||
amount = cardDetail.amount,
|
|
||||||
cardType = cardDetail.cardType,
|
|
||||||
ownerType = cardDetail.ownerType,
|
|
||||||
isInterestFree = cardDetail.isInterestFree,
|
|
||||||
approveNo = cardDetail.approveNo,
|
|
||||||
installmentPlanMonths = cardDetail.installmentPlanMonths,
|
|
||||||
easypayProvider = easypay.provider,
|
|
||||||
easypayDiscountAmount = easypay.discountAmount
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun PaymentGatewayResponse.toEasypayPrepaidDetail(): EasypayPrepaidPaymentDetail {
|
|
||||||
val easypay = this.easyPay ?: throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR)
|
|
||||||
|
|
||||||
return EasypayPrepaidPaymentDetail(
|
|
||||||
provider = easypay.provider,
|
|
||||||
amount = easypay.amount,
|
|
||||||
discountAmount = easypay.discountAmount
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@ -6,18 +6,27 @@ import com.sangdol.roomescape.common.types.CurrentUserContext
|
|||||||
import com.sangdol.roomescape.payment.business.PaymentService
|
import com.sangdol.roomescape.payment.business.PaymentService
|
||||||
import com.sangdol.roomescape.payment.docs.PaymentAPI
|
import com.sangdol.roomescape.payment.docs.PaymentAPI
|
||||||
import com.sangdol.roomescape.payment.dto.PaymentCancelRequest
|
import com.sangdol.roomescape.payment.dto.PaymentCancelRequest
|
||||||
|
import com.sangdol.roomescape.payment.dto.PaymentConfirmRequest
|
||||||
|
import com.sangdol.roomescape.payment.dto.PaymentGatewayResponse
|
||||||
import jakarta.validation.Valid
|
import jakarta.validation.Valid
|
||||||
import org.springframework.http.ResponseEntity
|
import org.springframework.http.ResponseEntity
|
||||||
import org.springframework.web.bind.annotation.PostMapping
|
import org.springframework.web.bind.annotation.*
|
||||||
import org.springframework.web.bind.annotation.RequestBody
|
|
||||||
import org.springframework.web.bind.annotation.RequestMapping
|
|
||||||
import org.springframework.web.bind.annotation.RestController
|
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/payments")
|
@RequestMapping("/payments")
|
||||||
class PaymentController(
|
class PaymentController(
|
||||||
private val paymentService: PaymentService
|
private val paymentService: PaymentService
|
||||||
) : PaymentAPI {
|
) : PaymentAPI {
|
||||||
|
|
||||||
|
@PostMapping("/confirm")
|
||||||
|
override fun confirmPayment(
|
||||||
|
@Valid @RequestBody request: PaymentConfirmRequest
|
||||||
|
): ResponseEntity<CommonApiResponse<PaymentGatewayResponse>> {
|
||||||
|
val response = paymentService.requestConfirm(request)
|
||||||
|
|
||||||
|
return ResponseEntity.ok(CommonApiResponse(response))
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/cancel")
|
@PostMapping("/cancel")
|
||||||
override fun cancelPayment(
|
override fun cancelPayment(
|
||||||
@User user: CurrentUserContext,
|
@User user: CurrentUserContext,
|
||||||
|
|||||||
@ -1,34 +0,0 @@
|
|||||||
package com.sangdol.roomescape.reservation.business.event
|
|
||||||
|
|
||||||
import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationRepository
|
|
||||||
import io.github.oshai.kotlinlogging.KLogger
|
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
|
||||||
import org.springframework.context.event.EventListener
|
|
||||||
import org.springframework.scheduling.annotation.Async
|
|
||||||
import org.springframework.stereotype.Component
|
|
||||||
import org.springframework.transaction.annotation.Transactional
|
|
||||||
import java.time.Instant
|
|
||||||
|
|
||||||
private val log: KLogger = KotlinLogging.logger {}
|
|
||||||
|
|
||||||
@Component
|
|
||||||
class ReservationEventListener(
|
|
||||||
private val reservationRepository: ReservationRepository
|
|
||||||
) {
|
|
||||||
|
|
||||||
@Async
|
|
||||||
@EventListener
|
|
||||||
@Transactional
|
|
||||||
fun handleReservationConfirmEvent(event: ReservationConfirmEvent) {
|
|
||||||
val reservationId = event.reservationId
|
|
||||||
|
|
||||||
log.info { "[handleReservationConfirmEvent] 예약 확정 이벤트 수신: reservationId=${reservationId}" }
|
|
||||||
val modifiedRows = reservationRepository.confirmReservation(Instant.now(), reservationId)
|
|
||||||
|
|
||||||
if (modifiedRows == 0) {
|
|
||||||
log.warn { "[handleReservationConfirmEvent] 예상치 못한 예약 확정 실패 - 변경된 row 없음: reservationId=${reservationId}" }
|
|
||||||
}
|
|
||||||
|
|
||||||
log.info { "[handleReservationConfirmEvent] 예약 확정 이벤트 처리 완료" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -16,8 +16,8 @@ interface ReservationRepository : JpaRepository<ReservationEntity, Long> {
|
|||||||
@Query("SELECT r FROM ReservationEntity r WHERE r._id = :id")
|
@Query("SELECT r FROM ReservationEntity r WHERE r._id = :id")
|
||||||
fun findByIdForUpdate(@Param("id") id: Long): ReservationEntity?
|
fun findByIdForUpdate(@Param("id") id: Long): ReservationEntity?
|
||||||
|
|
||||||
@Query(
|
|
||||||
"""
|
@Query("""
|
||||||
SELECT
|
SELECT
|
||||||
r.id
|
r.id
|
||||||
FROM
|
FROM
|
||||||
@ -27,8 +27,7 @@ interface ReservationRepository : JpaRepository<ReservationEntity, Long> {
|
|||||||
WHERE
|
WHERE
|
||||||
r.status = 'PENDING' AND r.created_at <= DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 5 MINUTE)
|
r.status = 'PENDING' AND r.created_at <= DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 5 MINUTE)
|
||||||
FOR UPDATE SKIP LOCKED
|
FOR UPDATE SKIP LOCKED
|
||||||
""", nativeQuery = true
|
""", nativeQuery = true)
|
||||||
)
|
|
||||||
fun findAllExpiredReservation(): List<Long>
|
fun findAllExpiredReservation(): List<Long>
|
||||||
|
|
||||||
@Modifying
|
@Modifying
|
||||||
@ -48,23 +47,4 @@ interface ReservationRepository : JpaRepository<ReservationEntity, Long> {
|
|||||||
""", nativeQuery = true
|
""", nativeQuery = true
|
||||||
)
|
)
|
||||||
fun expirePendingReservations(@Param("now") now: Instant, @Param("reservationIds") reservationIds: List<Long>): Int
|
fun expirePendingReservations(@Param("now") now: Instant, @Param("reservationIds") reservationIds: List<Long>): Int
|
||||||
|
|
||||||
@Modifying
|
|
||||||
@Query(
|
|
||||||
"""
|
|
||||||
UPDATE
|
|
||||||
reservation r
|
|
||||||
JOIN
|
|
||||||
schedule s ON r.schedule_id = s.id AND s.status = 'HOLD'
|
|
||||||
SET
|
|
||||||
r.status = 'CONFIRMED',
|
|
||||||
r.updated_at = :now,
|
|
||||||
s.status = 'RESERVED',
|
|
||||||
s.hold_expired_at = NULL
|
|
||||||
WHERE
|
|
||||||
r.id = :id
|
|
||||||
AND r.status = 'PAYMENT_IN_PROGRESS'
|
|
||||||
""", nativeQuery = true
|
|
||||||
)
|
|
||||||
fun confirmReservation(@Param("now") now: Instant, @Param("id") id: Long): Int
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
package com.sangdol.roomescape.schedule.infrastructure.persistence
|
package com.sangdol.roomescape.schedule.infrastructure.persistence
|
||||||
|
|
||||||
import com.sangdol.roomescape.schedule.business.domain.ScheduleOverview
|
import com.sangdol.roomescape.schedule.business.domain.ScheduleOverview
|
||||||
|
import com.sangdol.roomescape.test.ScheduleWithThemeId
|
||||||
import jakarta.persistence.LockModeType
|
import jakarta.persistence.LockModeType
|
||||||
import org.springframework.data.jpa.repository.JpaRepository
|
import org.springframework.data.jpa.repository.JpaRepository
|
||||||
import org.springframework.data.jpa.repository.Lock
|
import org.springframework.data.jpa.repository.Lock
|
||||||
@ -159,4 +160,18 @@ interface ScheduleRepository : JpaRepository<ScheduleEntity, Long> {
|
|||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
fun releaseHeldSchedules(@Param("scheduleIds") scheduleIds: List<Long>): Int
|
fun releaseHeldSchedules(@Param("scheduleIds") scheduleIds: List<Long>): Int
|
||||||
|
|
||||||
|
/**
|
||||||
|
* for test
|
||||||
|
*/
|
||||||
|
@Query("""
|
||||||
|
SELECT
|
||||||
|
s.id, s.theme_id
|
||||||
|
FROM
|
||||||
|
schedule s
|
||||||
|
WHERE
|
||||||
|
s.status = 'AVAILABLE'
|
||||||
|
AND s.date > CURRENT_DATE
|
||||||
|
""", nativeQuery = true)
|
||||||
|
fun findAllAvailableSchedules(): List<ScheduleWithThemeId>
|
||||||
}
|
}
|
||||||
|
|||||||
@ -42,9 +42,4 @@ class TestSetupController(
|
|||||||
fun findAllStoreIds(): StoreIdList {
|
fun findAllStoreIds(): StoreIdList {
|
||||||
return testSetupService.findAllStores()
|
return testSetupService.findAllStores()
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/reservations-with-user")
|
|
||||||
fun findAllReservationsWithUser(): ReservationWithUserList {
|
|
||||||
return testSetupService.findAllReservationWithUser()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -45,13 +45,3 @@ data class StoreIdList(
|
|||||||
data class StoreId(
|
data class StoreId(
|
||||||
val storeId: Long
|
val storeId: Long
|
||||||
)
|
)
|
||||||
|
|
||||||
data class ReservationWithUser(
|
|
||||||
val account: String,
|
|
||||||
val password: String,
|
|
||||||
val reservationId: Long
|
|
||||||
)
|
|
||||||
|
|
||||||
data class ReservationWithUserList(
|
|
||||||
val results: List<ReservationWithUser>
|
|
||||||
)
|
|
||||||
|
|||||||
@ -1,50 +0,0 @@
|
|||||||
package com.sangdol.roomescape.test
|
|
||||||
|
|
||||||
import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationEntity
|
|
||||||
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleEntity
|
|
||||||
import com.sangdol.roomescape.user.infrastructure.persistence.UserEntity
|
|
||||||
import org.springframework.data.jpa.repository.JpaRepository
|
|
||||||
import org.springframework.data.jpa.repository.Query
|
|
||||||
|
|
||||||
interface TestSetupUserRepository: JpaRepository<UserEntity, Long> {
|
|
||||||
/**
|
|
||||||
* for test
|
|
||||||
*/
|
|
||||||
@Query("""
|
|
||||||
SELECT * FROM users u LIMIT :count
|
|
||||||
""", nativeQuery = true)
|
|
||||||
fun findUsersByCount(count: Long): List<UserEntity>
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TestSetupScheduleRepository: JpaRepository<ScheduleEntity, Long> {
|
|
||||||
/**
|
|
||||||
* for test
|
|
||||||
*/
|
|
||||||
@Query("""
|
|
||||||
SELECT
|
|
||||||
s.id, s.theme_id
|
|
||||||
FROM
|
|
||||||
schedule s
|
|
||||||
WHERE
|
|
||||||
s.status = 'AVAILABLE'
|
|
||||||
AND s.date > CURRENT_DATE
|
|
||||||
""", nativeQuery = true)
|
|
||||||
fun findAllAvailableSchedules(): List<ScheduleWithThemeId>
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TestSetupReservationRepository: JpaRepository<ReservationEntity, Long> {
|
|
||||||
/**
|
|
||||||
* for test
|
|
||||||
*/
|
|
||||||
@Query(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
u.email, u.password, r.id
|
|
||||||
FROM
|
|
||||||
reservation r
|
|
||||||
JOIN users u ON u.id = r.user_id
|
|
||||||
""", nativeQuery = true
|
|
||||||
)
|
|
||||||
fun findAllReservationWithUser(): List<ReservationWithUser>
|
|
||||||
}
|
|
||||||
@ -3,8 +3,10 @@ package com.sangdol.roomescape.test
|
|||||||
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminPermissionLevel
|
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminPermissionLevel
|
||||||
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminRepository
|
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminRepository
|
||||||
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminType
|
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminType
|
||||||
|
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleRepository
|
||||||
import com.sangdol.roomescape.store.infrastructure.persistence.StoreRepository
|
import com.sangdol.roomescape.store.infrastructure.persistence.StoreRepository
|
||||||
import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeRepository
|
import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeRepository
|
||||||
|
import com.sangdol.roomescape.user.infrastructure.persistence.UserRepository
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
import java.time.LocalTime
|
import java.time.LocalTime
|
||||||
@ -14,9 +16,8 @@ class TestSetupService(
|
|||||||
private val themeRepository: ThemeRepository,
|
private val themeRepository: ThemeRepository,
|
||||||
private val storeRepository: StoreRepository,
|
private val storeRepository: StoreRepository,
|
||||||
private val adminRepository: AdminRepository,
|
private val adminRepository: AdminRepository,
|
||||||
private val userRepository: TestSetupUserRepository,
|
private val userRepository: UserRepository,
|
||||||
private val scheduleRepository: TestSetupScheduleRepository,
|
private val scheduleRepository: ScheduleRepository,
|
||||||
private val reservationRepository: TestSetupReservationRepository
|
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
@ -84,11 +85,4 @@ class TestSetupService(
|
|||||||
StoreId(it.id)
|
StoreId(it.id)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
fun findAllReservationWithUser(): ReservationWithUserList {
|
|
||||||
return ReservationWithUserList(
|
|
||||||
reservationRepository.findAllReservationWithUser()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,12 +1,21 @@
|
|||||||
package com.sangdol.roomescape.user.infrastructure.persistence
|
package com.sangdol.roomescape.user.infrastructure.persistence
|
||||||
|
|
||||||
import org.springframework.data.jpa.repository.JpaRepository
|
import org.springframework.data.jpa.repository.JpaRepository
|
||||||
|
import org.springframework.data.jpa.repository.Query
|
||||||
|
|
||||||
interface UserRepository : JpaRepository<UserEntity, Long> {
|
interface UserRepository : JpaRepository<UserEntity, Long> {
|
||||||
|
|
||||||
fun existsByEmail(email: String): Boolean
|
fun existsByEmail(email: String): Boolean
|
||||||
fun existsByPhone(phone: String): Boolean
|
fun existsByPhone(phone: String): Boolean
|
||||||
fun findByEmail(email: String): UserEntity?
|
fun findByEmail(email: String): UserEntity?
|
||||||
|
|
||||||
|
/**
|
||||||
|
* for test
|
||||||
|
*/
|
||||||
|
@Query("""
|
||||||
|
SELECT * FROM users u LIMIT :count
|
||||||
|
""", nativeQuery = true)
|
||||||
|
fun findUsersByCount(count: Long): List<UserEntity>
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UserStatusHistoryRepository : JpaRepository<UserStatusHistoryEntity, Long>
|
interface UserStatusHistoryRepository : JpaRepository<UserStatusHistoryEntity, Long>
|
||||||
|
|||||||
@ -1,38 +1,46 @@
|
|||||||
package com.sangdol.roomescape.order
|
package com.sangdol.roomescape.order
|
||||||
|
|
||||||
import com.ninjasquad.springmockk.MockkBean
|
import com.ninjasquad.springmockk.SpykBean
|
||||||
import com.sangdol.common.utils.KoreaDate
|
import com.sangdol.common.utils.KoreaDate
|
||||||
import com.sangdol.common.utils.KoreaTime
|
import com.sangdol.common.utils.KoreaTime
|
||||||
import com.sangdol.roomescape.auth.exception.AuthErrorCode
|
import com.sangdol.roomescape.auth.exception.AuthErrorCode
|
||||||
import com.sangdol.roomescape.order.exception.OrderErrorCode
|
import com.sangdol.roomescape.order.exception.OrderErrorCode
|
||||||
|
import com.sangdol.roomescape.order.infrastructure.persistence.AttemptResult
|
||||||
|
import com.sangdol.roomescape.order.infrastructure.persistence.PaymentAttemptEntity
|
||||||
|
import com.sangdol.roomescape.order.infrastructure.persistence.PaymentAttemptRepository
|
||||||
|
import com.sangdol.roomescape.order.infrastructure.persistence.PostOrderTaskRepository
|
||||||
|
import com.sangdol.roomescape.payment.business.PaymentService
|
||||||
import com.sangdol.roomescape.payment.business.domain.PaymentMethod
|
import com.sangdol.roomescape.payment.business.domain.PaymentMethod
|
||||||
import com.sangdol.roomescape.payment.business.event.PaymentEvent
|
|
||||||
import com.sangdol.roomescape.payment.business.event.PaymentEventListener
|
|
||||||
import com.sangdol.roomescape.payment.dto.PaymentConfirmRequest
|
import com.sangdol.roomescape.payment.dto.PaymentConfirmRequest
|
||||||
import com.sangdol.roomescape.payment.dto.PaymentGatewayResponse
|
import com.sangdol.roomescape.payment.dto.PaymentGatewayResponse
|
||||||
import com.sangdol.roomescape.payment.exception.ExternalPaymentException
|
|
||||||
import com.sangdol.roomescape.payment.exception.PaymentErrorCode
|
import com.sangdol.roomescape.payment.exception.PaymentErrorCode
|
||||||
import com.sangdol.roomescape.payment.infrastructure.client.TosspayClient
|
import com.sangdol.roomescape.payment.exception.PaymentException
|
||||||
import com.sangdol.roomescape.reservation.business.event.ReservationConfirmEvent
|
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentDetailRepository
|
||||||
import com.sangdol.roomescape.reservation.business.event.ReservationEventListener
|
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentRepository
|
||||||
import com.sangdol.roomescape.reservation.exception.ReservationErrorCode
|
import com.sangdol.roomescape.reservation.exception.ReservationErrorCode
|
||||||
import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationRepository
|
import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationRepository
|
||||||
import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationStatus
|
import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationStatus
|
||||||
|
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleRepository
|
||||||
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus
|
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus
|
||||||
import com.sangdol.roomescape.supports.*
|
import com.sangdol.roomescape.supports.*
|
||||||
import com.sangdol.roomescape.user.infrastructure.persistence.UserEntity
|
import com.sangdol.roomescape.user.infrastructure.persistence.UserEntity
|
||||||
import io.kotest.assertions.assertSoftly
|
import io.kotest.assertions.assertSoftly
|
||||||
|
import io.kotest.matchers.booleans.shouldBeTrue
|
||||||
|
import io.kotest.matchers.nulls.shouldNotBeNull
|
||||||
import io.kotest.matchers.shouldBe
|
import io.kotest.matchers.shouldBe
|
||||||
import io.mockk.*
|
import io.mockk.every
|
||||||
import org.springframework.data.repository.findByIdOrNull
|
import org.springframework.data.repository.findByIdOrNull
|
||||||
import org.springframework.http.HttpMethod
|
import org.springframework.http.HttpMethod
|
||||||
import org.springframework.http.HttpStatus
|
import org.springframework.http.HttpStatus
|
||||||
|
|
||||||
class OrderApiTest(
|
class OrderApiTest(
|
||||||
@MockkBean(relaxed = true) private val paymentClient: TosspayClient,
|
@SpykBean private val paymentService: PaymentService,
|
||||||
@MockkBean(relaxed = true) private val reservationEventListener: ReservationEventListener,
|
private val paymentAttemptRepository: PaymentAttemptRepository,
|
||||||
@MockkBean(relaxed = true) private val paymentEventListener: PaymentEventListener,
|
|
||||||
private val reservationRepository: ReservationRepository,
|
private val reservationRepository: ReservationRepository,
|
||||||
|
private val postOrderTaskRepository: PostOrderTaskRepository,
|
||||||
|
private val scheduleRepository: ScheduleRepository,
|
||||||
|
private val paymentRepository: PaymentRepository,
|
||||||
|
private val paymentDetailRepository: PaymentDetailRepository
|
||||||
) : FunSpecSpringbootTest() {
|
) : FunSpecSpringbootTest() {
|
||||||
|
|
||||||
val paymentRequest: PaymentConfirmRequest = PaymentFixture.confirmRequest
|
val paymentRequest: PaymentConfirmRequest = PaymentFixture.confirmRequest
|
||||||
@ -74,52 +82,39 @@ class OrderApiTest(
|
|||||||
}
|
}
|
||||||
|
|
||||||
test("정상 응답") {
|
test("정상 응답") {
|
||||||
val reservationId = dummyInitializer.createPendingReservation(user).id
|
val reservation = dummyInitializer.createPendingReservation(user)
|
||||||
|
|
||||||
val reservationConfirmEventSlot = slot<ReservationConfirmEvent>()
|
|
||||||
val paymentEventSlot = slot<PaymentEvent>()
|
|
||||||
|
|
||||||
every {
|
every {
|
||||||
paymentClient.confirm(paymentRequest.paymentKey, paymentRequest.orderId, paymentRequest.amount)
|
paymentService.requestConfirm(paymentRequest)
|
||||||
} returns expectedPaymentResponse
|
} returns expectedPaymentResponse
|
||||||
|
|
||||||
every {
|
|
||||||
paymentEventListener.handlePaymentEvent(capture(paymentEventSlot))
|
|
||||||
} just runs
|
|
||||||
|
|
||||||
every {
|
|
||||||
reservationEventListener.handleReservationConfirmEvent(capture(reservationConfirmEventSlot))
|
|
||||||
} just runs
|
|
||||||
|
|
||||||
runTest(
|
runTest(
|
||||||
token = token,
|
token = token,
|
||||||
using = {
|
using = {
|
||||||
body(paymentRequest)
|
body(paymentRequest)
|
||||||
},
|
},
|
||||||
on = {
|
on = {
|
||||||
post("/orders/${reservationId}/confirm")
|
post("/orders/${reservation.id}/confirm")
|
||||||
},
|
},
|
||||||
expect = {
|
expect = {
|
||||||
statusCode(HttpStatus.OK.value())
|
statusCode(HttpStatus.OK.value())
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
verify(exactly = 1) {
|
assertSoftly(scheduleRepository.findByIdOrNull(reservation.scheduleId)!!) {
|
||||||
paymentEventListener.handlePaymentEvent(any())
|
this.status shouldBe ScheduleStatus.RESERVED
|
||||||
}.also {
|
this.holdExpiredAt shouldBe null
|
||||||
assertSoftly(paymentEventSlot.captured) {
|
|
||||||
this.paymentKey shouldBe expectedPaymentResponse.paymentKey
|
|
||||||
this.reservationId shouldBe reservationId
|
|
||||||
}
|
}
|
||||||
|
reservationRepository.findByIdOrNull(reservation.id)!!.status shouldBe ReservationStatus.CONFIRMED
|
||||||
|
|
||||||
|
assertSoftly(paymentRepository.findByReservationId(reservation.id)) {
|
||||||
|
this.shouldNotBeNull()
|
||||||
|
this.status shouldBe expectedPaymentResponse.status
|
||||||
|
|
||||||
|
paymentDetailRepository.findByPaymentId(this.id)!!.shouldNotBeNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
verify(exactly = 1) {
|
paymentAttemptRepository.isSuccessAttemptExists(reservation.id).shouldBeTrue()
|
||||||
reservationEventListener.handleReservationConfirmEvent(any())
|
|
||||||
}.also {
|
|
||||||
assertSoftly(reservationConfirmEventSlot.captured) {
|
|
||||||
this.reservationId shouldBe reservationId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
context("검증 과정에서의 실패 응답") {
|
context("검증 과정에서의 실패 응답") {
|
||||||
@ -133,6 +128,24 @@ class OrderApiTest(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("이미 결제가 완료된 예약이면 실패한다.") {
|
||||||
|
val reservation = dummyInitializer.createPendingReservation(user)
|
||||||
|
|
||||||
|
paymentAttemptRepository.save(PaymentAttemptEntity(
|
||||||
|
id = IDGenerator.create(),
|
||||||
|
reservationId = reservation.id,
|
||||||
|
result = AttemptResult.SUCCESS
|
||||||
|
))
|
||||||
|
|
||||||
|
runExceptionTest(
|
||||||
|
token = token,
|
||||||
|
method = HttpMethod.POST,
|
||||||
|
endpoint = "/orders/${reservation.id}/confirm",
|
||||||
|
requestBody = paymentRequest,
|
||||||
|
expectedErrorCode = OrderErrorCode.NOT_CONFIRMABLE
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
test("이미 확정된 예약이면 실패한다.") {
|
test("이미 확정된 예약이면 실패한다.") {
|
||||||
val reservation = dummyInitializer.createConfirmReservation(user)
|
val reservation = dummyInitializer.createConfirmReservation(user)
|
||||||
|
|
||||||
@ -210,23 +223,68 @@ class OrderApiTest(
|
|||||||
}
|
}
|
||||||
|
|
||||||
context("결제 과정에서의 실패 응답.") {
|
context("결제 과정에서의 실패 응답.") {
|
||||||
test("결제에 실패해도 검증을 통과하면 예약을 ${ReservationStatus.PAYMENT_IN_PROGRESS} 상태가 된다.") {
|
test("결제에 실패해도 검증을 통과하면 예약을 ${ReservationStatus.PAYMENT_IN_PROGRESS} 상태로 바꾸고, 결제 시도 이력을 기록한다.") {
|
||||||
val reservationId = dummyInitializer.createPendingReservation(user).id
|
val reservation = dummyInitializer.createPendingReservation(user)
|
||||||
|
|
||||||
every {
|
every {
|
||||||
paymentClient.confirm(paymentRequest.paymentKey, paymentRequest.orderId, paymentRequest.amount)
|
paymentService.requestConfirm(paymentRequest)
|
||||||
} throws ExternalPaymentException(400, "INVALID_REQUEST", "잘못 요청함")
|
} throws PaymentException(PaymentErrorCode.PAYMENT_CLIENT_ERROR)
|
||||||
|
|
||||||
runExceptionTest(
|
runExceptionTest(
|
||||||
token = token,
|
token = token,
|
||||||
method = HttpMethod.POST,
|
method = HttpMethod.POST,
|
||||||
endpoint = "/orders/${reservationId}/confirm",
|
endpoint = "/orders/${reservation.id}/confirm",
|
||||||
requestBody = paymentRequest,
|
requestBody = paymentRequest,
|
||||||
expectedErrorCode = PaymentErrorCode.PAYMENT_CLIENT_ERROR
|
expectedErrorCode = PaymentErrorCode.PAYMENT_CLIENT_ERROR
|
||||||
|
).also {
|
||||||
|
it.extract().path<Long>("trial") shouldBe 0
|
||||||
|
}
|
||||||
|
|
||||||
|
assertSoftly(reservationRepository.findByIdOrNull(reservation.id)!!) {
|
||||||
|
this.status shouldBe ReservationStatus.PAYMENT_IN_PROGRESS
|
||||||
|
}
|
||||||
|
|
||||||
|
val paymentAttempt = paymentAttemptRepository.findAll().first { it.reservationId == reservation.id }
|
||||||
|
assertSoftly(paymentAttempt) {
|
||||||
|
it.shouldNotBeNull()
|
||||||
|
it.result shouldBe AttemptResult.FAILED
|
||||||
|
it.errorCode shouldBe PaymentErrorCode.PAYMENT_CLIENT_ERROR.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
context("결제 성공 이후 실패 응답.") {
|
||||||
|
test("결제 이력 저장 과정에서 예외가 발생하면 해당 작업을 저장하며, 사용자는 정상 응답을 받는다.") {
|
||||||
|
val reservation = dummyInitializer.createPendingReservation(user)
|
||||||
|
|
||||||
|
every {
|
||||||
|
paymentService.requestConfirm(paymentRequest)
|
||||||
|
} returns expectedPaymentResponse
|
||||||
|
|
||||||
|
every {
|
||||||
|
paymentService.savePayment(reservation.id, expectedPaymentResponse)
|
||||||
|
} throws RuntimeException("결제 저장 실패!")
|
||||||
|
|
||||||
|
runTest(
|
||||||
|
token = token,
|
||||||
|
using = {
|
||||||
|
body(paymentRequest)
|
||||||
|
},
|
||||||
|
on = {
|
||||||
|
post("/orders/${reservation.id}/confirm")
|
||||||
|
},
|
||||||
|
expect = {
|
||||||
|
statusCode(HttpStatus.OK.value())
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
assertSoftly(reservationRepository.findByIdOrNull(reservationId)!!) {
|
paymentAttemptRepository.isSuccessAttemptExists(reservation.id).shouldBeTrue()
|
||||||
this.status shouldBe ReservationStatus.PAYMENT_IN_PROGRESS
|
|
||||||
|
val postOrderTask = postOrderTaskRepository.findAll().first { it.reservationId == reservation.id }
|
||||||
|
assertSoftly(postOrderTask) {
|
||||||
|
it.shouldNotBeNull()
|
||||||
|
it.paymentKey shouldBe paymentRequest.paymentKey
|
||||||
|
it.trial shouldBe 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,7 +18,6 @@ import com.sangdol.roomescape.supports.ReservationFixture
|
|||||||
import com.sangdol.roomescape.user.infrastructure.persistence.UserEntity
|
import com.sangdol.roomescape.user.infrastructure.persistence.UserEntity
|
||||||
import io.kotest.assertions.assertSoftly
|
import io.kotest.assertions.assertSoftly
|
||||||
import io.kotest.matchers.shouldBe
|
import io.kotest.matchers.shouldBe
|
||||||
import io.kotest.matchers.shouldNotBe
|
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
@ -70,7 +69,7 @@ class OrderConcurrencyTest(
|
|||||||
|
|
||||||
test("결제 요청 직후 시작되는 예약 만료 배치는, 해당 예약이 만료되었더라도 적용되지 않는다.") {
|
test("결제 요청 직후 시작되는 예약 만료 배치는, 해당 예약이 만료되었더라도 적용되지 않는다.") {
|
||||||
every {
|
every {
|
||||||
paymentService.requestConfirm(reservation.id, paymentConfirmRequest)
|
paymentService.requestConfirm(paymentConfirmRequest)
|
||||||
} returns paymentGatewayResponse
|
} returns paymentGatewayResponse
|
||||||
|
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
@ -88,13 +87,18 @@ class OrderConcurrencyTest(
|
|||||||
}
|
}
|
||||||
|
|
||||||
assertSoftly(reservationRepository.findByIdOrNull(reservation.id)!!) {
|
assertSoftly(reservationRepository.findByIdOrNull(reservation.id)!!) {
|
||||||
this.status shouldNotBe ReservationStatus.EXPIRED
|
this.status shouldBe ReservationStatus.CONFIRMED
|
||||||
|
}
|
||||||
|
|
||||||
|
assertSoftly(scheduleRepository.findByIdOrNull(schedule.id)!!) {
|
||||||
|
this.status shouldBe ScheduleStatus.RESERVED
|
||||||
|
this.holdExpiredAt shouldBe null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
test("결제 요청 직전에 예약 만료 배치가 시작하면, 결제 요청은 실패한다.") {
|
test("결제 요청 직전에 예약 만료 배치가 시작하면, 결제 요청은 실패한다.") {
|
||||||
every {
|
every {
|
||||||
paymentService.requestConfirm(reservation.id, paymentConfirmRequest)
|
paymentService.requestConfirm(paymentConfirmRequest)
|
||||||
} returns paymentGatewayResponse
|
} returns paymentGatewayResponse
|
||||||
|
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
@ -109,6 +113,8 @@ class OrderConcurrencyTest(
|
|||||||
async {
|
async {
|
||||||
assertThrows<OrderException> {
|
assertThrows<OrderException> {
|
||||||
orderService.confirm(reservation.id, paymentConfirmRequest)
|
orderService.confirm(reservation.id, paymentConfirmRequest)
|
||||||
|
}.also {
|
||||||
|
it.trial shouldBe 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,21 +4,22 @@ import com.ninjasquad.springmockk.MockkBean
|
|||||||
import com.sangdol.common.types.web.HttpStatus
|
import com.sangdol.common.types.web.HttpStatus
|
||||||
import com.sangdol.roomescape.auth.exception.AuthErrorCode
|
import com.sangdol.roomescape.auth.exception.AuthErrorCode
|
||||||
import com.sangdol.roomescape.payment.business.PaymentService
|
import com.sangdol.roomescape.payment.business.PaymentService
|
||||||
import com.sangdol.roomescape.payment.business.domain.PaymentMethod
|
import com.sangdol.roomescape.payment.business.domain.*
|
||||||
import com.sangdol.roomescape.payment.business.domain.PaymentStatus
|
import com.sangdol.roomescape.payment.dto.*
|
||||||
import com.sangdol.roomescape.payment.dto.PaymentConfirmRequest
|
import com.sangdol.roomescape.payment.exception.ExternalPaymentException
|
||||||
import com.sangdol.roomescape.payment.exception.PaymentErrorCode
|
import com.sangdol.roomescape.payment.exception.PaymentErrorCode
|
||||||
import com.sangdol.roomescape.payment.infrastructure.client.TosspayClient
|
import com.sangdol.roomescape.payment.infrastructure.client.TosspayClient
|
||||||
import com.sangdol.roomescape.payment.infrastructure.persistence.CanceledPaymentRepository
|
import com.sangdol.roomescape.payment.infrastructure.persistence.CanceledPaymentRepository
|
||||||
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentDetailRepository
|
|
||||||
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentEntity
|
|
||||||
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentRepository
|
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentRepository
|
||||||
import com.sangdol.roomescape.payment.mapper.toDetailEntity
|
import com.sangdol.roomescape.supports.FunSpecSpringbootTest
|
||||||
import com.sangdol.roomescape.payment.mapper.toEntity
|
import com.sangdol.roomescape.supports.PaymentFixture
|
||||||
import com.sangdol.roomescape.payment.mapper.toEvent
|
import com.sangdol.roomescape.supports.runExceptionTest
|
||||||
import com.sangdol.roomescape.supports.*
|
import com.sangdol.roomescape.supports.runTest
|
||||||
import io.kotest.matchers.shouldBe
|
import io.kotest.matchers.shouldBe
|
||||||
|
import io.mockk.clearMocks
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
|
import org.hamcrest.CoreMatchers.containsString
|
||||||
|
import org.hamcrest.CoreMatchers.equalTo
|
||||||
import org.springframework.data.repository.findByIdOrNull
|
import org.springframework.data.repository.findByIdOrNull
|
||||||
import org.springframework.http.HttpMethod
|
import org.springframework.http.HttpMethod
|
||||||
|
|
||||||
@ -27,10 +28,211 @@ class PaymentAPITest(
|
|||||||
private val tosspayClient: TosspayClient,
|
private val tosspayClient: TosspayClient,
|
||||||
private val paymentService: PaymentService,
|
private val paymentService: PaymentService,
|
||||||
private val paymentRepository: PaymentRepository,
|
private val paymentRepository: PaymentRepository,
|
||||||
private val paymentDetailRepository: PaymentDetailRepository,
|
|
||||||
private val canceledPaymentRepository: CanceledPaymentRepository
|
private val canceledPaymentRepository: CanceledPaymentRepository
|
||||||
) : FunSpecSpringbootTest() {
|
) : FunSpecSpringbootTest() {
|
||||||
init {
|
init {
|
||||||
|
context("결제를 승인한다.") {
|
||||||
|
context("권한이 없으면 접근할 수 없다.") {
|
||||||
|
val endpoint = "/payments/confirm"
|
||||||
|
|
||||||
|
test("비회원") {
|
||||||
|
runExceptionTest(
|
||||||
|
method = HttpMethod.POST,
|
||||||
|
endpoint = endpoint,
|
||||||
|
expectedErrorCode = AuthErrorCode.TOKEN_NOT_FOUND
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("관리자") {
|
||||||
|
runExceptionTest(
|
||||||
|
token = testAuthUtil.defaultHqAdminLogin().second,
|
||||||
|
method = HttpMethod.POST,
|
||||||
|
endpoint = endpoint,
|
||||||
|
expectedErrorCode = AuthErrorCode.ACCESS_DENIED
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val amount = 100_000
|
||||||
|
context("간편결제 + 카드로 ${amount}원을 결제한다.") {
|
||||||
|
context("일시불") {
|
||||||
|
test("토스페이 + 토스뱅크카드(신용)") {
|
||||||
|
runConfirmTest(
|
||||||
|
amount = amount,
|
||||||
|
cardDetail = PaymentFixture.cardDetail(
|
||||||
|
amount = amount,
|
||||||
|
issuerCode = CardIssuerCode.TOSS_BANK,
|
||||||
|
cardType = CardType.CREDIT,
|
||||||
|
),
|
||||||
|
easyPayDetail = PaymentFixture.easypayDetail(
|
||||||
|
amount = 0,
|
||||||
|
provider = EasyPayCompanyCode.TOSSPAY
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("삼성페이 + 삼성카드(법인)") {
|
||||||
|
runConfirmTest(
|
||||||
|
amount = amount,
|
||||||
|
cardDetail = PaymentFixture.cardDetail(
|
||||||
|
amount = amount,
|
||||||
|
issuerCode = CardIssuerCode.SAMSUNG,
|
||||||
|
cardType = CardType.CREDIT,
|
||||||
|
ownerType = CardOwnerType.CORPORATE
|
||||||
|
),
|
||||||
|
easyPayDetail = PaymentFixture.easypayDetail(
|
||||||
|
amount = 0,
|
||||||
|
provider = EasyPayCompanyCode.SAMSUNGPAY
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
context("할부") {
|
||||||
|
val installmentPlanMonths = 12
|
||||||
|
test("네이버페이 + 신한카드 / 12개월") {
|
||||||
|
runConfirmTest(
|
||||||
|
amount = amount,
|
||||||
|
cardDetail = PaymentFixture.cardDetail(
|
||||||
|
amount = amount,
|
||||||
|
issuerCode = CardIssuerCode.SHINHAN,
|
||||||
|
installmentPlanMonths = installmentPlanMonths
|
||||||
|
),
|
||||||
|
easyPayDetail = PaymentFixture.easypayDetail(
|
||||||
|
amount = 0,
|
||||||
|
provider = EasyPayCompanyCode.NAVERPAY
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
context("간편결제사 포인트 일부 사용") {
|
||||||
|
val point = (amount * 0.1).toInt()
|
||||||
|
test("토스페이 + 국민카드(체크) / 일시불 / $point 포인트 사용") {
|
||||||
|
runConfirmTest(
|
||||||
|
amount = amount,
|
||||||
|
cardDetail = PaymentFixture.cardDetail(
|
||||||
|
amount = (amount - point),
|
||||||
|
issuerCode = CardIssuerCode.KOOKMIN,
|
||||||
|
cardType = CardType.CHECK
|
||||||
|
),
|
||||||
|
easyPayDetail = PaymentFixture.easypayDetail(
|
||||||
|
amount = 0,
|
||||||
|
provider = EasyPayCompanyCode.TOSSPAY,
|
||||||
|
discountAmount = point
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
context("간편결제사의 선불 충전금액으로 ${amount}원을 결제한다.") {
|
||||||
|
test("토스페이 + 토스페이머니 / 전액") {
|
||||||
|
runConfirmTest(
|
||||||
|
easyPayDetail = PaymentFixture.easypayDetail(
|
||||||
|
amount = amount,
|
||||||
|
provider = EasyPayCompanyCode.TOSSPAY
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val point = (amount * 0.05).toInt()
|
||||||
|
|
||||||
|
test("카카오페이 + 카카오페이머니 / $point 사용") {
|
||||||
|
runConfirmTest(
|
||||||
|
easyPayDetail = PaymentFixture.easypayDetail(
|
||||||
|
amount = (amount - point),
|
||||||
|
provider = EasyPayCompanyCode.KAKAOPAY,
|
||||||
|
discountAmount = point
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
context("계좌이체로 결제한다.") {
|
||||||
|
test("토스뱅크") {
|
||||||
|
runConfirmTest(
|
||||||
|
transferDetail = PaymentFixture.transferDetail(bankCode = BankCode.TOSS_BANK)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
context("결제 처리중 오류가 발생한다.") {
|
||||||
|
lateinit var token: String
|
||||||
|
val commonRequest = PaymentFixture.confirmRequest
|
||||||
|
|
||||||
|
beforeTest {
|
||||||
|
token = testAuthUtil.defaultUserLogin().second
|
||||||
|
}
|
||||||
|
|
||||||
|
afterTest {
|
||||||
|
clearMocks(tosspayClient)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("예외 코드가 UserFacingPaymentErrorCode에 있으면 결제 실패 메시지를 같이 담는다.") {
|
||||||
|
val statusCode = HttpStatus.BAD_REQUEST.value()
|
||||||
|
val message = "거래금액 한도를 초과했습니다."
|
||||||
|
|
||||||
|
every {
|
||||||
|
tosspayClient.confirm(commonRequest.paymentKey, commonRequest.orderId, commonRequest.amount)
|
||||||
|
} throws ExternalPaymentException(
|
||||||
|
httpStatusCode = statusCode,
|
||||||
|
errorCode = UserFacingPaymentErrorCode.EXCEED_MAX_AMOUNT.name,
|
||||||
|
message = message
|
||||||
|
)
|
||||||
|
|
||||||
|
runTest(
|
||||||
|
token = token,
|
||||||
|
using = {
|
||||||
|
body(commonRequest)
|
||||||
|
},
|
||||||
|
on = {
|
||||||
|
post("/payments/confirm")
|
||||||
|
},
|
||||||
|
expect = {
|
||||||
|
statusCode(statusCode)
|
||||||
|
body("code", equalTo(PaymentErrorCode.PAYMENT_CLIENT_ERROR.errorCode))
|
||||||
|
body("message", containsString(message))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
context("예외 코드가 UserFacingPaymentErrorCode에 없으면 Client의 상태 코드에 따라 다르게 처리한다.") {
|
||||||
|
mapOf(
|
||||||
|
HttpStatus.BAD_REQUEST.value() to PaymentErrorCode.PAYMENT_CLIENT_ERROR,
|
||||||
|
HttpStatus.INTERNAL_SERVER_ERROR.value() to PaymentErrorCode.PAYMENT_PROVIDER_ERROR
|
||||||
|
).forEach { (statusCode, expectedErrorCode) ->
|
||||||
|
test("statusCode=${statusCode}") {
|
||||||
|
val message = "잘못된 시크릿키 연동 정보 입니다."
|
||||||
|
|
||||||
|
every {
|
||||||
|
tosspayClient.confirm(commonRequest.paymentKey, commonRequest.orderId, commonRequest.amount)
|
||||||
|
} throws ExternalPaymentException(
|
||||||
|
httpStatusCode = statusCode,
|
||||||
|
errorCode = "INVALID_API_KEY",
|
||||||
|
message = message
|
||||||
|
)
|
||||||
|
|
||||||
|
runTest(
|
||||||
|
token = token,
|
||||||
|
using = {
|
||||||
|
body(commonRequest)
|
||||||
|
},
|
||||||
|
on = {
|
||||||
|
post("/payments/confirm")
|
||||||
|
},
|
||||||
|
expect = {
|
||||||
|
statusCode(statusCode)
|
||||||
|
body("code", equalTo(expectedErrorCode.errorCode))
|
||||||
|
body("message", equalTo(expectedErrorCode.message))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
context("결제를 취소한다.") {
|
context("결제를 취소한다.") {
|
||||||
context("권한이 없으면 접근할 수 없다.") {
|
context("권한이 없으면 접근할 수 없다.") {
|
||||||
val endpoint = "/payments/cancel"
|
val endpoint = "/payments/cancel"
|
||||||
@ -60,7 +262,7 @@ class PaymentAPITest(
|
|||||||
val reservation = dummyInitializer.createConfirmReservation(user = user)
|
val reservation = dummyInitializer.createConfirmReservation(user = user)
|
||||||
val confirmRequest = PaymentFixture.confirmRequest
|
val confirmRequest = PaymentFixture.confirmRequest
|
||||||
|
|
||||||
val paymentEntity = createPayment(
|
val paymentCreateResponse = createPayment(
|
||||||
request = confirmRequest,
|
request = confirmRequest,
|
||||||
reservationId = reservation.id
|
reservationId = reservation.id
|
||||||
)
|
)
|
||||||
@ -87,10 +289,10 @@ class PaymentAPITest(
|
|||||||
statusCode(HttpStatus.OK.value())
|
statusCode(HttpStatus.OK.value())
|
||||||
}
|
}
|
||||||
).also {
|
).also {
|
||||||
val payment = paymentRepository.findByIdOrNull(paymentEntity.id)
|
val payment = paymentRepository.findByIdOrNull(paymentCreateResponse.paymentId)
|
||||||
?: throw AssertionError("Unexpected Exception Occurred.")
|
?: throw AssertionError("Unexpected Exception Occurred.")
|
||||||
val canceledPayment =
|
val canceledPayment =
|
||||||
canceledPaymentRepository.findByPaymentId(paymentEntity.id)
|
canceledPaymentRepository.findByPaymentId(paymentCreateResponse.paymentId)
|
||||||
?: throw AssertionError("Unexpected Exception Occurred.")
|
?: throw AssertionError("Unexpected Exception Occurred.")
|
||||||
|
|
||||||
payment.status shouldBe PaymentStatus.CANCELED
|
payment.status shouldBe PaymentStatus.CANCELED
|
||||||
@ -117,7 +319,7 @@ class PaymentAPITest(
|
|||||||
private fun createPayment(
|
private fun createPayment(
|
||||||
request: PaymentConfirmRequest,
|
request: PaymentConfirmRequest,
|
||||||
reservationId: Long,
|
reservationId: Long,
|
||||||
): PaymentEntity {
|
): PaymentCreateResponse {
|
||||||
every {
|
every {
|
||||||
tosspayClient.confirm(request.paymentKey, request.orderId, request.amount)
|
tosspayClient.confirm(request.paymentKey, request.orderId, request.amount)
|
||||||
} returns PaymentFixture.confirmResponse(
|
} returns PaymentFixture.confirmResponse(
|
||||||
@ -129,10 +331,49 @@ class PaymentAPITest(
|
|||||||
transferDetail = null,
|
transferDetail = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
val paymentEvent = paymentService.requestConfirm(reservationId, request).toEvent(reservationId)
|
val paymentResponse = paymentService.requestConfirm(request)
|
||||||
|
return paymentService.savePayment(reservationId, paymentResponse)
|
||||||
return paymentRepository.save(paymentEvent.toEntity(IDGenerator.create())).also {
|
|
||||||
paymentDetailRepository.save(paymentEvent.toDetailEntity(IDGenerator.create(), it.id))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun runConfirmTest(
|
||||||
|
cardDetail: CardDetailResponse? = null,
|
||||||
|
easyPayDetail: EasyPayDetailResponse? = null,
|
||||||
|
transferDetail: TransferDetailResponse? = null,
|
||||||
|
paymentKey: String = "paymentKey",
|
||||||
|
amount: Int = 10000,
|
||||||
|
) {
|
||||||
|
val token = testAuthUtil.defaultUserLogin().second
|
||||||
|
val request = PaymentFixture.confirmRequest.copy(paymentKey = paymentKey, amount = amount)
|
||||||
|
|
||||||
|
val method = if (easyPayDetail != null) {
|
||||||
|
PaymentMethod.EASY_PAY
|
||||||
|
} else if (cardDetail != null) {
|
||||||
|
PaymentMethod.CARD
|
||||||
|
} else if (transferDetail != null) {
|
||||||
|
PaymentMethod.TRANSFER
|
||||||
|
} else {
|
||||||
|
throw AssertionError("결제타입 확인 필요.")
|
||||||
|
}
|
||||||
|
|
||||||
|
val clientResponse = PaymentFixture.confirmResponse(
|
||||||
|
paymentKey, amount, method, cardDetail, easyPayDetail, transferDetail
|
||||||
|
)
|
||||||
|
|
||||||
|
every {
|
||||||
|
tosspayClient.confirm(request.paymentKey, request.orderId, request.amount)
|
||||||
|
} returns clientResponse
|
||||||
|
|
||||||
|
runTest(
|
||||||
|
token = token,
|
||||||
|
using = {
|
||||||
|
body(request)
|
||||||
|
},
|
||||||
|
on = {
|
||||||
|
post("/payments/confirm")
|
||||||
|
},
|
||||||
|
expect = {
|
||||||
|
statusCode(HttpStatus.OK.value())
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,54 +0,0 @@
|
|||||||
package com.sangdol.roomescape.payment
|
|
||||||
|
|
||||||
import com.sangdol.roomescape.payment.business.domain.PaymentMethod
|
|
||||||
import com.sangdol.roomescape.payment.business.event.PaymentEventListener
|
|
||||||
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentCardDetailEntity
|
|
||||||
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentDetailRepository
|
|
||||||
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentRepository
|
|
||||||
import com.sangdol.roomescape.payment.mapper.toEvent
|
|
||||||
import com.sangdol.roomescape.supports.FunSpecSpringbootTest
|
|
||||||
import com.sangdol.roomescape.supports.PaymentFixture
|
|
||||||
import com.sangdol.roomescape.supports.initialize
|
|
||||||
import io.kotest.assertions.assertSoftly
|
|
||||||
import io.kotest.matchers.nulls.shouldNotBeNull
|
|
||||||
import io.kotest.matchers.shouldBe
|
|
||||||
|
|
||||||
class PaymentEventListenerTest(
|
|
||||||
private val paymentEventListener: PaymentEventListener,
|
|
||||||
private val paymentRepository: PaymentRepository,
|
|
||||||
private val paymentDetailRepository: PaymentDetailRepository,
|
|
||||||
) : FunSpecSpringbootTest() {
|
|
||||||
|
|
||||||
init {
|
|
||||||
test("결제 완료 이벤트를 처리한다.") {
|
|
||||||
val reservationId = initialize("FK 제약조건 해소를 위한 예약 생성") {
|
|
||||||
val user = testAuthUtil.defaultUser()
|
|
||||||
dummyInitializer.createPendingReservation(user)
|
|
||||||
}.id
|
|
||||||
|
|
||||||
val paymentExternalAPIResponse = PaymentFixture.confirmResponse(
|
|
||||||
paymentKey = "paymentKey",
|
|
||||||
amount = 100_000,
|
|
||||||
method = PaymentMethod.CARD
|
|
||||||
)
|
|
||||||
|
|
||||||
paymentEventListener.handlePaymentEvent(paymentExternalAPIResponse.toEvent(reservationId)).also {
|
|
||||||
Thread.sleep(100)
|
|
||||||
}
|
|
||||||
|
|
||||||
val payment = paymentRepository.findByReservationId(reservationId)
|
|
||||||
assertSoftly(payment!!) {
|
|
||||||
this.paymentKey shouldBe paymentExternalAPIResponse.paymentKey
|
|
||||||
this.totalAmount shouldBe paymentExternalAPIResponse.totalAmount
|
|
||||||
this.method shouldBe paymentExternalAPIResponse.method
|
|
||||||
}
|
|
||||||
|
|
||||||
val paymentDetail = paymentDetailRepository.findByPaymentId(payment.id)
|
|
||||||
assertSoftly(paymentDetail) {
|
|
||||||
this.shouldNotBeNull()
|
|
||||||
this::class shouldBe PaymentCardDetailEntity::class
|
|
||||||
(this as PaymentCardDetailEntity).amount shouldBe paymentExternalAPIResponse.totalAmount
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,185 +0,0 @@
|
|||||||
package com.sangdol.roomescape.payment
|
|
||||||
|
|
||||||
import com.ninjasquad.springmockk.MockkBean
|
|
||||||
import com.sangdol.roomescape.payment.business.PaymentService
|
|
||||||
import com.sangdol.roomescape.payment.business.domain.*
|
|
||||||
import com.sangdol.roomescape.payment.business.event.PaymentEvent
|
|
||||||
import com.sangdol.roomescape.payment.business.event.PaymentEventListener
|
|
||||||
import com.sangdol.roomescape.payment.dto.PaymentConfirmRequest
|
|
||||||
import com.sangdol.roomescape.payment.dto.PaymentGatewayResponse
|
|
||||||
import com.sangdol.roomescape.payment.exception.ExternalPaymentException
|
|
||||||
import com.sangdol.roomescape.payment.exception.PaymentErrorCode
|
|
||||||
import com.sangdol.roomescape.payment.exception.PaymentException
|
|
||||||
import com.sangdol.roomescape.payment.infrastructure.client.TosspayClient
|
|
||||||
import com.sangdol.roomescape.supports.FunSpecSpringbootTest
|
|
||||||
import com.sangdol.roomescape.supports.PaymentFixture
|
|
||||||
import io.kotest.assertions.assertSoftly
|
|
||||||
import io.kotest.matchers.shouldBe
|
|
||||||
import io.mockk.*
|
|
||||||
import org.junit.jupiter.api.assertThrows
|
|
||||||
|
|
||||||
class PaymentServiceTest(
|
|
||||||
private val paymentService: PaymentService,
|
|
||||||
@MockkBean(relaxed = true) private val paymentEventListener: PaymentEventListener,
|
|
||||||
@MockkBean(relaxed = true) private val tosspayClient: TosspayClient
|
|
||||||
) : FunSpecSpringbootTest() {
|
|
||||||
|
|
||||||
init {
|
|
||||||
afterTest {
|
|
||||||
clearAllMocks()
|
|
||||||
}
|
|
||||||
|
|
||||||
context("결제를 승인한다.") {
|
|
||||||
val request = PaymentFixture.confirmRequest
|
|
||||||
|
|
||||||
context("결제 정상 승인 및 이벤트 발행 확인") {
|
|
||||||
test("간편결제 + 카드") {
|
|
||||||
val tosspayAPIResponse = PaymentFixture.confirmResponse(
|
|
||||||
paymentKey = request.paymentKey,
|
|
||||||
amount = request.amount,
|
|
||||||
orderId = request.orderId,
|
|
||||||
method = PaymentMethod.EASY_PAY,
|
|
||||||
cardDetail = PaymentFixture.cardDetail(100_000),
|
|
||||||
easyPayDetail = PaymentFixture.easypayDetail(0)
|
|
||||||
)
|
|
||||||
|
|
||||||
runSuccessTest(request, tosspayAPIResponse) {
|
|
||||||
assertSoftly(it.detail) {
|
|
||||||
this::class shouldBe EasypayCardPaymentDetail::class
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
test("간편결제 - 충전식") {
|
|
||||||
val tosspayAPIResponse = PaymentFixture.confirmResponse(
|
|
||||||
paymentKey = request.paymentKey,
|
|
||||||
amount = request.amount,
|
|
||||||
orderId = request.orderId,
|
|
||||||
method = PaymentMethod.EASY_PAY,
|
|
||||||
)
|
|
||||||
|
|
||||||
runSuccessTest(request, tosspayAPIResponse) {
|
|
||||||
assertSoftly(it.detail) {
|
|
||||||
this::class shouldBe EasypayPrepaidPaymentDetail::class
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
test("카드") {
|
|
||||||
val tosspayAPIResponse = PaymentFixture.confirmResponse(
|
|
||||||
paymentKey = request.paymentKey,
|
|
||||||
amount = request.amount,
|
|
||||||
orderId = request.orderId,
|
|
||||||
method = PaymentMethod.CARD,
|
|
||||||
)
|
|
||||||
|
|
||||||
runSuccessTest(request, tosspayAPIResponse) {
|
|
||||||
assertSoftly(it.detail) {
|
|
||||||
this::class shouldBe CardPaymentDetail::class
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
test("계좌이체") {
|
|
||||||
val tosspayAPIResponse = PaymentFixture.confirmResponse(
|
|
||||||
paymentKey = request.paymentKey,
|
|
||||||
amount = request.amount,
|
|
||||||
orderId = request.orderId,
|
|
||||||
method = PaymentMethod.TRANSFER,
|
|
||||||
)
|
|
||||||
|
|
||||||
runSuccessTest(request, tosspayAPIResponse) {
|
|
||||||
assertSoftly(it.detail) {
|
|
||||||
this::class shouldBe BankTransferPaymentDetail::class
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
context("외부 API 요청 과정에서 예외가 발생하면, 예외를 PaymentException으로 변환한 뒤 던진다.") {
|
|
||||||
|
|
||||||
test("외부 API가 4xx 응답을 보내면 ${PaymentErrorCode.PAYMENT_CLIENT_ERROR}로 변환하여 예외를 던진다.") {
|
|
||||||
val expectedErrorCode = PaymentErrorCode.PAYMENT_CLIENT_ERROR
|
|
||||||
val exception = ExternalPaymentException(400, "INVALID_REQUEST", "잘못된 요청입니다.")
|
|
||||||
every {
|
|
||||||
tosspayClient.confirm(request.paymentKey, request.orderId, request.amount)
|
|
||||||
} throws exception
|
|
||||||
|
|
||||||
assertThrows<PaymentException> {
|
|
||||||
paymentService.requestConfirm(12345L, request)
|
|
||||||
}.also {
|
|
||||||
it.errorCode shouldBe expectedErrorCode
|
|
||||||
it.message shouldBe expectedErrorCode.message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
test("외부 API가 5xx 응답을 보내면 ${PaymentErrorCode.PAYMENT_PROVIDER_ERROR}로 변환하여 예외를 던진다.") {
|
|
||||||
val expectedErrorCode = PaymentErrorCode.PAYMENT_PROVIDER_ERROR
|
|
||||||
val exception = ExternalPaymentException(500, "UNKNOWN_PAYMENT_ERROR", "결제에 실패했어요. 같은 문제가 반복된다면 은행이나 카드사로 문의해주세요.")
|
|
||||||
every {
|
|
||||||
tosspayClient.confirm(request.paymentKey, request.orderId, request.amount)
|
|
||||||
} throws exception
|
|
||||||
|
|
||||||
assertThrows<PaymentException> {
|
|
||||||
paymentService.requestConfirm(12345L, request)
|
|
||||||
}.also {
|
|
||||||
it.errorCode shouldBe expectedErrorCode
|
|
||||||
it.message shouldBe expectedErrorCode.message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
test("외부 API의 에러코드가 ${UserFacingPaymentErrorCode::class.simpleName}에 있으면 해당 예외 메시지를 담아 던진다.") {
|
|
||||||
val expectedErrorCode = PaymentErrorCode.PAYMENT_CLIENT_ERROR
|
|
||||||
val exception = ExternalPaymentException(400, "EXCEED_MAX_CARD_INSTALLMENT_PLAN", "설정 가능한 최대 할부 개월 수를 초과했습니다.")
|
|
||||||
every {
|
|
||||||
tosspayClient.confirm(request.paymentKey, request.orderId, request.amount)
|
|
||||||
} throws exception
|
|
||||||
|
|
||||||
assertThrows<PaymentException> {
|
|
||||||
paymentService.requestConfirm(12345L, request)
|
|
||||||
}.also {
|
|
||||||
it.errorCode shouldBe expectedErrorCode
|
|
||||||
it.message shouldBe "${expectedErrorCode.message}(${exception.message})"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
test("외부 API에서 예상치 못한 예외가 발생한 경우 ${PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR}로 변환한다.") {
|
|
||||||
val expectedErrorCode = PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR
|
|
||||||
every {
|
|
||||||
tosspayClient.confirm(request.paymentKey, request.orderId, request.amount)
|
|
||||||
} throws Exception("unexpected")
|
|
||||||
|
|
||||||
assertThrows<PaymentException> {
|
|
||||||
paymentService.requestConfirm(12345L, request)
|
|
||||||
}.also {
|
|
||||||
it.errorCode shouldBe expectedErrorCode
|
|
||||||
it.message shouldBe expectedErrorCode.message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun runSuccessTest(request: PaymentConfirmRequest, tosspayAPIResponse: PaymentGatewayResponse, additionalAssertion: (PaymentEvent) -> Unit): PaymentEvent {
|
|
||||||
val paymentEventSlot = slot<PaymentEvent>()
|
|
||||||
|
|
||||||
every {
|
|
||||||
tosspayClient.confirm(request.paymentKey, request.orderId, request.amount)
|
|
||||||
} returns tosspayAPIResponse
|
|
||||||
|
|
||||||
every {
|
|
||||||
paymentEventListener.handlePaymentEvent(capture(paymentEventSlot))
|
|
||||||
} just runs
|
|
||||||
|
|
||||||
paymentService.requestConfirm(12345L, request)
|
|
||||||
|
|
||||||
assertSoftly(paymentEventSlot.captured) {
|
|
||||||
this.paymentKey shouldBe request.paymentKey
|
|
||||||
this.orderId shouldBe request.orderId
|
|
||||||
this.totalAmount shouldBe request.amount
|
|
||||||
this.method shouldBe tosspayAPIResponse.method
|
|
||||||
}
|
|
||||||
|
|
||||||
return paymentEventSlot.captured
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,45 +0,0 @@
|
|||||||
package com.sangdol.roomescape.reservation.business.event
|
|
||||||
|
|
||||||
import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationRepository
|
|
||||||
import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationStatus
|
|
||||||
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleRepository
|
|
||||||
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus
|
|
||||||
import com.sangdol.roomescape.supports.FunSpecSpringbootTest
|
|
||||||
import io.kotest.assertions.assertSoftly
|
|
||||||
import io.kotest.core.spec.style.FunSpec
|
|
||||||
import io.kotest.matchers.nulls.shouldNotBeNull
|
|
||||||
import io.kotest.matchers.shouldBe
|
|
||||||
import org.springframework.data.repository.findByIdOrNull
|
|
||||||
|
|
||||||
class ReservationEventListenerTest(
|
|
||||||
private val reservationEventListener: ReservationEventListener,
|
|
||||||
private val reservationRepository: ReservationRepository,
|
|
||||||
private val scheduleRepository: ScheduleRepository
|
|
||||||
) : FunSpecSpringbootTest() {
|
|
||||||
|
|
||||||
init {
|
|
||||||
test("예약 확정 이벤트를 처리한다.") {
|
|
||||||
val pendingReservation = dummyInitializer.createPendingReservation(testAuthUtil.defaultUser()).also {
|
|
||||||
it.status = ReservationStatus.PAYMENT_IN_PROGRESS
|
|
||||||
reservationRepository.saveAndFlush(it)
|
|
||||||
}
|
|
||||||
|
|
||||||
val reservationConfirmEvent = ReservationConfirmEvent(pendingReservation.id)
|
|
||||||
|
|
||||||
reservationEventListener.handleReservationConfirmEvent(reservationConfirmEvent).also {
|
|
||||||
Thread.sleep(100)
|
|
||||||
}
|
|
||||||
|
|
||||||
assertSoftly(reservationRepository.findByIdOrNull(pendingReservation.id)) {
|
|
||||||
this.shouldNotBeNull()
|
|
||||||
this.status shouldBe ReservationStatus.CONFIRMED
|
|
||||||
}
|
|
||||||
|
|
||||||
assertSoftly(scheduleRepository.findByIdOrNull(pendingReservation.scheduleId)) {
|
|
||||||
this.shouldNotBeNull()
|
|
||||||
this.status shouldBe ScheduleStatus.RESERVED
|
|
||||||
this.holdExpiredAt shouldBe null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,11 +1,11 @@
|
|||||||
package com.sangdol.roomescape.supports
|
package com.sangdol.roomescape.supports
|
||||||
|
|
||||||
|
import com.sangdol.roomescape.payment.business.PaymentWriter
|
||||||
import com.sangdol.roomescape.payment.business.domain.PaymentMethod
|
import com.sangdol.roomescape.payment.business.domain.PaymentMethod
|
||||||
import com.sangdol.roomescape.payment.dto.*
|
import com.sangdol.roomescape.payment.dto.*
|
||||||
import com.sangdol.roomescape.payment.infrastructure.persistence.*
|
import com.sangdol.roomescape.payment.infrastructure.persistence.CanceledPaymentEntity
|
||||||
import com.sangdol.roomescape.payment.mapper.toDetailEntity
|
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentEntity
|
||||||
import com.sangdol.roomescape.payment.mapper.toEntity
|
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentRepository
|
||||||
import com.sangdol.roomescape.payment.mapper.toEvent
|
|
||||||
import com.sangdol.roomescape.payment.mapper.toResponse
|
import com.sangdol.roomescape.payment.mapper.toResponse
|
||||||
import com.sangdol.roomescape.reservation.dto.PendingReservationCreateRequest
|
import com.sangdol.roomescape.reservation.dto.PendingReservationCreateRequest
|
||||||
import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationEntity
|
import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationEntity
|
||||||
@ -33,8 +33,7 @@ class DummyInitializer(
|
|||||||
private val scheduleRepository: ScheduleRepository,
|
private val scheduleRepository: ScheduleRepository,
|
||||||
private val reservationRepository: ReservationRepository,
|
private val reservationRepository: ReservationRepository,
|
||||||
private val paymentRepository: PaymentRepository,
|
private val paymentRepository: PaymentRepository,
|
||||||
private val paymentDetailRepository: PaymentDetailRepository,
|
private val paymentWriter: PaymentWriter
|
||||||
private val canceledPaymentRepository: CanceledPaymentRepository
|
|
||||||
) {
|
) {
|
||||||
|
|
||||||
fun createStore(
|
fun createStore(
|
||||||
@ -205,10 +204,12 @@ class DummyInitializer(
|
|||||||
transferDetail = transferDetail
|
transferDetail = transferDetail
|
||||||
)
|
)
|
||||||
|
|
||||||
val paymentEvent = clientConfirmResponse.toEvent(reservationId)
|
val payment = paymentWriter.createPayment(
|
||||||
val payment = paymentRepository.save(paymentEvent.toEntity(IDGenerator.create()))
|
reservationId = reservationId,
|
||||||
|
paymentGatewayResponse = clientConfirmResponse
|
||||||
|
)
|
||||||
|
|
||||||
val detail = paymentDetailRepository.save(paymentEvent.toDetailEntity(IDGenerator.create(), payment.id))
|
val detail = paymentWriter.createDetail(clientConfirmResponse, payment.id)
|
||||||
|
|
||||||
return payment.toResponse(detail = detail.toResponse(), cancel = null)
|
return payment.toResponse(detail = detail.toResponse(), cancel = null)
|
||||||
}
|
}
|
||||||
@ -226,14 +227,11 @@ class DummyInitializer(
|
|||||||
cancelReason = cancelReason,
|
cancelReason = cancelReason,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return paymentWriter.cancel(
|
||||||
return clientCancelResponse.cancels.toEntity(
|
userId,
|
||||||
id = IDGenerator.create(),
|
payment,
|
||||||
paymentId = payment.id,
|
requestedAt = Instant.now(),
|
||||||
cancelRequestedAt = Instant.now(),
|
clientCancelResponse
|
||||||
canceledBy = userId
|
)
|
||||||
).also {
|
|
||||||
canceledPaymentRepository.save(it)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,7 @@
|
|||||||
package com.sangdol.roomescape.supports
|
package com.sangdol.roomescape.supports
|
||||||
|
|
||||||
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminRepository
|
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminRepository
|
||||||
import com.sangdol.roomescape.payment.infrastructure.persistence.CanceledPaymentRepository
|
import com.sangdol.roomescape.payment.business.PaymentWriter
|
||||||
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentDetailRepository
|
|
||||||
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentRepository
|
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentRepository
|
||||||
import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationRepository
|
import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationRepository
|
||||||
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleRepository
|
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleRepository
|
||||||
@ -44,7 +43,13 @@ abstract class FunSpecSpringbootTest(
|
|||||||
}
|
}
|
||||||
}) {
|
}) {
|
||||||
@Autowired
|
@Autowired
|
||||||
lateinit var testAuthUtil: TestAuthUtil
|
private lateinit var userRepository: UserRepository
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private lateinit var adminRepository: AdminRepository
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private lateinit var storeRepository: StoreRepository
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
lateinit var dummyInitializer: DummyInitializer
|
lateinit var dummyInitializer: DummyInitializer
|
||||||
@ -52,40 +57,32 @@ abstract class FunSpecSpringbootTest(
|
|||||||
@LocalServerPort
|
@LocalServerPort
|
||||||
var port: Int = 0
|
var port: Int = 0
|
||||||
|
|
||||||
|
lateinit var testAuthUtil: TestAuthUtil
|
||||||
|
|
||||||
override suspend fun beforeSpec(spec: Spec) {
|
override suspend fun beforeSpec(spec: Spec) {
|
||||||
RestAssured.port = port
|
RestAssured.port = port
|
||||||
|
testAuthUtil = TestAuthUtil(userRepository, adminRepository, storeRepository)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@TestConfiguration
|
@TestConfiguration
|
||||||
class TestConfig {
|
class TestConfig {
|
||||||
@Bean
|
|
||||||
fun testAuthUtil(
|
|
||||||
userRepository: UserRepository,
|
|
||||||
adminRepository: AdminRepository,
|
|
||||||
storeRepository: StoreRepository
|
|
||||||
): TestAuthUtil {
|
|
||||||
return TestAuthUtil(userRepository, adminRepository, storeRepository)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
fun dummyInitializer(
|
fun dummyInitializer(
|
||||||
storeRepository: StoreRepository,
|
storeRepository: StoreRepository,
|
||||||
themeRepository: ThemeRepository,
|
themeRepository: ThemeRepository,
|
||||||
scheduleRepository: ScheduleRepository,
|
scheduleRepository: ScheduleRepository,
|
||||||
reservationRepository: ReservationRepository,
|
reservationRepository: ReservationRepository,
|
||||||
paymentRepository: PaymentRepository,
|
paymentWriter: PaymentWriter,
|
||||||
paymentDetailRepository: PaymentDetailRepository,
|
paymentRepository: PaymentRepository
|
||||||
canceledPaymentRepository: CanceledPaymentRepository
|
|
||||||
): DummyInitializer {
|
): DummyInitializer {
|
||||||
return DummyInitializer(
|
return DummyInitializer(
|
||||||
themeRepository = themeRepository,
|
themeRepository = themeRepository,
|
||||||
scheduleRepository = scheduleRepository,
|
scheduleRepository = scheduleRepository,
|
||||||
reservationRepository = reservationRepository,
|
reservationRepository = reservationRepository,
|
||||||
|
paymentWriter = paymentWriter,
|
||||||
paymentRepository = paymentRepository,
|
paymentRepository = paymentRepository,
|
||||||
storeRepository = storeRepository,
|
storeRepository = storeRepository
|
||||||
paymentDetailRepository = paymentDetailRepository,
|
|
||||||
canceledPaymentRepository = canceledPaymentRepository
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user