From 4c82ad80c0aeeee5a10a664c7c05256649aaa159 Mon Sep 17 00:00:00 2001 From: pricelees Date: Sun, 17 Aug 2025 21:22:58 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=83=88=EB=A1=9C=EC=9A=B4=20=EC=8A=A4?= =?UTF-8?q?=ED=8E=99=EC=97=90=20=EB=A7=9E=EC=B6=98=20=EA=B2=B0=EC=A0=9C=20?= =?UTF-8?q?=EB=B0=8F=20=EC=B7=A8=EC=86=8C=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../payment/implement/PaymentWriterV2.kt | 119 ++++++++++++++++++ .../client/v2/TosspaymentCancelDTO.kt | 2 +- .../client/v2/TosspaymentConfirmDTO.kt | 83 +++++++++++- .../persistence/v2/PaymentRepositoryV2.kt | 5 +- .../ReservationWithPaymentServiceV2.kt | 103 +++++++++++++++ .../exception/ReservationErrorCode.kt | 2 + .../implement/ReservationFinder.kt | 10 ++ .../implement/ReservationValidator.kt | 12 ++ .../implement/ReservationWriter.kt | 13 +- .../persistence/ReservationEntity.kt | 11 ++ 10 files changed, 354 insertions(+), 6 deletions(-) create mode 100644 src/main/kotlin/roomescape/payment/implement/PaymentWriterV2.kt create mode 100644 src/main/kotlin/roomescape/reservation/business/ReservationWithPaymentServiceV2.kt diff --git a/src/main/kotlin/roomescape/payment/implement/PaymentWriterV2.kt b/src/main/kotlin/roomescape/payment/implement/PaymentWriterV2.kt new file mode 100644 index 00000000..c1ba870d --- /dev/null +++ b/src/main/kotlin/roomescape/payment/implement/PaymentWriterV2.kt @@ -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) } + } +} diff --git a/src/main/kotlin/roomescape/payment/infrastructure/client/v2/TosspaymentCancelDTO.kt b/src/main/kotlin/roomescape/payment/infrastructure/client/v2/TosspaymentCancelDTO.kt index 261fda1c..cb7d535b 100644 --- a/src/main/kotlin/roomescape/payment/infrastructure/client/v2/TosspaymentCancelDTO.kt +++ b/src/main/kotlin/roomescape/payment/infrastructure/client/v2/TosspaymentCancelDTO.kt @@ -10,7 +10,7 @@ import java.time.OffsetDateTime data class PaymentCancelRequestV2( val paymentKey: String, - val amount: Long, + val amount: Int, val cancelReason: String ) diff --git a/src/main/kotlin/roomescape/payment/infrastructure/client/v2/TosspaymentConfirmDTO.kt b/src/main/kotlin/roomescape/payment/infrastructure/client/v2/TosspaymentConfirmDTO.kt index 13859a01..819ac4b4 100644 --- a/src/main/kotlin/roomescape/payment/infrastructure/client/v2/TosspaymentConfirmDTO.kt +++ b/src/main/kotlin/roomescape/payment/infrastructure/client/v2/TosspaymentConfirmDTO.kt @@ -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 + ) +} diff --git a/src/main/kotlin/roomescape/payment/infrastructure/persistence/v2/PaymentRepositoryV2.kt b/src/main/kotlin/roomescape/payment/infrastructure/persistence/v2/PaymentRepositoryV2.kt index 61a7dd95..6fd16b94 100644 --- a/src/main/kotlin/roomescape/payment/infrastructure/persistence/v2/PaymentRepositoryV2.kt +++ b/src/main/kotlin/roomescape/payment/infrastructure/persistence/v2/PaymentRepositoryV2.kt @@ -2,4 +2,7 @@ package roomescape.payment.infrastructure.persistence.v2 import org.springframework.data.jpa.repository.JpaRepository -interface PaymentRepositoryV2: JpaRepository \ No newline at end of file +interface PaymentRepositoryV2: JpaRepository { + + fun findByReservationId(reservationId: Long): PaymentEntityV2? +} diff --git a/src/main/kotlin/roomescape/reservation/business/ReservationWithPaymentServiceV2.kt b/src/main/kotlin/roomescape/reservation/business/ReservationWithPaymentServiceV2.kt new file mode 100644 index 00000000..56a7aff8 --- /dev/null +++ b/src/main/kotlin/roomescape/reservation/business/ReservationWithPaymentServiceV2.kt @@ -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) } + } + } +} diff --git a/src/main/kotlin/roomescape/reservation/exception/ReservationErrorCode.kt b/src/main/kotlin/roomescape/reservation/exception/ReservationErrorCode.kt index e1033a6a..1ca04cfe 100644 --- a/src/main/kotlin/roomescape/reservation/exception/ReservationErrorCode.kt +++ b/src/main/kotlin/roomescape/reservation/exception/ReservationErrorCode.kt @@ -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", "결제 대기 중인 예약이 아니에요."), + ; } diff --git a/src/main/kotlin/roomescape/reservation/implement/ReservationFinder.kt b/src/main/kotlin/roomescape/reservation/implement/ReservationFinder.kt index adf824d7..0d479a63 100644 --- a/src/main/kotlin/roomescape/reservation/implement/ReservationFinder.kt +++ b/src/main/kotlin/roomescape/reservation/implement/ReservationFinder.kt @@ -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}" } + } + } } diff --git a/src/main/kotlin/roomescape/reservation/implement/ReservationValidator.kt b/src/main/kotlin/roomescape/reservation/implement/ReservationValidator.kt index 257c9815..aef346c8 100644 --- a/src/main/kotlin/roomescape/reservation/implement/ReservationValidator.kt +++ b/src/main/kotlin/roomescape/reservation/implement/ReservationValidator.kt @@ -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) + } + } } diff --git a/src/main/kotlin/roomescape/reservation/implement/ReservationWriter.kt b/src/main/kotlin/roomescape/reservation/implement/ReservationWriter.kt index 23f34de8..6a881242 100644 --- a/src/main/kotlin/roomescape/reservation/implement/ReservationWriter.kt +++ b/src/main/kotlin/roomescape/reservation/implement/ReservationWriter.kt @@ -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}" } + } + } } diff --git a/src/main/kotlin/roomescape/reservation/infrastructure/persistence/ReservationEntity.kt b/src/main/kotlin/roomescape/reservation/infrastructure/persistence/ReservationEntity.kt index 3f8afa87..3ad2949c 100644 --- a/src/main/kotlin/roomescape/reservation/infrastructure/persistence/ReservationEntity.kt +++ b/src/main/kotlin/roomescape/reservation/infrastructure/persistence/ReservationEntity.kt @@ -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 {