[#35] 결제 스키마 재정의 & 예약 조회 페이지 개선 #36

Merged
pricelees merged 37 commits from refactor/#35 into main 2025-08-22 06:43:16 +00:00
10 changed files with 354 additions and 6 deletions
Showing only changes of commit 4c82ad80c0 - Show all commits

View 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) }
}
}

View File

@ -10,7 +10,7 @@ import java.time.OffsetDateTime
data class PaymentCancelRequestV2( data class PaymentCancelRequestV2(
val paymentKey: String, val paymentKey: String,
val amount: Long, val amount: Int,
val cancelReason: String val cancelReason: String
) )

View File

@ -1,19 +1,26 @@
package roomescape.payment.infrastructure.client.v2 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.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 import java.time.OffsetDateTime
data class PaymentConfirmRequest( data class PaymentConfirmRequest(
val paymentKey: String, val paymentKey: String,
val orderId: String, val orderId: String,
val amount: Long, val amount: Long,
val paymentType: PaymentType,
) )
data class PaymentConfirmResponse( data class PaymentConfirmResponse(
val paymentKey: String, val paymentKey: String,
val orderId: String, val status: PaymentStatus,
val totalAmount: Int, val totalAmount: Int,
val vat: Int,
val suppliedAmount: Int,
val method: PaymentMethod, val method: PaymentMethod,
val card: CardDetail?, val card: CardDetail?,
val easyPay: EasyPayDetail?, val easyPay: EasyPayDetail?,
@ -22,6 +29,24 @@ data class PaymentConfirmResponse(
val approvedAt: OffsetDateTime, 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( data class CardDetail(
val issuerCode: CardIssuerCode, val issuerCode: CardIssuerCode,
val number: String, val number: String,
@ -33,13 +58,67 @@ data class CardDetail(
val installmentPlanMonths: Int 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( data class EasyPayDetail(
val provider: EasyPayCompanyCode, val provider: EasyPayCompanyCode,
val amount: Int, val amount: Int,
val discountAmount: 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( data class TransferDetail(
val bankCode: BankCode, val bankCode: BankCode,
val settlementStatus: String, 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
)
}

View File

@ -2,4 +2,7 @@ package roomescape.payment.infrastructure.persistence.v2
import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.JpaRepository
interface PaymentRepositoryV2: JpaRepository<PaymentEntityV2, Long> interface PaymentRepositoryV2: JpaRepository<PaymentEntityV2, Long> {
fun findByReservationId(reservationId: Long): PaymentEntityV2?
}

View File

@ -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) }
}
}
}

View File

@ -17,4 +17,6 @@ enum class ReservationErrorCode(
NOT_RESERVATION_OWNER(HttpStatus.FORBIDDEN, "R006", "타인의 예약은 취소할 수 없어요."), NOT_RESERVATION_OWNER(HttpStatus.FORBIDDEN, "R006", "타인의 예약은 취소할 수 없어요."),
INVALID_SEARCH_DATE_RANGE(HttpStatus.BAD_REQUEST, "R007", "종료 날짜는 시작 날짜 이후여야 해요."), INVALID_SEARCH_DATE_RANGE(HttpStatus.BAD_REQUEST, "R007", "종료 날짜는 시작 날짜 이후여야 해요."),
NO_PERMISSION(HttpStatus.FORBIDDEN, "R008", "접근 권한이 없어요."), NO_PERMISSION(HttpStatus.FORBIDDEN, "R008", "접근 권한이 없어요."),
RESERVATION_NOT_PENDING(HttpStatus.BAD_REQUEST, "R009", "결제 대기 중인 예약이 아니에요."),
;
} }

View File

@ -91,4 +91,14 @@ class ReservationFinder(
return reservationRepository.existsByTime(time) return reservationRepository.existsByTime(time)
.also { log.debug { "[ReservationFinder.isTimeReserved] 완료: isExist=$it, timeId=${time.id}, startAt=${time.startAt}" } } .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}" }
}
}
} }

View File

@ -10,6 +10,7 @@ import roomescape.reservation.exception.ReservationException
import roomescape.reservation.infrastructure.persistence.ReservationEntity import roomescape.reservation.infrastructure.persistence.ReservationEntity
import roomescape.reservation.infrastructure.persistence.ReservationRepository import roomescape.reservation.infrastructure.persistence.ReservationRepository
import roomescape.reservation.infrastructure.persistence.ReservationSearchSpecification import roomescape.reservation.infrastructure.persistence.ReservationSearchSpecification
import roomescape.reservation.infrastructure.persistence.ReservationStatus
import roomescape.theme.infrastructure.persistence.ThemeEntity import roomescape.theme.infrastructure.persistence.ThemeEntity
import roomescape.time.infrastructure.persistence.TimeEntity import roomescape.time.infrastructure.persistence.TimeEntity
import java.time.LocalDate import java.time.LocalDate
@ -141,4 +142,15 @@ class ReservationValidator(
log.debug { "[ReservationValidator.validateAlreadyConfirmed] 완료: reservationId=$reservationId" } 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)
}
}
} }

View File

@ -6,8 +6,6 @@ import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
import roomescape.common.config.next import roomescape.common.config.next
import roomescape.member.implement.MemberFinder 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.ReservationEntity
import roomescape.reservation.infrastructure.persistence.ReservationRepository import roomescape.reservation.infrastructure.persistence.ReservationRepository
import roomescape.reservation.infrastructure.persistence.ReservationStatus import roomescape.reservation.infrastructure.persistence.ReservationStatus
@ -101,4 +99,15 @@ class ReservationWriter(
log.debug { "[ReservationWriter.confirm] 완료: reservationId=$reservationId, status=${ReservationStatus.CONFIRMED_PAYMENT_REQUIRED}" } 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}" }
}
}
} }

View File

@ -43,12 +43,23 @@ class ReservationEntity(
fun isReservedBy(memberId: Long): Boolean { fun isReservedBy(memberId: Long): Boolean {
return this.member.id == memberId return this.member.id == memberId
} }
fun cancelByUser() {
this.status = ReservationStatus.CANCELED_BY_USER
}
fun confirm() {
this.status = ReservationStatus.CONFIRMED
}
} }
enum class ReservationStatus { enum class ReservationStatus {
CONFIRMED, CONFIRMED,
CONFIRMED_PAYMENT_REQUIRED, CONFIRMED_PAYMENT_REQUIRED,
PENDING,
WAITING, WAITING,
CANCELED_BY_USER,
AUTOMATICALLY_CANCELED,
; ;
companion object { companion object {