generated from pricelees/issue-pr-template
feat: 새로운 스펙에 맞춘 결제 및 취소 로직 구현
This commit is contained in:
parent
4d98b18016
commit
4c82ad80c0
119
src/main/kotlin/roomescape/payment/implement/PaymentWriterV2.kt
Normal file
119
src/main/kotlin/roomescape/payment/implement/PaymentWriterV2.kt
Normal file
@ -0,0 +1,119 @@
|
||||
package roomescape.payment.implement
|
||||
|
||||
import com.github.f4b6a3.tsid.TsidFactory
|
||||
import io.github.oshai.kotlinlogging.KLogger
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import org.springframework.stereotype.Component
|
||||
import roomescape.common.config.next
|
||||
import roomescape.payment.exception.PaymentErrorCode
|
||||
import roomescape.payment.exception.PaymentException
|
||||
import roomescape.payment.infrastructure.client.v2.*
|
||||
import roomescape.payment.infrastructure.common.PaymentMethod
|
||||
import roomescape.payment.infrastructure.persistence.v2.CanceledPaymentRepositoryV2
|
||||
import roomescape.payment.infrastructure.persistence.v2.CanceledPaymentEntityV2
|
||||
import roomescape.payment.infrastructure.persistence.v2.PaymentDetailEntity
|
||||
import roomescape.payment.infrastructure.persistence.v2.PaymentDetailRepository
|
||||
import roomescape.payment.infrastructure.persistence.v2.PaymentEntityV2
|
||||
import roomescape.payment.infrastructure.persistence.v2.PaymentRepositoryV2
|
||||
import roomescape.reservation.web.ReservationCancelRequest
|
||||
import roomescape.reservation.web.ReservationPaymentRequest
|
||||
import roomescape.reservation.web.toPaymentConfirmRequest
|
||||
|
||||
private val log: KLogger = KotlinLogging.logger {}
|
||||
|
||||
@Component
|
||||
class PaymentWriterV2(
|
||||
private val paymentClient: TosspaymentClientV2,
|
||||
private val paymentRepository: PaymentRepositoryV2,
|
||||
private val paymentDetailRepository: PaymentDetailRepository,
|
||||
private val canceledPaymentRepository: CanceledPaymentRepositoryV2,
|
||||
private val tsidFactory: TsidFactory,
|
||||
) {
|
||||
|
||||
fun requestConfirmPayment(
|
||||
reservationId: Long,
|
||||
request: ReservationPaymentRequest
|
||||
): PaymentConfirmResponse {
|
||||
log.debug { "[PaymentWriterV2.requestConfirmPayment] 결제 승인 요청 시작: reservationId=${reservationId}, paymentKey=${request.paymentKey}" }
|
||||
|
||||
return paymentClient.confirm(request.toPaymentConfirmRequest()).also {
|
||||
log.debug { "[PaymentWriterV2.requestConfirmPayment] 결제 승인 요청 완료: response=$it" }
|
||||
}
|
||||
}
|
||||
|
||||
fun createPayment(
|
||||
reservationId: Long,
|
||||
request: ReservationPaymentRequest,
|
||||
paymentConfirmResponse: PaymentConfirmResponse
|
||||
): PaymentEntityV2 {
|
||||
log.debug { "[PaymentWriterV2.createPayment] 결제 승인 및 결제 정보 저장 시작: reservationId=${reservationId}, paymentKey=${request.paymentKey}" }
|
||||
|
||||
return paymentConfirmResponse.toEntity(
|
||||
id = tsidFactory.next(), reservationId, request.orderId, request.paymentType
|
||||
).also {
|
||||
paymentRepository.save(it)
|
||||
saveDetail(paymentConfirmResponse, it.id)
|
||||
log.debug { "[PaymentWriterV2.createPayment] 결제 승인 및 결제 정보 저장 완료: reservationId=${reservationId}, paymentId=${it.id}" }
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveDetail(
|
||||
paymentResponse: PaymentConfirmResponse,
|
||||
paymentId: Long,
|
||||
): PaymentDetailEntity {
|
||||
val method: PaymentMethod = paymentResponse.method
|
||||
val id = tsidFactory.next()
|
||||
|
||||
if (method == PaymentMethod.TRANSFER) {
|
||||
return paymentDetailRepository.save(paymentResponse.toTransferDetailEntity(id, paymentId))
|
||||
}
|
||||
if (method == PaymentMethod.EASY_PAY && paymentResponse.card == null) {
|
||||
return paymentDetailRepository.save(paymentResponse.toEasypayPrepaidDetailEntity(id, paymentId))
|
||||
}
|
||||
if (paymentResponse.card != null) {
|
||||
return paymentDetailRepository.save(paymentResponse.toCardDetailEntity(id, paymentId))
|
||||
}
|
||||
throw PaymentException(PaymentErrorCode.NOT_SUPPORTED_PAYMENT_TYPE)
|
||||
}
|
||||
|
||||
fun requestCancelPayment(
|
||||
reservationId: Long,
|
||||
request: ReservationCancelRequest,
|
||||
): PaymentCancelResponseV2 {
|
||||
log.debug { "[PaymentWriterV2.requestConfirmPayment] 결제 취소 요청 시작: reservationId=$reservationId, request=${request}" }
|
||||
|
||||
val payment: PaymentEntityV2 = paymentRepository.findByReservationId(reservationId)
|
||||
?: throw PaymentException(PaymentErrorCode.PAYMENT_NOT_FOUND)
|
||||
|
||||
val paymentCancelRequest = PaymentCancelRequestV2(
|
||||
paymentKey = payment.paymentKey,
|
||||
cancelReason = request.cancelReason,
|
||||
amount = payment.totalAmount
|
||||
)
|
||||
|
||||
return paymentClient.cancel(paymentCancelRequest).also {
|
||||
log.debug { "[PaymentWriterV2.requestCancelPayment] 결제 취소 요청 완료: reservationId=${reservationId}, paymentKey=${payment.paymentKey}" }
|
||||
}
|
||||
}
|
||||
|
||||
fun createCanceledPayment(
|
||||
memberId: Long,
|
||||
reservationId: Long,
|
||||
request: ReservationCancelRequest,
|
||||
paymentCancelResponse: PaymentCancelResponseV2
|
||||
) {
|
||||
val payment: PaymentEntityV2= paymentRepository.findByReservationId(reservationId)
|
||||
?.also { it.cancel() }
|
||||
?: throw PaymentException(PaymentErrorCode.PAYMENT_NOT_FOUND)
|
||||
|
||||
val cancelDetail: CancelDetail = paymentCancelResponse.cancels
|
||||
|
||||
CanceledPaymentEntityV2(
|
||||
id = tsidFactory.next(),
|
||||
canceledAt = cancelDetail.canceledAt,
|
||||
paymentId = payment.id,
|
||||
canceledBy = memberId,
|
||||
cancelReason = request.cancelReason
|
||||
).also { canceledPaymentRepository.save(it) }
|
||||
}
|
||||
}
|
||||
@ -10,7 +10,7 @@ import java.time.OffsetDateTime
|
||||
|
||||
data class PaymentCancelRequestV2(
|
||||
val paymentKey: String,
|
||||
val amount: Long,
|
||||
val amount: Int,
|
||||
val cancelReason: String
|
||||
)
|
||||
|
||||
|
||||
@ -1,19 +1,26 @@
|
||||
package roomescape.payment.infrastructure.client.v2
|
||||
|
||||
import roomescape.payment.exception.PaymentErrorCode
|
||||
import roomescape.payment.exception.PaymentException
|
||||
import roomescape.payment.infrastructure.common.*
|
||||
import roomescape.payment.infrastructure.persistence.v2.PaymentBankTransferDetailEntity
|
||||
import roomescape.payment.infrastructure.persistence.v2.PaymentCardDetailEntity
|
||||
import roomescape.payment.infrastructure.persistence.v2.PaymentEasypayPrepaidDetailEntity
|
||||
import roomescape.payment.infrastructure.persistence.v2.PaymentEntityV2
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
data class PaymentConfirmRequest(
|
||||
val paymentKey: String,
|
||||
val orderId: String,
|
||||
val amount: Long,
|
||||
val paymentType: PaymentType,
|
||||
)
|
||||
|
||||
data class PaymentConfirmResponse(
|
||||
val paymentKey: String,
|
||||
val orderId: String,
|
||||
val status: PaymentStatus,
|
||||
val totalAmount: Int,
|
||||
val vat: Int,
|
||||
val suppliedAmount: Int,
|
||||
val method: PaymentMethod,
|
||||
val card: CardDetail?,
|
||||
val easyPay: EasyPayDetail?,
|
||||
@ -22,6 +29,24 @@ data class PaymentConfirmResponse(
|
||||
val approvedAt: OffsetDateTime,
|
||||
)
|
||||
|
||||
fun PaymentConfirmResponse.toEntity(
|
||||
id: Long,
|
||||
reservationId: Long,
|
||||
orderId: String,
|
||||
paymentType: PaymentType
|
||||
) = PaymentEntityV2(
|
||||
id = id,
|
||||
reservationId = reservationId,
|
||||
paymentKey = this.paymentKey,
|
||||
orderId = orderId,
|
||||
totalAmount = this.totalAmount,
|
||||
requestedAt = this.requestedAt,
|
||||
approvedAt = this.approvedAt,
|
||||
type = paymentType,
|
||||
method = this.method,
|
||||
status = this.status,
|
||||
)
|
||||
|
||||
data class CardDetail(
|
||||
val issuerCode: CardIssuerCode,
|
||||
val number: String,
|
||||
@ -33,13 +58,67 @@ data class CardDetail(
|
||||
val installmentPlanMonths: Int
|
||||
)
|
||||
|
||||
fun PaymentConfirmResponse.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,
|
||||
)
|
||||
}
|
||||
|
||||
data class EasyPayDetail(
|
||||
val provider: EasyPayCompanyCode,
|
||||
val amount: Int,
|
||||
val discountAmount: Int,
|
||||
)
|
||||
|
||||
fun PaymentConfirmResponse.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
|
||||
)
|
||||
}
|
||||
|
||||
data class TransferDetail(
|
||||
val bankCode: BankCode,
|
||||
val settlementStatus: String,
|
||||
)
|
||||
|
||||
fun PaymentConfirmResponse.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
|
||||
)
|
||||
}
|
||||
|
||||
@ -2,4 +2,7 @@ package roomescape.payment.infrastructure.persistence.v2
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
|
||||
interface PaymentRepositoryV2: JpaRepository<PaymentEntityV2, Long>
|
||||
interface PaymentRepositoryV2: JpaRepository<PaymentEntityV2, Long> {
|
||||
|
||||
fun findByReservationId(reservationId: Long): PaymentEntityV2?
|
||||
}
|
||||
|
||||
@ -0,0 +1,103 @@
|
||||
package roomescape.reservation.business
|
||||
|
||||
import io.github.oshai.kotlinlogging.KLogger
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import jakarta.transaction.Transactional
|
||||
import org.springframework.stereotype.Service
|
||||
import roomescape.common.util.TransactionExecutionUtil
|
||||
import roomescape.payment.implement.PaymentWriterV2
|
||||
import roomescape.payment.infrastructure.client.v2.PaymentConfirmResponse
|
||||
import roomescape.payment.infrastructure.persistence.v2.PaymentEntityV2
|
||||
import roomescape.reservation.implement.ReservationFinder
|
||||
import roomescape.reservation.implement.ReservationWriter
|
||||
import roomescape.reservation.infrastructure.persistence.ReservationEntity
|
||||
import roomescape.reservation.infrastructure.persistence.ReservationStatus
|
||||
import roomescape.reservation.web.*
|
||||
|
||||
private val log: KLogger = KotlinLogging.logger {}
|
||||
|
||||
@Service
|
||||
class ReservationWithPaymentServiceV2(
|
||||
private val reservationWriter: ReservationWriter,
|
||||
private val reservationFinder: ReservationFinder,
|
||||
private val paymentWriter: PaymentWriterV2,
|
||||
private val transactionExecutionUtil: TransactionExecutionUtil
|
||||
) {
|
||||
|
||||
@Transactional
|
||||
fun createPendingReservation(memberId: Long, request: ReservationCreateRequest): ReservationCreateResponseV2 {
|
||||
log.info {
|
||||
"[ReservationWithPaymentServiceV2.createPendingReservation] " +
|
||||
"PENDING 예약 저장 시작: memberId=$memberId, request=$request"
|
||||
}
|
||||
|
||||
val reservation: ReservationEntity = reservationWriter.create(
|
||||
date = request.date,
|
||||
timeId = request.timeId,
|
||||
themeId = request.themeId,
|
||||
status = ReservationStatus.PENDING,
|
||||
memberId = memberId,
|
||||
requesterId = memberId
|
||||
)
|
||||
|
||||
return reservation.toCreateResponseV2().also {
|
||||
log.info {
|
||||
"[ReservationWithPaymentServiceV2.createPendingReservation] " +
|
||||
"PENDING 예약 저장 완료: reservationId=${reservation.id}, response=$it"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun payReservation(
|
||||
memberId: Long,
|
||||
reservationId: Long,
|
||||
request: ReservationPaymentRequest
|
||||
): ReservationPaymentResponse {
|
||||
log.info {
|
||||
"[ReservationWithPaymentServiceV2.payReservation] " +
|
||||
"예약 결제 시작: memberId=$memberId, reservationId=$reservationId, request=$request"
|
||||
}
|
||||
|
||||
val paymentConfirmResponse = paymentWriter.requestConfirmPayment(reservationId, request)
|
||||
|
||||
return transactionExecutionUtil.withNewTransaction(isReadOnly = false) {
|
||||
savePayment(memberId, reservationId, request, paymentConfirmResponse)
|
||||
}
|
||||
}
|
||||
|
||||
private fun savePayment(
|
||||
memberId: Long,
|
||||
reservationId: Long,
|
||||
request: ReservationPaymentRequest,
|
||||
paymentConfirmResponse: PaymentConfirmResponse
|
||||
): ReservationPaymentResponse {
|
||||
val reservation =
|
||||
reservationFinder.findPendingReservation(reservationId, memberId).also { it.confirm() }
|
||||
val payment: PaymentEntityV2 = paymentWriter.createPayment(
|
||||
reservationId = reservationId,
|
||||
request = request,
|
||||
paymentConfirmResponse = paymentConfirmResponse
|
||||
)
|
||||
|
||||
return ReservationPaymentResponse(reservationId, reservation.status, payment.id, payment.status)
|
||||
.also { log.info { "[ReservationWithPaymentServiceV2.payReservation] 예약 결제 완료: response=${it}" } }
|
||||
}
|
||||
|
||||
fun cancelReservation(
|
||||
memberId: Long,
|
||||
reservationId: Long,
|
||||
request: ReservationCancelRequest
|
||||
) {
|
||||
log.info {
|
||||
"[ReservationWithPaymentServiceV2.cancelReservation] " +
|
||||
"예약 취소 시작: memberId=$memberId, reservationId=$reservationId, request=$request"
|
||||
}
|
||||
|
||||
val paymentCancelResponse = paymentWriter.requestCancelPayment(reservationId, request)
|
||||
|
||||
transactionExecutionUtil.withNewTransaction(isReadOnly = false) {
|
||||
paymentWriter.createCanceledPayment(memberId, reservationId, request, paymentCancelResponse)
|
||||
reservationFinder.findById(reservationId).also { reservationWriter.cancelByUser(it, memberId) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -17,4 +17,6 @@ enum class ReservationErrorCode(
|
||||
NOT_RESERVATION_OWNER(HttpStatus.FORBIDDEN, "R006", "타인의 예약은 취소할 수 없어요."),
|
||||
INVALID_SEARCH_DATE_RANGE(HttpStatus.BAD_REQUEST, "R007", "종료 날짜는 시작 날짜 이후여야 해요."),
|
||||
NO_PERMISSION(HttpStatus.FORBIDDEN, "R008", "접근 권한이 없어요."),
|
||||
RESERVATION_NOT_PENDING(HttpStatus.BAD_REQUEST, "R009", "결제 대기 중인 예약이 아니에요."),
|
||||
;
|
||||
}
|
||||
|
||||
@ -91,4 +91,14 @@ class ReservationFinder(
|
||||
return reservationRepository.existsByTime(time)
|
||||
.also { log.debug { "[ReservationFinder.isTimeReserved] 완료: isExist=$it, timeId=${time.id}, startAt=${time.startAt}" } }
|
||||
}
|
||||
|
||||
fun findPendingReservation(reservationId: Long, memberId: Long): ReservationEntity {
|
||||
log.debug { "[ReservationFinder.findPendingReservationIfExists] 시작: reservationId=$reservationId, memberId=$memberId" }
|
||||
|
||||
return findById(reservationId).also {
|
||||
reservationValidator.validateIsReservedByMemberAndPending(it, memberId)
|
||||
}.also {
|
||||
log.debug { "[ReservationFinder.findPendingReservationIfExists] 완료: reservationId=${it.id}, status=${it.status}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@ import roomescape.reservation.exception.ReservationException
|
||||
import roomescape.reservation.infrastructure.persistence.ReservationEntity
|
||||
import roomescape.reservation.infrastructure.persistence.ReservationRepository
|
||||
import roomescape.reservation.infrastructure.persistence.ReservationSearchSpecification
|
||||
import roomescape.reservation.infrastructure.persistence.ReservationStatus
|
||||
import roomescape.theme.infrastructure.persistence.ThemeEntity
|
||||
import roomescape.time.infrastructure.persistence.TimeEntity
|
||||
import java.time.LocalDate
|
||||
@ -141,4 +142,15 @@ class ReservationValidator(
|
||||
|
||||
log.debug { "[ReservationValidator.validateAlreadyConfirmed] 완료: reservationId=$reservationId" }
|
||||
}
|
||||
|
||||
fun validateIsReservedByMemberAndPending(reservation: ReservationEntity, requesterId: Long) {
|
||||
if (reservation.member.id != requesterId) {
|
||||
log.error { "[ReservationValidator.validateIsReservedByMemberAndPending] 예약자 본인이 아님: reservationId=${reservation.id}, reservation.memberId=${reservation.member.id} requesterId=$requesterId" }
|
||||
throw ReservationException(ReservationErrorCode.NOT_RESERVATION_OWNER)
|
||||
}
|
||||
if (reservation.status != ReservationStatus.PENDING) {
|
||||
log.warn { "[ReservationValidator.validateIsReservedByMemberAndPending] 예약 상태가 대기 중이 아님: reservationId=${reservation.id}, status=${reservation.status}" }
|
||||
throw ReservationException(ReservationErrorCode.RESERVATION_NOT_PENDING)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,8 +6,6 @@ import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import org.springframework.stereotype.Component
|
||||
import roomescape.common.config.next
|
||||
import roomescape.member.implement.MemberFinder
|
||||
import roomescape.reservation.exception.ReservationErrorCode
|
||||
import roomescape.reservation.exception.ReservationException
|
||||
import roomescape.reservation.infrastructure.persistence.ReservationEntity
|
||||
import roomescape.reservation.infrastructure.persistence.ReservationRepository
|
||||
import roomescape.reservation.infrastructure.persistence.ReservationStatus
|
||||
@ -101,4 +99,15 @@ class ReservationWriter(
|
||||
|
||||
log.debug { "[ReservationWriter.confirm] 완료: reservationId=$reservationId, status=${ReservationStatus.CONFIRMED_PAYMENT_REQUIRED}" }
|
||||
}
|
||||
|
||||
fun cancelByUser(reservation: ReservationEntity, requesterId: Long) {
|
||||
log.debug { "[ReservationWriter.cancel] 예약 취소 시작: reservationId=${reservation.id}, requesterId=$requesterId" }
|
||||
|
||||
memberFinder.findById(requesterId)
|
||||
.also { reservationValidator.validateDeleteAuthority(reservation, requester = it) }
|
||||
|
||||
reservation.cancelByUser().also {
|
||||
log.debug { "[ReservationWriter.cancel] 예약 취소 완료: reservationId=${reservation.id}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -43,12 +43,23 @@ class ReservationEntity(
|
||||
fun isReservedBy(memberId: Long): Boolean {
|
||||
return this.member.id == memberId
|
||||
}
|
||||
|
||||
fun cancelByUser() {
|
||||
this.status = ReservationStatus.CANCELED_BY_USER
|
||||
}
|
||||
|
||||
fun confirm() {
|
||||
this.status = ReservationStatus.CONFIRMED
|
||||
}
|
||||
}
|
||||
|
||||
enum class ReservationStatus {
|
||||
CONFIRMED,
|
||||
CONFIRMED_PAYMENT_REQUIRED,
|
||||
PENDING,
|
||||
WAITING,
|
||||
CANCELED_BY_USER,
|
||||
AUTOMATICALLY_CANCELED,
|
||||
;
|
||||
|
||||
companion object {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user