diff --git a/backend-submodule b/backend-submodule index abd8358b..7da737b9 160000 --- a/backend-submodule +++ b/backend-submodule @@ -1 +1 @@ -Subproject commit abd8358b8b01ac497dbc295aa91aa9ca0340702a +Subproject commit 7da737b9efbaa8b3e79f2bf725e3660eebb173fd diff --git a/build.gradle.kts b/build.gradle.kts index 1baa4b1b..92663e97 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -106,3 +106,10 @@ tasks.register("copySecret", Copy::class) { include("application*.yml") into("./src/main/resources/") } + +tasks.test { + jvmArgs( + "--add-opens", "java.base/java.time=ALL-UNNAMED", + "--add-opens", "java.base/java.lang.reflect=ALL-UNNAMED" + ) +} diff --git a/src/main/kotlin/com/petqua/application/order/OrderService.kt b/src/main/kotlin/com/petqua/application/order/OrderService.kt index 5ad16cf8..845592fa 100644 --- a/src/main/kotlin/com/petqua/application/order/OrderService.kt +++ b/src/main/kotlin/com/petqua/application/order/OrderService.kt @@ -2,6 +2,7 @@ package com.petqua.application.order import com.petqua.application.order.dto.SaveOrderCommand import com.petqua.application.order.dto.SaveOrderResponse +import com.petqua.application.payment.infra.PaymentGatewayClient import com.petqua.common.domain.findByIdOrThrow import com.petqua.common.util.throwExceptionWhen import com.petqua.domain.order.Order @@ -33,6 +34,7 @@ class OrderService( private val productOptionRepository: ProductOptionRepository, private val shippingAddressRepository: ShippingAddressRepository, private val storeRepository: StoreRepository, + private val paymentGatewayClient: PaymentGatewayClient, ) { fun save(command: SaveOrderCommand): SaveOrderResponse { @@ -68,8 +70,8 @@ class OrderService( ?: throw ProductException(INVALID_PRODUCT_OPTION) throwExceptionWhen( - productCommand.orderPrice != (product.discountPrice + productOption.additionalPrice) * productCommand.quantity.toBigDecimal() - || productCommand.deliveryFee != product.getDeliveryFee(productCommand.deliveryMethod) + productCommand.orderPrice.setScale(2) != (product.discountPrice + productOption.additionalPrice) * productCommand.quantity.toBigDecimal() + || productCommand.deliveryFee.setScale(2) != product.getDeliveryFee(productCommand.deliveryMethod) ) { OrderException( ORDER_PRICE_NOT_MATCH @@ -127,8 +129,8 @@ class OrderService( return SaveOrderResponse( orderId = orders.first().orderNumber.value, orderName = orders.first().orderName.value, - successUrl = "successUrl", - failUrl = "failUrl", + successUrl = paymentGatewayClient.successUrl(), + failUrl = paymentGatewayClient.failUrl(), ) } } diff --git a/src/main/kotlin/com/petqua/application/order/dto/OrderDtos.kt b/src/main/kotlin/com/petqua/application/order/dto/OrderDtos.kt index 143634cf..f432a8da 100644 --- a/src/main/kotlin/com/petqua/application/order/dto/OrderDtos.kt +++ b/src/main/kotlin/com/petqua/application/order/dto/OrderDtos.kt @@ -44,12 +44,12 @@ data class OrderProductCommand( ): OrderProduct { return OrderProduct( quantity = quantity, - originalPrice = originalPrice, + originalPrice = originalPrice.setScale(2), discountRate = discountRate, - discountPrice = discountPrice, - deliveryFee = deliveryFee, + discountPrice = discountPrice.setScale(2), + deliveryFee = deliveryFee.setScale(2), shippingNumber = shippingNumber, - orderPrice = orderPrice, + orderPrice = orderPrice.setScale(2), productId = productId, productName = product.name, thumbnailUrl = product.thumbnailUrl, diff --git a/src/main/kotlin/com/petqua/application/payment/PaymentDtos.kt b/src/main/kotlin/com/petqua/application/payment/PaymentDtos.kt new file mode 100644 index 00000000..05af9714 --- /dev/null +++ b/src/main/kotlin/com/petqua/application/payment/PaymentDtos.kt @@ -0,0 +1,75 @@ +package com.petqua.application.payment + +import com.petqua.domain.order.OrderNumber +import com.petqua.domain.payment.tosspayment.TossPaymentType +import com.petqua.exception.payment.FailPaymentCode +import com.petqua.exception.payment.FailPaymentException +import com.petqua.exception.payment.FailPaymentExceptionType.ORDER_NUMBER_MISSING_EXCEPTION +import java.math.BigDecimal + +data class SucceedPaymentCommand( + val memberId: Long, + val paymentType: TossPaymentType, + val orderNumber: OrderNumber, + val paymentKey: String, + val amount: BigDecimal, +) { + fun toPaymentConfirmRequest(): PaymentConfirmRequestToPG { + return PaymentConfirmRequestToPG( + orderNumber = orderNumber, + paymentKey = paymentKey, + amount = amount + ) + } + + companion object { + fun of( + memberId: Long, + paymentType: String, + orderId: String, + paymentKey: String, + amount: BigDecimal, + ): SucceedPaymentCommand { + return SucceedPaymentCommand( + memberId = memberId, + paymentType = TossPaymentType.from(paymentType), + orderNumber = OrderNumber.from(orderId), + paymentKey = paymentKey, + amount = amount, + ) + } + } +} + +data class FailPaymentCommand( + val memberId: Long, + val code: FailPaymentCode, + val message: String, + val orderNumber: String?, +) { + + fun toOrderNumber(): OrderNumber { + return orderNumber?.let { OrderNumber.from(it) } ?: throw FailPaymentException(ORDER_NUMBER_MISSING_EXCEPTION) + } + + companion object { + fun of( + memberId: Long, + code: String, + message: String, + orderId: String?, + ): FailPaymentCommand { + return FailPaymentCommand( + memberId = memberId, + code = FailPaymentCode.from(code), + message = message, + orderNumber = orderId, + ) + } + } +} + +data class FailPaymentResponse( + val code: FailPaymentCode, + val message: String, +) diff --git a/src/main/kotlin/com/petqua/application/payment/PaymentFacadeService.kt b/src/main/kotlin/com/petqua/application/payment/PaymentFacadeService.kt new file mode 100644 index 00000000..f948b34f --- /dev/null +++ b/src/main/kotlin/com/petqua/application/payment/PaymentFacadeService.kt @@ -0,0 +1,38 @@ +package com.petqua.application.payment + +import com.petqua.exception.payment.FailPaymentCode.PAY_PROCESS_ABORTED +import com.petqua.exception.payment.FailPaymentCode.PAY_PROCESS_CANCELED +import com.petqua.exception.payment.FailPaymentCode.REJECT_CARD_COMPANY +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service + +@Service +class PaymentFacadeService( + private val paymentService: PaymentService, + private val paymentGatewayService: PaymentGatewayService, +) { + + private val log = LoggerFactory.getLogger(PaymentFacadeService::class.java) + + fun succeedPayment(command: SucceedPaymentCommand) { + paymentService.validateAmount(command) + val paymentResponse = paymentGatewayService.confirmPayment(command.toPaymentConfirmRequest()) + paymentService.processPayment(paymentResponse.toPayment()) + } + + fun failPayment(command: FailPaymentCommand): FailPaymentResponse { + logFailPayment(command) + if (command.code != PAY_PROCESS_CANCELED) { + paymentService.cancelOrder(command.memberId, command.toOrderNumber()) + } + return FailPaymentResponse(command.code, command.message) + } + + private fun logFailPayment(command: FailPaymentCommand) { + when (command.code) { + PAY_PROCESS_ABORTED -> log.error("PG사 혹은 원천사에서 결제를 중단했습니다. message: ${command.message}, OrderNumber: ${command.orderNumber}, MemberId: ${command.memberId}") + PAY_PROCESS_CANCELED -> log.warn("사용자가 결제를 중단했습니다. message: ${command.message}, OrderNumber: ${command.orderNumber}, MemberId: ${command.memberId}") + REJECT_CARD_COMPANY -> log.warn("카드사에서 결제를 거절했습니다. message: ${command.message}, OrderNumber: ${command.orderNumber}, MemberId: ${command.memberId}") + } + } +} diff --git a/src/main/kotlin/com/petqua/application/payment/PaymentGatewayDtos.kt b/src/main/kotlin/com/petqua/application/payment/PaymentGatewayDtos.kt new file mode 100644 index 00000000..91664721 --- /dev/null +++ b/src/main/kotlin/com/petqua/application/payment/PaymentGatewayDtos.kt @@ -0,0 +1,184 @@ +package com.petqua.application.payment + +import com.petqua.domain.order.OrderName +import com.petqua.domain.order.OrderNumber +import com.petqua.domain.payment.tosspayment.TossPayment +import com.petqua.domain.payment.tosspayment.TossPaymentMethod +import com.petqua.domain.payment.tosspayment.TossPaymentStatus +import com.petqua.domain.payment.tosspayment.TossPaymentType +import java.math.BigDecimal + +data class PaymentConfirmRequestToPG( + val orderNumber: OrderNumber, + val paymentKey: String, + val amount: BigDecimal, +) + +data class PaymentResponseFromPG( + val version: String, + val paymentKey: String, + val type: String, + val orderId: String, + val orderName: String, + val mid: String, + val currency: String, + val method: String, + val totalAmount: BigDecimal, + val balanceAmount: BigDecimal, + val status: String, + val requestedAt: String, + val approvedAt: String, + val useEscrow: Boolean, + val lastTransactionKey: String?, + val suppliedAmount: BigDecimal, + val vat: BigDecimal, + val cultureExpense: Boolean, + val taxFreeAmount: BigDecimal, + val taxExemptionAmount: BigDecimal, + val cancels: List?, + val isPartialCancelable: Boolean, + val card: CardResponseFromPG?, + val virtualAccount: VirtualAccountResponseFromPG?, + val secret: String?, + val mobilePhone: MobilePhoneResponseFromPG?, + val giftCertificate: GiftCertificateResponseFromPG?, + val transfer: TransferResponseFromPG?, + val receipt: ReceiptResponseFromPG?, + val checkout: CheckoutResponseFromPG?, + val easyPay: EasyPayResponseFromPG?, + val country: String, + val failure: FailureResponseFromPG?, + val cashReceipt: CashReceiptResponseFromPG?, + val cashReceipts: List?, + val discount: DiscountResponseFromPG?, +) { + fun toPayment(): TossPayment { + return TossPayment( + paymentKey = paymentKey, + orderNumber = OrderNumber.from(orderId), + orderName = OrderName(orderName), + method = TossPaymentMethod.valueOf(method), + totalAmount = totalAmount, + status = TossPaymentStatus.valueOf(status), + requestedAt = requestedAt, + approvedAt = approvedAt, + useEscrow = useEscrow, + type = TossPaymentType.valueOf(type), + ) + } +} + +data class CancelResponseFromPG( + val cancelAmount: BigDecimal, + val cancelReason: String, + val taxFreeAmount: BigDecimal, + val taxExemptionAmount: BigDecimal, + val refundableAmount: BigDecimal, + val easyPayDiscountAmount: BigDecimal, + val canceledAt: String, + val transactionKey: String, + val receiptKey: String?, +) + +data class CardResponseFromPG( + val amount: BigDecimal, + val issuerCode: String, + val acquirerCode: String?, + val number: String, + val installmentPlanMonths: Int, + val approveNo: String, + val useCardPoint: Boolean, + val cardType: String, + val ownerType: String, + val acquireStatus: String, + val isInterestFree: Boolean, + val interestPayer: String?, +) + +data class VirtualAccountResponseFromPG( + val accountType: String, + val accountNumber: String, + val bankCode: String, + val customerName: String, + val dueDate: String, + val refundStatus: String, + val expired: Boolean, + val settlementStatus: String, + val refundReceiveAccount: RefundReceiveAccountResponseFromPG?, // 결제창 띄운 시점부터 30분 동안만 조회 가능 +) + +data class RefundReceiveAccountResponseFromPG( + val bankCode: String, + val accountNumber: String, + val holderName: String, +) + +data class MobilePhoneResponseFromPG( + val customerMobilePhone: String, + val settlementStatus: String, + val receiptUrl: String, +) + +data class GiftCertificateResponseFromPG( + val approveNo: String, + val settlementStatus: String, +) + +data class TransferResponseFromPG( + val bankCode: String, + val settlementStatus: String, +) + +data class ReceiptResponseFromPG( + val url: String, +) + +data class CheckoutResponseFromPG( + val url: String, +) + +data class EasyPayResponseFromPG( + val provider: String, + val amount: BigDecimal, + val discountAmount: BigDecimal, +) + +data class FailureResponseFromPG( + val code: String, + val message: String, +) + +data class CashReceiptResponseFromPG( + val type: String, + val receiptKey: String, + val issueNumber: String, + val receiptUrl: String, + val amount: BigDecimal, + val taxFreeAmount: BigDecimal, +) + +data class CashReceiptHistoryResponseFromPG( + val receiptKey: String, + val orderId: String, + val orderName: String, + val type: String, + val issueNumber: String, + val receiptUrl: String, + val businessNumber: String, + val transactionType: String, + val amount: BigDecimal, + val taxFreeAmount: BigDecimal, + val issueStatus: String, + val failure: FailureResponseFromPG, + val customerIdentityNumber: String, + val requestedAt: String, +) + +data class DiscountResponseFromPG( + val amount: BigDecimal, +) + +data class PaymentErrorResponseFromPG( + val code: String, + val message: String, +) diff --git a/src/main/kotlin/com/petqua/application/payment/PaymentGatewayService.kt b/src/main/kotlin/com/petqua/application/payment/PaymentGatewayService.kt new file mode 100644 index 00000000..c246c0b7 --- /dev/null +++ b/src/main/kotlin/com/petqua/application/payment/PaymentGatewayService.kt @@ -0,0 +1,14 @@ +package com.petqua.application.payment + +import com.petqua.application.payment.infra.PaymentGatewayClient +import org.springframework.stereotype.Service + +@Service +class PaymentGatewayService( + private val paymentGatewayClient: PaymentGatewayClient, +) { + + fun confirmPayment(request: PaymentConfirmRequestToPG): PaymentResponseFromPG { + return paymentGatewayClient.confirmPayment(request) + } +} diff --git a/src/main/kotlin/com/petqua/application/payment/PaymentService.kt b/src/main/kotlin/com/petqua/application/payment/PaymentService.kt new file mode 100644 index 00000000..d3cf53ee --- /dev/null +++ b/src/main/kotlin/com/petqua/application/payment/PaymentService.kt @@ -0,0 +1,54 @@ +package com.petqua.application.payment + +import com.petqua.domain.order.OrderNumber +import com.petqua.domain.order.OrderPayment +import com.petqua.domain.order.OrderPaymentRepository +import com.petqua.domain.order.OrderRepository +import com.petqua.domain.order.findByOrderNumberOrThrow +import com.petqua.domain.payment.tosspayment.TossPayment +import com.petqua.domain.payment.tosspayment.TossPaymentRepository +import com.petqua.exception.order.OrderException +import com.petqua.exception.order.OrderExceptionType.ORDER_NOT_FOUND +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Transactional +@Service +class PaymentService( + private val orderRepository: OrderRepository, + private val orderPaymentRepository: OrderPaymentRepository, + private val paymentRepository: TossPaymentRepository, +) { + + @Transactional(readOnly = true) + fun validateAmount(command: SucceedPaymentCommand) { + val order = orderRepository.findByOrderNumberOrThrow(command.orderNumber) { + OrderException(ORDER_NOT_FOUND) + } + order.validateOwner(command.memberId) + order.validateAmount(command.amount.setScale(2)) + } + + fun processPayment(tossPayment: TossPayment): OrderPayment { + val order = orderRepository.findByOrderNumberOrThrow(tossPayment.orderNumber) { + OrderException(ORDER_NOT_FOUND) + } + order.pay() + + val payment = paymentRepository.save(tossPayment) + return orderPaymentRepository.save( + OrderPayment( + orderId = order.id, + tossPaymentId = payment.id + ) + ) + } + + fun cancelOrder(memberId: Long, orderNumber: OrderNumber) { + val order = orderRepository.findByOrderNumberOrThrow(orderNumber) { + OrderException(ORDER_NOT_FOUND) + } + order.validateOwner(memberId) + order.cancel() + } +} diff --git a/src/main/kotlin/com/petqua/application/payment/infra/PaymentGatewayClient.kt b/src/main/kotlin/com/petqua/application/payment/infra/PaymentGatewayClient.kt new file mode 100644 index 00000000..91d40511 --- /dev/null +++ b/src/main/kotlin/com/petqua/application/payment/infra/PaymentGatewayClient.kt @@ -0,0 +1,13 @@ +package com.petqua.application.payment.infra + +import com.petqua.application.payment.PaymentConfirmRequestToPG +import com.petqua.application.payment.PaymentResponseFromPG + +interface PaymentGatewayClient { + + fun confirmPayment(paymentConfirmRequestToPG: PaymentConfirmRequestToPG): PaymentResponseFromPG + + fun successUrl(): String + + fun failUrl(): String +} diff --git a/src/main/kotlin/com/petqua/application/payment/infra/TossPaymentClient.kt b/src/main/kotlin/com/petqua/application/payment/infra/TossPaymentClient.kt new file mode 100644 index 00000000..0c77a3af --- /dev/null +++ b/src/main/kotlin/com/petqua/application/payment/infra/TossPaymentClient.kt @@ -0,0 +1,47 @@ +package com.petqua.application.payment.infra + +import com.fasterxml.jackson.databind.ObjectMapper +import com.petqua.application.payment.PaymentConfirmRequestToPG +import com.petqua.application.payment.PaymentErrorResponseFromPG +import com.petqua.application.payment.PaymentResponseFromPG +import com.petqua.common.util.BasicAuthUtils +import com.petqua.exception.payment.PaymentException +import com.petqua.exception.payment.PaymentExceptionType +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.boot.context.properties.EnableConfigurationProperties +import org.springframework.stereotype.Component +import org.springframework.web.reactive.function.client.WebClientResponseException + +@ConfigurationProperties(prefix = "payment.toss-payments") +data class PaymentProperties( + val secretKey: String, + val successUrl: String, + val failUrl: String, +) + +@EnableConfigurationProperties(PaymentProperties::class) +@Component +class TossPaymentClient( + private val tossPaymentsApiClient: TossPaymentsApiClient, + private val paymentProperties: PaymentProperties, + private val objectMapper: ObjectMapper, +) : PaymentGatewayClient { + + override fun confirmPayment(paymentConfirmRequestToPG: PaymentConfirmRequestToPG): PaymentResponseFromPG { + val credentials = BasicAuthUtils.encodeCredentialsWithColon(paymentProperties.secretKey) + try { + return tossPaymentsApiClient.confirmPayment(credentials, paymentConfirmRequestToPG) + } catch (e: WebClientResponseException) { + val errorResponse = objectMapper.readValue(e.responseBodyAsString, PaymentErrorResponseFromPG::class.java) + throw PaymentException(PaymentExceptionType.from(errorResponse.code)) + } + } + + override fun successUrl(): String { + return paymentProperties.successUrl + } + + override fun failUrl(): String { + return paymentProperties.failUrl + } +} diff --git a/src/main/kotlin/com/petqua/application/payment/infra/TossPaymentsApiClient.kt b/src/main/kotlin/com/petqua/application/payment/infra/TossPaymentsApiClient.kt new file mode 100644 index 00000000..cfe42152 --- /dev/null +++ b/src/main/kotlin/com/petqua/application/payment/infra/TossPaymentsApiClient.kt @@ -0,0 +1,23 @@ +package com.petqua.application.payment.infra + +import com.petqua.application.payment.PaymentConfirmRequestToPG +import com.petqua.application.payment.PaymentResponseFromPG +import org.springframework.http.HttpHeaders.AUTHORIZATION +import org.springframework.http.MediaType.APPLICATION_JSON_VALUE +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestHeader +import org.springframework.web.service.annotation.HttpExchange +import org.springframework.web.service.annotation.PostExchange + +@HttpExchange(url = "https://api.tosspayments.com") +interface TossPaymentsApiClient { + + @PostExchange( + url = "/v1/payments/confirm", + contentType = APPLICATION_JSON_VALUE, + ) + fun confirmPayment( + @RequestHeader(name = AUTHORIZATION) credentials: String, + @RequestBody paymentConfirmRequestToPG: PaymentConfirmRequestToPG, + ): PaymentResponseFromPG +} diff --git a/src/main/kotlin/com/petqua/common/config/ApiClientConfig.kt b/src/main/kotlin/com/petqua/common/config/ApiClientConfig.kt new file mode 100644 index 00000000..9613fbfb --- /dev/null +++ b/src/main/kotlin/com/petqua/common/config/ApiClientConfig.kt @@ -0,0 +1,25 @@ +package com.petqua.common.config + +import com.petqua.application.payment.infra.TossPaymentsApiClient +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Profile +import org.springframework.web.reactive.function.client.WebClient +import org.springframework.web.reactive.function.client.support.WebClientAdapter +import org.springframework.web.service.invoker.HttpServiceProxyFactory + +@Configuration +@Profile("!test") +class ApiClientConfig { + + @Bean + fun tossPaymentsApiClient(): TossPaymentsApiClient { + return createApiClient(TossPaymentsApiClient::class.java) + } + + private fun createApiClient(clazz: Class): T { + val webClient = WebClient.create() + val builder = HttpServiceProxyFactory.builderFor(WebClientAdapter.create(webClient)) + return builder.build().createClient(clazz) + } +} diff --git a/src/main/kotlin/com/petqua/common/config/DataInitializer.kt b/src/main/kotlin/com/petqua/common/config/DataInitializer.kt index b1b623ad..6dd80676 100644 --- a/src/main/kotlin/com/petqua/common/config/DataInitializer.kt +++ b/src/main/kotlin/com/petqua/common/config/DataInitializer.kt @@ -9,6 +9,8 @@ import com.petqua.domain.keyword.ProductKeyword import com.petqua.domain.keyword.ProductKeywordRepository import com.petqua.domain.member.Member import com.petqua.domain.member.MemberRepository +import com.petqua.domain.order.ShippingAddress +import com.petqua.domain.order.ShippingAddressRepository import com.petqua.domain.product.Product import com.petqua.domain.product.ProductRepository import com.petqua.domain.product.WishCount @@ -73,6 +75,7 @@ class DataInitializer( private val wishProductRepository: WishProductRepository, private val productKeywordRepository: ProductKeywordRepository, private val productDescriptionRepository: ProductDescriptionRepository, + private val shippingAddressRepository: ShippingAddressRepository, ) { @EventListener(ApplicationReadyEvent::class) @@ -87,6 +90,20 @@ class DataInitializer( // banner saveBanners() + val shippingAddress = shippingAddressRepository.save( + ShippingAddress( + memberId = member.id, + name = "집", + receiver = "홍길동", + phoneNumber = "010-1234-5678", + zipCode = 12345, + address = "서울시 강남구 역삼동 99번길", + detailAddress = "101동 101호", + isDefaultAddress = true, + ) + ) + + // others saveCommerceData(member.id) } diff --git a/src/main/kotlin/com/petqua/common/util/AuthUtils.kt b/src/main/kotlin/com/petqua/common/util/AuthUtils.kt new file mode 100644 index 00000000..d6d24bd6 --- /dev/null +++ b/src/main/kotlin/com/petqua/common/util/AuthUtils.kt @@ -0,0 +1,15 @@ +package com.petqua.common.util + +import java.util.Base64 + +private const val EMPTY_PASSWORD = "" + +object BasicAuthUtils { + fun encodeCredentialsWithColon( + userName: String, + password: String = EMPTY_PASSWORD, + ): String { + val credentials = "$userName:$password" + return Base64.getEncoder().encodeToString(credentials.toByteArray()) + } +} diff --git a/src/main/kotlin/com/petqua/domain/auth/oauth/OauthApiClientConfig.kt b/src/main/kotlin/com/petqua/domain/auth/oauth/OauthApiClientConfig.kt index 878f6f89..70158876 100644 --- a/src/main/kotlin/com/petqua/domain/auth/oauth/OauthApiClientConfig.kt +++ b/src/main/kotlin/com/petqua/domain/auth/oauth/OauthApiClientConfig.kt @@ -3,15 +3,17 @@ package com.petqua.domain.auth.oauth import com.petqua.domain.auth.oauth.kakao.KakaoOauthApiClient import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Profile import org.springframework.web.reactive.function.client.WebClient import org.springframework.web.reactive.function.client.support.WebClientAdapter import org.springframework.web.service.invoker.HttpServiceProxyFactory @Configuration +@Profile("!test") class OauthApiClientConfig { @Bean - fun KakaoOauthApiClient(): KakaoOauthApiClient { + fun kakaoOauthApiClient(): KakaoOauthApiClient { return HttpServiceProxyFactory.builderFor( WebClientAdapter.create(WebClient.create()) ).build().createClient(KakaoOauthApiClient::class.java) diff --git a/src/main/kotlin/com/petqua/domain/order/Order.kt b/src/main/kotlin/com/petqua/domain/order/Order.kt index 070fff33..41da0e72 100644 --- a/src/main/kotlin/com/petqua/domain/order/Order.kt +++ b/src/main/kotlin/com/petqua/domain/order/Order.kt @@ -1,6 +1,11 @@ package com.petqua.domain.order import com.petqua.common.domain.BaseEntity +import com.petqua.common.util.throwExceptionWhen +import com.petqua.exception.order.OrderException +import com.petqua.exception.order.OrderExceptionType.FORBIDDEN_ORDER +import com.petqua.exception.order.OrderExceptionType.ORDER_CAN_NOT_PAY +import com.petqua.exception.order.OrderExceptionType.PAYMENT_PRICE_NOT_MATCH import jakarta.persistence.AttributeOverride import jakarta.persistence.Column import jakarta.persistence.Embedded @@ -41,8 +46,36 @@ class Order( @Enumerated(STRING) @Column(nullable = false) - val status: OrderStatus, + var status: OrderStatus, @Column(nullable = false) val totalAmount: BigDecimal, -) : BaseEntity() +) : BaseEntity() { + + fun validateAmount(amount: BigDecimal) { + throwExceptionWhen(totalAmount != amount) { + throw OrderException(PAYMENT_PRICE_NOT_MATCH) + } + } + + fun validateOwner(memberId: Long) { + throwExceptionWhen(this.memberId != memberId) { + throw OrderException(FORBIDDEN_ORDER) + } + } + + fun cancel() { + // TODO isAbleToCancel 사용 + // throwExceptionWhen(!isAbleToCancel) { + // OrderException(ORDER_NOT_FOUND) + // } + status = OrderStatus.CANCELED + } + + fun pay() { + throwExceptionWhen(!status.isAbleToPay()) { + throw OrderException(ORDER_CAN_NOT_PAY) + } + status = OrderStatus.PAYMENT_CONFIRMED + } +} diff --git a/src/main/kotlin/com/petqua/domain/order/OrderNumber.kt b/src/main/kotlin/com/petqua/domain/order/OrderNumber.kt index 0bd7b29b..ba2cd95a 100644 --- a/src/main/kotlin/com/petqua/domain/order/OrderNumber.kt +++ b/src/main/kotlin/com/petqua/domain/order/OrderNumber.kt @@ -1,11 +1,15 @@ package com.petqua.domain.order +import com.petqua.common.util.throwExceptionWhen +import com.petqua.exception.order.OrderException +import com.petqua.exception.order.OrderExceptionType.ORDER_NOT_FOUND import jakarta.persistence.Column import jakarta.persistence.Embeddable import java.time.Instant import java.time.ZoneId import java.time.format.DateTimeFormatter import java.util.UUID +import java.util.regex.Pattern @Embeddable data class OrderNumber( @@ -14,10 +18,19 @@ data class OrderNumber( ) { companion object { + private val orderNumberPattern = Pattern.compile("^\\d{14}[A-Z0-9]{12}\$") // 숫자 14자 + 숫자 or 대문자 12자 + fun generate(): OrderNumber { // 202402211607026029E90DB030 val createdTime = Instant.now().atZone(ZoneId.of("Asia/Seoul")).toLocalDateTime() val uuid = UUID.randomUUID().toString().replace("-", "").substring(0, 12).uppercase() return OrderNumber(createdTime.format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss")) + uuid) } + + fun from(orderNumber: String): OrderNumber { + throwExceptionWhen(!orderNumberPattern.matcher(orderNumber).matches()) { + OrderException(ORDER_NOT_FOUND) + } + return OrderNumber(orderNumber) + } } } diff --git a/src/main/kotlin/com/petqua/domain/order/OrderPaymentRepository.kt b/src/main/kotlin/com/petqua/domain/order/OrderPaymentRepository.kt new file mode 100644 index 00000000..14ba41bb --- /dev/null +++ b/src/main/kotlin/com/petqua/domain/order/OrderPaymentRepository.kt @@ -0,0 +1,5 @@ +package com.petqua.domain.order + +import org.springframework.data.jpa.repository.JpaRepository + +interface OrderPaymentRepository : JpaRepository diff --git a/src/main/kotlin/com/petqua/domain/order/OrderRepository.kt b/src/main/kotlin/com/petqua/domain/order/OrderRepository.kt index 27632406..177722eb 100644 --- a/src/main/kotlin/com/petqua/domain/order/OrderRepository.kt +++ b/src/main/kotlin/com/petqua/domain/order/OrderRepository.kt @@ -2,5 +2,14 @@ package com.petqua.domain.order import org.springframework.data.jpa.repository.JpaRepository +fun OrderRepository.findByOrderNumberOrThrow( + orderNumber: OrderNumber, + exceptionSupplier: () -> Exception = { IllegalArgumentException("${Order::class.java.name} entity 를 찾을 수 없습니다.") } +): Order { + return findByOrderNumber(orderNumber) ?: throw exceptionSupplier() +} + interface OrderRepository : JpaRepository { + + fun findByOrderNumber(orderNumber: OrderNumber): Order? } diff --git a/src/main/kotlin/com/petqua/domain/order/OrderStatus.kt b/src/main/kotlin/com/petqua/domain/order/OrderStatus.kt index e88bf161..589f8162 100644 --- a/src/main/kotlin/com/petqua/domain/order/OrderStatus.kt +++ b/src/main/kotlin/com/petqua/domain/order/OrderStatus.kt @@ -18,4 +18,9 @@ enum class OrderStatus( EXCHANGE_COMPLETED("교환 완료"), REFUND_REQUESTED("환불 요청"), REFUND_COMPLETED("환불 완료"), + ; + + fun isAbleToPay(): Boolean { + return this == ORDER_CREATED + } } diff --git a/src/main/kotlin/com/petqua/domain/payment/tosspayment/TossPayment.kt b/src/main/kotlin/com/petqua/domain/payment/tosspayment/TossPayment.kt index 6f37631e..d0d01f34 100644 --- a/src/main/kotlin/com/petqua/domain/payment/tosspayment/TossPayment.kt +++ b/src/main/kotlin/com/petqua/domain/payment/tosspayment/TossPayment.kt @@ -11,8 +11,8 @@ import jakarta.persistence.Enumerated import jakarta.persistence.GeneratedValue import jakarta.persistence.GenerationType.IDENTITY import jakarta.persistence.Id +import java.math.BigDecimal -// FIXME: 레퍼런스 첨부 https://docs.tosspayments.com/reference#%EA%B2%B0%EC%A0%9C @Entity class TossPayment( @Id @GeneratedValue(strategy = IDENTITY) @@ -34,7 +34,7 @@ class TossPayment( val method: TossPaymentMethod, @Column(nullable = false) - val totalAmount: String, + val totalAmount: BigDecimal, @Enumerated(STRING) @Column(nullable = false) @@ -47,7 +47,7 @@ class TossPayment( val approvedAt: String, @Column(nullable = false) - val useEscrow: String, // FIXME: 레퍼런스 첨부 https://docs.tosspayments.com/resources/glossary/escrow + val useEscrow: Boolean, @Enumerated(STRING) @Column(nullable = false) diff --git a/src/main/kotlin/com/petqua/domain/payment/tosspayment/TossPaymentRepository.kt b/src/main/kotlin/com/petqua/domain/payment/tosspayment/TossPaymentRepository.kt new file mode 100644 index 00000000..02196d2b --- /dev/null +++ b/src/main/kotlin/com/petqua/domain/payment/tosspayment/TossPaymentRepository.kt @@ -0,0 +1,6 @@ +package com.petqua.domain.payment.tosspayment + +import org.springframework.data.jpa.repository.JpaRepository + +interface TossPaymentRepository : JpaRepository { +} diff --git a/src/main/kotlin/com/petqua/domain/payment/tosspayment/TossPaymentType.kt b/src/main/kotlin/com/petqua/domain/payment/tosspayment/TossPaymentType.kt index 2311bd8e..31e17db9 100644 --- a/src/main/kotlin/com/petqua/domain/payment/tosspayment/TossPaymentType.kt +++ b/src/main/kotlin/com/petqua/domain/payment/tosspayment/TossPaymentType.kt @@ -1,5 +1,9 @@ package com.petqua.domain.payment.tosspayment +import com.petqua.exception.order.OrderException +import com.petqua.exception.order.OrderExceptionType.INVALID_PAYMENT_TYPE +import java.util.Locale + enum class TossPaymentType( private val description: String, ) { @@ -7,4 +11,12 @@ enum class TossPaymentType( NORMAL("일반 결제"), BILLING("자동 결제"), BRAND_PAY("브랜드 페이"), + ; + + companion object { + fun from(name: String): TossPaymentType { + return enumValues().find { it.name == name.uppercase(Locale.ENGLISH) } + ?: throw OrderException(INVALID_PAYMENT_TYPE) + } + } } diff --git a/src/main/kotlin/com/petqua/exception/order/OrderExceptionType.kt b/src/main/kotlin/com/petqua/exception/order/OrderExceptionType.kt index 1bfc022f..2ad58b67 100644 --- a/src/main/kotlin/com/petqua/exception/order/OrderExceptionType.kt +++ b/src/main/kotlin/com/petqua/exception/order/OrderExceptionType.kt @@ -3,6 +3,8 @@ package com.petqua.exception.order import com.petqua.common.exception.BaseExceptionType import org.springframework.http.HttpStatus import org.springframework.http.HttpStatus.BAD_REQUEST +import org.springframework.http.HttpStatus.FORBIDDEN +import org.springframework.http.HttpStatus.NOT_FOUND enum class OrderExceptionType( private val httpStatus: HttpStatus, @@ -13,6 +15,15 @@ enum class OrderExceptionType( PRODUCT_NOT_FOUND(BAD_REQUEST, "O01", "주문한 상품이 존재하지 않습니다."), ORDER_PRICE_NOT_MATCH(BAD_REQUEST, "O10", "주문한 상품의 가격이 일치하지 않습니다."), + ORDER_NOT_FOUND(NOT_FOUND, "O11", "존재하지 않는 주문입니다."), + PAYMENT_PRICE_NOT_MATCH(BAD_REQUEST, "O12", "주문한 상품 가격과 결제 금액이 일치하지 않습니다."), + + INVALID_PAYMENT_TYPE(BAD_REQUEST, "O20", "유효하지 않은 결제 방식입니다."), + + FORBIDDEN_ORDER(FORBIDDEN, "O30", "해당 주문에 대한 권한이 없습니다."), + + ORDER_CAN_NOT_CANCEL(BAD_REQUEST, "O31", "취소할 수 없는 주문입니다."), + ORDER_CAN_NOT_PAY(BAD_REQUEST, "O32", "결제할 수 없는 주문입니다."), ; override fun httpStatus(): HttpStatus { diff --git a/src/main/kotlin/com/petqua/exception/payment/FailPaymentCode.kt b/src/main/kotlin/com/petqua/exception/payment/FailPaymentCode.kt new file mode 100644 index 00000000..97ee169b --- /dev/null +++ b/src/main/kotlin/com/petqua/exception/payment/FailPaymentCode.kt @@ -0,0 +1,19 @@ +package com.petqua.exception.payment + +import com.petqua.exception.payment.FailPaymentExceptionType.INVALID_CODE +import java.util.Locale + +enum class FailPaymentCode { + + PAY_PROCESS_CANCELED, + PAY_PROCESS_ABORTED, + REJECT_CARD_COMPANY, + ; + + companion object { + fun from(name: String): FailPaymentCode { + return enumValues().find { it.name == name.uppercase(Locale.ENGLISH) } + ?: throw FailPaymentException(INVALID_CODE) + } + } +} diff --git a/src/main/kotlin/com/petqua/exception/payment/FailPaymentException.kt b/src/main/kotlin/com/petqua/exception/payment/FailPaymentException.kt new file mode 100644 index 00000000..e538957f --- /dev/null +++ b/src/main/kotlin/com/petqua/exception/payment/FailPaymentException.kt @@ -0,0 +1,13 @@ +package com.petqua.exception.payment + +import com.petqua.common.exception.BaseException +import com.petqua.common.exception.BaseExceptionType + +class FailPaymentException( + private val exceptionType: FailPaymentExceptionType, +) : BaseException() { + + override fun exceptionType(): BaseExceptionType { + return exceptionType + } +} diff --git a/src/main/kotlin/com/petqua/exception/payment/FailPaymentExceptionType.kt b/src/main/kotlin/com/petqua/exception/payment/FailPaymentExceptionType.kt new file mode 100644 index 00000000..8fcf15a1 --- /dev/null +++ b/src/main/kotlin/com/petqua/exception/payment/FailPaymentExceptionType.kt @@ -0,0 +1,28 @@ +package com.petqua.exception.payment + +import com.petqua.common.exception.BaseExceptionType +import org.springframework.http.HttpStatus +import org.springframework.http.HttpStatus.BAD_REQUEST + +enum class FailPaymentExceptionType( + private val httpStatus: HttpStatus, + private val code: String, + private val errorMessage: String, +) : BaseExceptionType { + + INVALID_CODE(BAD_REQUEST, "PF01", "지원하지 않는 결제 실패 코드입니다."), + ORDER_NUMBER_MISSING_EXCEPTION(BAD_REQUEST, "PF02", "주문번호가 입력되지 않았습니다."), + ; + + override fun httpStatus(): HttpStatus { + return httpStatus + } + + override fun code(): String { + return code + } + + override fun errorMessage(): String { + return errorMessage + } +} diff --git a/src/main/kotlin/com/petqua/exception/payment/PaymentException.kt b/src/main/kotlin/com/petqua/exception/payment/PaymentException.kt new file mode 100644 index 00000000..d2dc4eab --- /dev/null +++ b/src/main/kotlin/com/petqua/exception/payment/PaymentException.kt @@ -0,0 +1,13 @@ +package com.petqua.exception.payment + +import com.petqua.common.exception.BaseException +import com.petqua.common.exception.BaseExceptionType + +class PaymentException( + private val exceptionType: PaymentExceptionType, +) : BaseException() { + + override fun exceptionType(): BaseExceptionType { + return exceptionType + } +} diff --git a/src/main/kotlin/com/petqua/exception/payment/PaymentExceptionType.kt b/src/main/kotlin/com/petqua/exception/payment/PaymentExceptionType.kt new file mode 100644 index 00000000..39ca777e --- /dev/null +++ b/src/main/kotlin/com/petqua/exception/payment/PaymentExceptionType.kt @@ -0,0 +1,90 @@ +package com.petqua.exception.payment + +import com.petqua.common.exception.BaseExceptionType +import org.springframework.http.HttpStatus +import org.springframework.http.HttpStatus.BAD_REQUEST +import org.springframework.http.HttpStatus.FORBIDDEN +import org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR +import org.springframework.http.HttpStatus.NOT_FOUND +import org.springframework.http.HttpStatus.UNAUTHORIZED +import java.util.Locale + +enum class PaymentExceptionType( + private val httpStatus: HttpStatus, + private val code: String, + private val errorMessage: String, +) : BaseExceptionType { + + ALREADY_PROCESSED_PAYMENT(BAD_REQUEST, "PA01", "이미 처리된 결제 입니다"), + PROVIDER_ERROR(BAD_REQUEST, "PA02", "일시적인 오류가 발생했습니다. 잠시 후 다시 시도해주세요."), + EXCEED_MAX_CARD_INSTALLMENT_PLAN(BAD_REQUEST, "PA03", "설정 가능한 최대 할부 개월 수를 초과했습니다."), + INVALID_REQUEST(BAD_REQUEST, "PA04", "잘못된 요청입니다."), + NOT_ALLOWED_POINT_USE(BAD_REQUEST, "PA05", "포인트 사용이 불가한 카드로 카드 포인트 결제에 실패했습니다."), + INVALID_API_KEY(BAD_REQUEST, "PA06", "잘못된 시크릿키 연동 정보 입니다."), + INVALID_REJECT_CARD(BAD_REQUEST, "PA07", "카드 사용이 거절되었습니다. 카드사 문의가 필요합니다."), + BELOW_MINIMUM_AMOUNT(BAD_REQUEST, "PA08", "신용카드는 결제금액이 100원 이상, 계좌는 200원이상부터 결제가 가능합니다."), + INVALID_CARD_EXPIRATION(BAD_REQUEST, "PA09", "카드 정보를 다시 확인해주세요. (유효기간)"), + INVALID_STOPPED_CARD(BAD_REQUEST, "PA10", "정지된 카드 입니다."), + EXCEED_MAX_DAILY_PAYMENT_COUNT(BAD_REQUEST, "PA11", "하루 결제 가능 횟수를 초과했습니다."), + NOT_SUPPORTED_INSTALLMENT_PLAN_CARD_OR_MERCHANT(BAD_REQUEST, "PA12", "할부가 지원되지 않는 카드 또는 가맹점 입니다."), + INVALID_CARD_INSTALLMENT_PLAN(BAD_REQUEST, "PA13", "할부 개월 정보가 잘못되었습니다."), + NOT_SUPPORTED_MONTHLY_INSTALLMENT_PLAN(BAD_REQUEST, "PA14", "할부가 지원되지 않는 카드입니다."), + EXCEED_MAX_PAYMENT_AMOUNT(BAD_REQUEST, "PA15", "하루 결제 가능 금액을 초과했습니다."), + NOT_FOUND_TERMINAL_ID(BAD_REQUEST, "PA16", "단말기번호(Terminal Id)가 없습니다. 토스페이먼츠로 문의 바랍니다."), + INVALID_AUTHORIZE_AUTH(BAD_REQUEST, "PA17", "유효하지 않은 인증 방식입니다."), + INVALID_CARD_LOST_OR_STOLEN(BAD_REQUEST, "PA18", "분실 혹은 도난 카드입니다."), + RESTRICTED_TRANSFER_ACCOUNT(BAD_REQUEST, "PA19", "계좌는 등록 후 12시간 뒤부터 결제할 수 있습니다. 관련 정책은 해당 은행으로 문의해주세요."), + INVALID_CARD_NUMBER(BAD_REQUEST, "PA20", "카드번호를 다시 확인해주세요."), + INVALID_UNREGISTERED_SUBMALL(BAD_REQUEST, "PA21", "등록되지 않은 서브몰입니다. 서브몰이 없는 가맹점이라면 안심클릭이나 ISP 결제가 필요합니다."), + NOT_REGISTERED_BUSINESS(BAD_REQUEST, "PA22", "등록되지 않은 사업자 번호입니다."), + EXCEED_MAX_ONE_DAY_WITHDRAW_AMOUNT(BAD_REQUEST, "PA23", "1일 출금 한도를 초과했습니다."), + EXCEED_MAX_ONE_TIME_WITHDRAW_AMOUNT(BAD_REQUEST, "PA24", "1회 출금 한도를 초과했습니다."), + CARD_PROCESSING_ERROR(BAD_REQUEST, "PA25", "카드사에서 오류가 발생했습니다."), + EXCEED_MAX_AMOUNT(BAD_REQUEST, "PA26", "거래금액 한도를 초과했습니다."), + INVALID_ACCOUNT_INFO_RE_REGISTER(BAD_REQUEST, "PA27", "유효하지 않은 계좌입니다. 계좌 재등록 후 시도해주세요."), + NOT_AVAILABLE_PAYMENT(BAD_REQUEST, "PA28", "결제가 불가능한 시간대입니다."), + UNAPPROVED_ORDER_ID(BAD_REQUEST, "PA29", "아직 승인되지 않은 주문번호입니다."), + + UNAUTHORIZED_KEY(UNAUTHORIZED, "PA30", "인증되지 않은 시크릿 키 혹은 클라이언트 키 입니다."), + + REJECT_ACCOUNT_PAYMENT(FORBIDDEN, "PA31", "잔액부족으로 결제에 실패했습니다."), + REJECT_CARD_PAYMENT(FORBIDDEN, "PA32", "한도초과 혹은 잔액부족으로 결제에 실패했습니다."), + REJECT_CARD_COMPANY(FORBIDDEN, "PA33", "결제 승인이 거절되었습니다."), + FORBIDDEN_REQUEST(FORBIDDEN, "PA34", "허용되지 않은 요청입니다."), + REJECT_TOSSPAY_INVALID_ACCOUNT(FORBIDDEN, "PA35", "선택하신 출금 계좌가 출금이체 등록이 되어 있지 않아요. 계좌를 다시 등록해 주세요."), + EXCEED_MAX_AUTH_COUNT(FORBIDDEN, "PA36", "최대 인증 횟수를 초과했습니다. 카드사로 문의해주세요."), + EXCEED_MAX_ONE_DAY_AMOUNT(FORBIDDEN, "PA37", "일일 한도를 초과했습니다."), + NOT_AVAILABLE_BANK(FORBIDDEN, "PA38", "은행 서비스 시간이 아닙니다."), + INVALID_PASSWORD(FORBIDDEN, "PA39", "결제 비밀번호가 일치하지 않습니다."), + INCORRECT_BASIC_AUTH_FORMAT(FORBIDDEN, "PA40", "잘못된 요청입니다. ':' 를 포함해 인코딩해주세요."), + FDS_ERROR(FORBIDDEN, "PA41", "[토스페이먼츠] 위험거래가 감지되어 결제가 제한됩니다.발송된 문자에 포함된 링크를 통해 본인인증 후 결제가 가능합니다.(고객센터: 1644-8051)"), + + NOT_FOUND_PAYMENT(NOT_FOUND, "PA42", "존재하지 않는 결제 정보 입니다."), + NOT_FOUND_PAYMENT_SESSION(NOT_FOUND, "PA43", "결제 시간이 만료되어 결제 진행 데이터가 존재하지 않습니다."), + + FAILED_PAYMENT_INTERNAL_SYSTEM_PROCESSING(INTERNAL_SERVER_ERROR, "PA44", "결제가 완료되지 않았어요. 다시 시도해주세요."), + FAILED_INTERNAL_SYSTEM_PROCESSING(INTERNAL_SERVER_ERROR, "PA45", "내부 시스템 처리 작업이 실패했습니다. 잠시 후 다시 시도해주세요."), + UNKNOWN_PAYMENT_ERROR(INTERNAL_SERVER_ERROR, "PA46", "결제에 실패했어요. 같은 문제가 반복된다면 은행이나 카드사로 문의해주세요."), + + UNKNOWN_PAYMENT_EXCEPTION(INTERNAL_SERVER_ERROR, "PA50", "지원하지 않는 결제 예외가 발생했습니다."), + ; + + override fun httpStatus(): HttpStatus { + return httpStatus + } + + override fun code(): String { + return code + } + + override fun errorMessage(): String { + return errorMessage + } + + companion object { + fun from(name: String): PaymentExceptionType { + return enumValues().find { it.name == name.uppercase(Locale.ENGLISH) } + ?: throw PaymentException(UNKNOWN_PAYMENT_EXCEPTION) + } + } +} diff --git a/src/main/kotlin/com/petqua/presentation/order/OrderController.kt b/src/main/kotlin/com/petqua/presentation/order/OrderController.kt index 81e235c3..48bcf378 100644 --- a/src/main/kotlin/com/petqua/presentation/order/OrderController.kt +++ b/src/main/kotlin/com/petqua/presentation/order/OrderController.kt @@ -16,8 +16,8 @@ import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController -@Tag(name = "Order", description = "주문 관련 API 명세") @SecurityRequirement(name = ACCESS_TOKEN_SECURITY_SCHEME_KEY) +@Tag(name = "Order", description = "주문 관련 API 명세") @RequestMapping("/orders") @RestController class OrderController( diff --git a/src/main/kotlin/com/petqua/presentation/payment/PaymentController.kt b/src/main/kotlin/com/petqua/presentation/payment/PaymentController.kt new file mode 100644 index 00000000..3fc96d64 --- /dev/null +++ b/src/main/kotlin/com/petqua/presentation/payment/PaymentController.kt @@ -0,0 +1,40 @@ +package com.petqua.presentation.payment + +import com.petqua.application.payment.FailPaymentResponse +import com.petqua.application.payment.PaymentFacadeService +import com.petqua.common.config.ACCESS_TOKEN_SECURITY_SCHEME_KEY +import com.petqua.domain.auth.Auth +import com.petqua.domain.auth.LoginMember +import io.swagger.v3.oas.annotations.security.SecurityRequirement +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@SecurityRequirement(name = ACCESS_TOKEN_SECURITY_SCHEME_KEY) +@Tag(name = "Payment", description = "결제 관련 API 명세") +@RequestMapping("/orders/payment") +@RestController +class PaymentController( + private val paymentFacadeService: PaymentFacadeService, +) { + + @PostMapping("/success") + fun succeedPayment( + @Auth loginMember: LoginMember, + request: SucceedPaymentRequest, + ): ResponseEntity { + paymentFacadeService.succeedPayment(request.toCommand(loginMember.memberId)) + return ResponseEntity.noContent().build() + } + + @PostMapping("/fail") + fun failPayment( + @Auth loginMember: LoginMember, + request: FailPaymentRequest, + ): ResponseEntity { + val response = paymentFacadeService.failPayment(request.toCommand(loginMember.memberId)) + return ResponseEntity.ok(response) + } +} diff --git a/src/main/kotlin/com/petqua/presentation/payment/PaymentDtos.kt b/src/main/kotlin/com/petqua/presentation/payment/PaymentDtos.kt new file mode 100644 index 00000000..a8319578 --- /dev/null +++ b/src/main/kotlin/com/petqua/presentation/payment/PaymentDtos.kt @@ -0,0 +1,39 @@ +package com.petqua.presentation.payment + +import com.petqua.application.payment.FailPaymentCommand +import com.petqua.application.payment.SucceedPaymentCommand +import java.math.BigDecimal + +data class SucceedPaymentRequest( + val paymentType: String, + val orderId: String, + val paymentKey: String, + val amount: BigDecimal, +) { + + fun toCommand(memberId: Long): SucceedPaymentCommand { + return SucceedPaymentCommand.of( + memberId = memberId, + paymentType = paymentType, + orderId = orderId, + paymentKey = paymentKey, + amount = amount, + ) + } +} + +data class FailPaymentRequest( + val code: String, + val message: String, + val orderId: String?, +) { + + fun toCommand(memberId: Long): FailPaymentCommand { + return FailPaymentCommand.of( + memberId = memberId, + code = code, + message = message, + orderId = orderId, + ) + } +} diff --git a/src/test/kotlin/com/petqua/application/auth/AuthFacadeServiceTest.kt b/src/test/kotlin/com/petqua/application/auth/AuthFacadeServiceTest.kt index 4bd7de7b..fdc6e1e7 100644 --- a/src/test/kotlin/com/petqua/application/auth/AuthFacadeServiceTest.kt +++ b/src/test/kotlin/com/petqua/application/auth/AuthFacadeServiceTest.kt @@ -13,7 +13,6 @@ import com.petqua.exception.auth.AuthExceptionType import com.petqua.exception.member.MemberException import com.petqua.exception.member.MemberExceptionType.NOT_FOUND_MEMBER import com.petqua.test.DataCleaner -import com.petqua.test.config.OauthTestConfig import com.petqua.test.fixture.member import io.kotest.assertions.assertSoftly import io.kotest.assertions.throwables.shouldNotThrow @@ -23,14 +22,12 @@ import io.kotest.matchers.shouldBe import io.mockk.verify import org.springframework.boot.test.context.SpringBootTest import org.springframework.boot.test.context.SpringBootTest.WebEnvironment.NONE -import org.springframework.context.annotation.Import import java.lang.System.currentTimeMillis import java.time.LocalDateTime -import java.util.* +import java.util.Date @SpringBootTest(webEnvironment = NONE) -@Import(OauthTestConfig::class) -class AuthFacadeServiceTest( +class AuthServiceTest( private val authFacadeService: AuthFacadeService, private val refreshTokenRepository: RefreshTokenRepository, private val authTokenProvider: AuthTokenProvider, diff --git a/src/test/kotlin/com/petqua/application/order/ShippingAddressServiceTest.kt b/src/test/kotlin/com/petqua/application/order/ShippingAddressServiceTest.kt index 047cbb29..6dc2dabb 100644 --- a/src/test/kotlin/com/petqua/application/order/ShippingAddressServiceTest.kt +++ b/src/test/kotlin/com/petqua/application/order/ShippingAddressServiceTest.kt @@ -20,9 +20,10 @@ import io.kotest.matchers.nulls.shouldBeNull import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.shouldBe import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment.NONE import kotlin.Long.Companion.MIN_VALUE -@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) +@SpringBootTest(webEnvironment = NONE) class ShippingAddressServiceTest( private val shippingAddressService: ShippingAddressService, private val shippingAddressRepository: ShippingAddressRepository, diff --git a/src/test/kotlin/com/petqua/application/payment/PaymentFacadeServiceTest.kt b/src/test/kotlin/com/petqua/application/payment/PaymentFacadeServiceTest.kt new file mode 100644 index 00000000..6c76b922 --- /dev/null +++ b/src/test/kotlin/com/petqua/application/payment/PaymentFacadeServiceTest.kt @@ -0,0 +1,359 @@ +package com.petqua.application.payment + +import com.ninjasquad.springmockk.SpykBean +import com.petqua.application.payment.infra.TossPaymentsApiClient +import com.petqua.common.domain.findByIdOrThrow +import com.petqua.domain.member.MemberRepository +import com.petqua.domain.order.OrderNumber +import com.petqua.domain.order.OrderPaymentRepository +import com.petqua.domain.order.OrderRepository +import com.petqua.domain.order.OrderStatus.CANCELED +import com.petqua.domain.order.OrderStatus.ORDER_CREATED +import com.petqua.domain.order.OrderStatus.PAYMENT_CONFIRMED +import com.petqua.domain.payment.tosspayment.TossPaymentRepository +import com.petqua.exception.order.OrderException +import com.petqua.exception.order.OrderExceptionType.FORBIDDEN_ORDER +import com.petqua.exception.order.OrderExceptionType.ORDER_CAN_NOT_PAY +import com.petqua.exception.order.OrderExceptionType.ORDER_NOT_FOUND +import com.petqua.exception.order.OrderExceptionType.PAYMENT_PRICE_NOT_MATCH +import com.petqua.exception.payment.FailPaymentCode.PAY_PROCESS_ABORTED +import com.petqua.exception.payment.FailPaymentCode.PAY_PROCESS_CANCELED +import com.petqua.exception.payment.FailPaymentException +import com.petqua.exception.payment.FailPaymentExceptionType.INVALID_CODE +import com.petqua.exception.payment.FailPaymentExceptionType.ORDER_NUMBER_MISSING_EXCEPTION +import com.petqua.exception.payment.PaymentException +import com.petqua.exception.payment.PaymentExceptionType.UNAUTHORIZED_KEY +import com.petqua.test.DataCleaner +import com.petqua.test.fixture.failPaymentCommand +import com.petqua.test.fixture.member +import com.petqua.test.fixture.order +import com.petqua.test.fixture.succeedPaymentCommand +import io.kotest.assertions.assertSoftly +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.BehaviorSpec +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.verify +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment.NONE +import org.springframework.web.reactive.function.client.WebClientResponseException +import java.math.BigDecimal.ONE +import kotlin.Long.Companion.MIN_VALUE + +@SpringBootTest(webEnvironment = NONE) +class PaymentFacadeServiceTest( + private val paymentFacadeService: PaymentFacadeService, + private val memberRepository: MemberRepository, + private val orderRepository: OrderRepository, + private val paymentRepository: TossPaymentRepository, + private val orderPaymentRepository: OrderPaymentRepository, + private val dataCleaner: DataCleaner, + @SpykBean private val tossPaymentsApiClient: TossPaymentsApiClient, +) : BehaviorSpec({ + + Given("결제 성공을 처리할 때") { + val member = memberRepository.save(member()) + val order = orderRepository.save( + order( + memberId = member.id, + orderNumber = OrderNumber.from("202402211607020ORDERNUMBER"), + totalAmount = ONE + ) + ) + + When("유효한 요쳥이면") { + paymentFacadeService.succeedPayment( + command = succeedPaymentCommand( + memberId = order.memberId, + orderNumber = order.orderNumber, + amount = order.totalAmount, + ) + ) + + Then("PG사에 결제 승인 요청을 보낸다") { + verify(exactly = 1) { + tossPaymentsApiClient.confirmPayment(any(String::class), any(PaymentConfirmRequestToPG::class)) + } + } + } + + When("결제 승인에 성공하면") { + paymentFacadeService.succeedPayment( + command = succeedPaymentCommand( + memberId = order.memberId, + orderNumber = order.orderNumber, + amount = order.totalAmount, + ) + ) + + Then("TossPayment 객체를 생성한다") { + val payments = paymentRepository.findAll() + + assertSoftly { + payments.size shouldBe 1 + val payment = payments[0] + + payment.orderNumber shouldBe order.orderNumber + payment.totalAmount shouldBe order.totalAmount.setScale(2) + } + } + + Then("Order의 상태를 변경한다") { + val updatedOrder = orderRepository.findByIdOrThrow(order.id) + + assertSoftly { + updatedOrder.status shouldBe PAYMENT_CONFIRMED + } + } + + Then("OrderPayment 객체를 생성한다") { + val orderPayments = orderPaymentRepository.findAll() + + assertSoftly { + orderPayments.size shouldBe 1 + val orderPayment = orderPayments[0] + + orderPayment.orderId shouldBe order.id + orderPayment.tossPaymentId shouldBe paymentRepository.findAll()[0].id + } + } + } + + When("존재하지 않는 주문이면") { + val orderNumber = OrderNumber.from("20240221160702ORDERNUMBER0") + + Then("예외를 던진다") { + shouldThrow { + paymentFacadeService.succeedPayment( + command = succeedPaymentCommand( + memberId = order.memberId, + orderNumber = orderNumber, + amount = order.totalAmount, + ) + ) + }.exceptionType() shouldBe ORDER_NOT_FOUND + } + } + + When("결제할 수 없는 주문이면") { + val invalidOrder = orderRepository.save( + order( + memberId = member.id, + orderNumber = OrderNumber.from("202402211607021ORDERNUMBER"), + totalAmount = ONE, + status = PAYMENT_CONFIRMED, + ) + ) + + Then("예외를 던진다") { + shouldThrow { + paymentFacadeService.succeedPayment( + command = succeedPaymentCommand( + memberId = invalidOrder.memberId, + orderNumber = invalidOrder.orderNumber, + amount = invalidOrder.totalAmount, + ) + ) + }.exceptionType() shouldBe ORDER_CAN_NOT_PAY + } + } + + When("권한이 없는 회원이면") { + val memberId = MIN_VALUE + + Then("예외를 던진다") { + shouldThrow { + paymentFacadeService.succeedPayment( + command = succeedPaymentCommand( + memberId = memberId, + orderNumber = order.orderNumber, + amount = order.totalAmount, + ) + ) + }.exceptionType() shouldBe FORBIDDEN_ORDER + } + } + + When("주문의 총가격과 결제 금액이 다르면") { + val amount = order.totalAmount + ONE + + Then("예외를 던진다") { + shouldThrow { + paymentFacadeService.succeedPayment( + command = succeedPaymentCommand( + memberId = order.memberId, + orderNumber = order.orderNumber, + amount = amount, + ) + ) + }.exceptionType() shouldBe PAYMENT_PRICE_NOT_MATCH + } + } + + When("PG사와 통신할 때 예외가 발생하면") { + every { + tossPaymentsApiClient.confirmPayment(any(String::class), any(PaymentConfirmRequestToPG::class)) + } throws WebClientResponseException( + 404, + "UNAUTHORIZED", + null, + "{\"code\":\"UNAUTHORIZED_KEY\",\"message\":\"인증되지 않은 시크릿 키 혹은 클라이언트 키 입니다.\",\"data\":null}".toByteArray(), + null + ) + + Then("예외를 던진다") { + shouldThrow { + paymentFacadeService.succeedPayment( + command = succeedPaymentCommand( + memberId = order.memberId, + orderNumber = order.orderNumber, + amount = order.totalAmount, + ) + ) + }.exceptionType() shouldBe UNAUTHORIZED_KEY + } + } + } + + Given("사용자가 취소한 결제 실패를 처리할 때") { + val member = memberRepository.save(member()) + val order = orderRepository.save( + order( + memberId = member.id, + orderNumber = OrderNumber.from("202402211607020ORDERNUMBER"), + totalAmount = ONE + ) + ) + + When("유효한 실패 내역이 입력되면") { + val response = paymentFacadeService.failPayment( + failPaymentCommand( + memberId = member.id, + code = PAY_PROCESS_CANCELED.name, + message = "사용자가 결제를 취소했습니다.", + orderNumber = null + ) + ) + + Then("실패 내역을 응답한다") { + response shouldBe FailPaymentResponse( + code = PAY_PROCESS_CANCELED, + message = "사용자가 결제를 취소했습니다.", + ) + } + + Then("주문을 취소하지 않는다") { + val updatedOrder = orderRepository.findByIdOrThrow(order.id) + + updatedOrder.status shouldBe ORDER_CREATED + } + } + + When("유효하지 않은 실패 코드가 입력되면") { + val code = "INVALID_CODE" + + Then("예외를 던진다") { + shouldThrow { + paymentFacadeService.failPayment( + failPaymentCommand( + memberId = member.id, + code = code, + message = "사용자가 결제를 취소했습니다.", + orderNumber = null + ) + ) + }.exceptionType() shouldBe INVALID_CODE + } + } + } + + Given("사용자가 취소하지 않은 결제 실패를 처리할 때") { + val member = memberRepository.save(member()) + val order = orderRepository.save( + order( + memberId = member.id, + orderNumber = OrderNumber.from("202402211607020ORDERNUMBER"), + totalAmount = ONE + ) + ) + + When("유효한 실패 내역이 입력되면") { + val response = paymentFacadeService.failPayment( + failPaymentCommand( + memberId = member.id, + code = PAY_PROCESS_ABORTED.name, + message = "시스템 오류로 결제가 실패했습니다.", + orderNumber = order.orderNumber.value, + ) + ) + + Then("해당 내용을 응답한다") { + response shouldBe FailPaymentResponse( + code = PAY_PROCESS_ABORTED, + message = "시스템 오류로 결제가 실패했습니다.", + ) + } + + Then("주문을 취소한다") { + val updatedOrder = orderRepository.findByIdOrThrow(order.id) + + updatedOrder.status shouldBe CANCELED + } + } + + When("주문번호가 입력되지 않으면") { + val orderNumber = null + + Then("예외를 던진다") { + shouldThrow { + paymentFacadeService.failPayment( + failPaymentCommand( + memberId = member.id, + code = PAY_PROCESS_ABORTED.name, + message = "시스템 오류로 결제가 실패했습니다.", + orderNumber = orderNumber, + ) + ) + }.exceptionType() shouldBe ORDER_NUMBER_MISSING_EXCEPTION + } + } + + When("주문에 대한 권한이 없다면") { + val memberId = MIN_VALUE + + Then("예외를 던진다") { + shouldThrow { + paymentFacadeService.failPayment( + failPaymentCommand( + memberId = memberId, + code = PAY_PROCESS_ABORTED.name, + message = "시스템 오류로 결제가 실패했습니다.", + orderNumber = order.orderNumber.value, + ) + ) + }.exceptionType() shouldBe FORBIDDEN_ORDER + } + } + + When("존재하지 않는 주문이면") { + val orderNumber = "wrongOrderNumber" + + Then("예외를 던진다") { + shouldThrow { + paymentFacadeService.failPayment( + failPaymentCommand( + memberId = member.id, + code = PAY_PROCESS_ABORTED.name, + message = "시스템 오류로 결제가 실패했습니다.", + orderNumber = orderNumber, + ) + ) + }.exceptionType() shouldBe ORDER_NOT_FOUND + } + } + } + + afterContainer { + dataCleaner.clean() + } +}) diff --git a/src/test/kotlin/com/petqua/application/product/WishProductServiceTest.kt b/src/test/kotlin/com/petqua/application/product/WishProductServiceTest.kt index 19560022..93fb9076 100644 --- a/src/test/kotlin/com/petqua/application/product/WishProductServiceTest.kt +++ b/src/test/kotlin/com/petqua/application/product/WishProductServiceTest.kt @@ -73,7 +73,7 @@ class WishProductServiceTest( } Given("찜 상품 수정시") { - val product = productRepository.save(product()) + productRepository.save(product()) val member = memberRepository.save(member()) When("상품이 존재하지 않으면") { @@ -168,7 +168,7 @@ class WishProductServiceTest( private fun saveProducts( productRepository: ProductRepository, - store: Store + store: Store, ): Triple { val product1 = productRepository.save( product( diff --git a/src/test/kotlin/com/petqua/application/product/category/CategoryServiceTest.kt b/src/test/kotlin/com/petqua/application/product/category/CategoryServiceTest.kt index 29a4e67f..4865f57e 100644 --- a/src/test/kotlin/com/petqua/application/product/category/CategoryServiceTest.kt +++ b/src/test/kotlin/com/petqua/application/product/category/CategoryServiceTest.kt @@ -23,9 +23,9 @@ import io.kotest.assertions.assertSoftly import io.kotest.core.spec.style.BehaviorSpec import io.kotest.matchers.collections.shouldContainExactly import io.kotest.matchers.shouldBe -import java.math.BigDecimal import org.springframework.boot.test.context.SpringBootTest import org.springframework.boot.test.context.SpringBootTest.WebEnvironment.NONE +import java.math.BigDecimal @SpringBootTest(webEnvironment = NONE) class CategoryServiceTest( diff --git a/src/test/kotlin/com/petqua/application/product/review/ProductReviewServiceTest.kt b/src/test/kotlin/com/petqua/application/product/review/ProductReviewServiceTest.kt index bd8e273f..fd4412d6 100644 --- a/src/test/kotlin/com/petqua/application/product/review/ProductReviewServiceTest.kt +++ b/src/test/kotlin/com/petqua/application/product/review/ProductReviewServiceTest.kt @@ -29,9 +29,9 @@ import io.kotest.matchers.collections.shouldBeSortedWith import io.kotest.matchers.ints.shouldBeGreaterThanOrEqual import io.kotest.matchers.nulls.shouldBeNull import io.kotest.matchers.shouldBe -import java.math.BigDecimal import org.springframework.boot.test.context.SpringBootTest import org.springframework.boot.test.context.SpringBootTest.WebEnvironment.NONE +import java.math.BigDecimal @SpringBootTest(webEnvironment = NONE) class ProductReviewServiceTest( diff --git a/src/test/kotlin/com/petqua/domain/order/OrderNumberTest.kt b/src/test/kotlin/com/petqua/domain/order/OrderNumberTest.kt new file mode 100644 index 00000000..df44f385 --- /dev/null +++ b/src/test/kotlin/com/petqua/domain/order/OrderNumberTest.kt @@ -0,0 +1,27 @@ +package com.petqua.domain.order + +import com.petqua.exception.order.OrderException +import com.petqua.exception.order.OrderExceptionType.ORDER_NOT_FOUND +import io.kotest.assertions.throwables.shouldNotThrow +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe + +class OrderNumberTest : StringSpec({ + + "주문번호 생성 시 주문번호 형식에 맞는지 검증한다" { + val orderNumber = OrderNumber.generate().value + + shouldNotThrow { + OrderNumber.from(orderNumber) + } + } + + "주문번호 생성 시 주문번호 형식에 맞지 않다면 예외를 던진다" { + val invalidOrderNumber = "2024030416391INVALID" + + shouldThrow { + OrderNumber.from(invalidOrderNumber) + }.exceptionType() shouldBe ORDER_NOT_FOUND + } +}) diff --git a/src/test/kotlin/com/petqua/domain/order/OrderTest.kt b/src/test/kotlin/com/petqua/domain/order/OrderTest.kt new file mode 100644 index 00000000..0cb7c964 --- /dev/null +++ b/src/test/kotlin/com/petqua/domain/order/OrderTest.kt @@ -0,0 +1,79 @@ +package com.petqua.domain.order + +import com.petqua.domain.order.OrderStatus.CANCELED +import com.petqua.domain.order.OrderStatus.ORDER_CREATED +import com.petqua.exception.order.OrderException +import com.petqua.exception.order.OrderExceptionType.FORBIDDEN_ORDER +import com.petqua.exception.order.OrderExceptionType.ORDER_CAN_NOT_PAY +import com.petqua.exception.order.OrderExceptionType.PAYMENT_PRICE_NOT_MATCH +import com.petqua.test.fixture.order +import io.kotest.assertions.throwables.shouldNotThrow +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe +import java.math.BigDecimal.ONE +import java.math.BigDecimal.TEN +import kotlin.Long.Companion.MIN_VALUE + +class OrderTest : StringSpec({ + + "결제 금액을 검증한다" { + val order = order( + totalAmount = TEN + ) + + shouldNotThrow { + order.validateAmount(TEN) + } + } + + "결제 금액을 검증할 때 금액이 다르다면 예외를 던진다" { + val order = order( + totalAmount = TEN + ) + + shouldThrow { + order.validateAmount(ONE) + }.exceptionType() shouldBe PAYMENT_PRICE_NOT_MATCH + } + + "소유자를 검증한다" { + val order = order( + memberId = 1L + ) + + shouldNotThrow { + order.validateOwner(1L) + } + } + + "소유자를 검증할 때 회원 Id가 다르다면 예외를 던진다" { + val order = order( + memberId = 1L + ) + + shouldThrow { + order.validateOwner(MIN_VALUE) + }.exceptionType() shouldBe FORBIDDEN_ORDER + } + + "결제 처리를 한다" { + val order = order( + status = ORDER_CREATED + ) + + shouldNotThrow { + order.pay() + } + } + + "결제 처리 시 결제를 할 수 없다면 예외를 던진다" { + val order = order( + status = CANCELED + ) + + shouldThrow { + order.pay() + }.exceptionType() shouldBe ORDER_CAN_NOT_PAY + } +}) diff --git a/src/test/kotlin/com/petqua/presentation/auth/AuthControllerTest.kt b/src/test/kotlin/com/petqua/presentation/auth/AuthControllerTest.kt index d970bcf2..48788064 100644 --- a/src/test/kotlin/com/petqua/presentation/auth/AuthControllerTest.kt +++ b/src/test/kotlin/com/petqua/presentation/auth/AuthControllerTest.kt @@ -9,20 +9,17 @@ import com.petqua.domain.auth.token.RefreshToken import com.petqua.domain.auth.token.RefreshTokenRepository import com.petqua.domain.member.MemberRepository import com.petqua.test.ApiTestConfig -import com.petqua.test.config.OauthTestConfig import com.petqua.test.fixture.member import io.kotest.assertions.assertSoftly import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.shouldBe import io.mockk.verify -import org.springframework.context.annotation.Import import org.springframework.http.HttpHeaders.AUTHORIZATION import org.springframework.http.HttpHeaders.SET_COOKIE import org.springframework.http.HttpStatus.NO_CONTENT import org.springframework.http.HttpStatus.OK import java.util.Date -@Import(OauthTestConfig::class) class AuthControllerTest( private val memberRepository: MemberRepository, private val refreshTokenRepository: RefreshTokenRepository, diff --git a/src/test/kotlin/com/petqua/presentation/payment/PaymentControllerSteps.kt b/src/test/kotlin/com/petqua/presentation/payment/PaymentControllerSteps.kt new file mode 100644 index 00000000..c47eba30 --- /dev/null +++ b/src/test/kotlin/com/petqua/presentation/payment/PaymentControllerSteps.kt @@ -0,0 +1,52 @@ +package com.petqua.presentation.payment + +import io.restassured.module.kotlin.extensions.Extract +import io.restassured.module.kotlin.extensions.Given +import io.restassured.module.kotlin.extensions.Then +import io.restassured.module.kotlin.extensions.When +import io.restassured.response.Response + +fun requestSucceedPayment( + accessToken: String, + succeedPaymentRequest: SucceedPaymentRequest, +): Response { + return Given { + log().all() + auth().preemptive().oauth2(accessToken) + params( + "paymentType", succeedPaymentRequest.paymentType, + "orderId", succeedPaymentRequest.orderId, + "paymentKey", succeedPaymentRequest.paymentKey, + "amount", succeedPaymentRequest.amount + ) + } When { + post("/orders/payment/success") + } Then { + log().all() + } Extract { + response() + } +} + +fun requestFailPayment( + accessToken: String, + failPaymentRequest: FailPaymentRequest, +): Response { + val paramMap = mapOf( + "code" to failPaymentRequest.code, + "message" to failPaymentRequest.message, + "orderId" to failPaymentRequest.orderId, + ).filterValues { it != null } + + return Given { + log().all() + auth().preemptive().oauth2(accessToken) + params(paramMap) + } When { + post("/orders/payment/fail") + } Then { + log().all() + } Extract { + response() + } +} diff --git a/src/test/kotlin/com/petqua/presentation/payment/PaymentControllerTest.kt b/src/test/kotlin/com/petqua/presentation/payment/PaymentControllerTest.kt new file mode 100644 index 00000000..4bbd1220 --- /dev/null +++ b/src/test/kotlin/com/petqua/presentation/payment/PaymentControllerTest.kt @@ -0,0 +1,381 @@ +package com.petqua.presentation.payment + +import com.ninjasquad.springmockk.SpykBean +import com.petqua.application.payment.FailPaymentResponse +import com.petqua.application.payment.PaymentConfirmRequestToPG +import com.petqua.application.payment.infra.TossPaymentsApiClient +import com.petqua.common.domain.findByIdOrThrow +import com.petqua.common.exception.ExceptionResponse +import com.petqua.domain.order.OrderNumber +import com.petqua.domain.order.OrderPaymentRepository +import com.petqua.domain.order.OrderRepository +import com.petqua.domain.order.OrderStatus +import com.petqua.domain.payment.tosspayment.TossPaymentRepository +import com.petqua.exception.order.OrderExceptionType.FORBIDDEN_ORDER +import com.petqua.exception.order.OrderExceptionType.ORDER_CAN_NOT_PAY +import com.petqua.exception.order.OrderExceptionType.ORDER_NOT_FOUND +import com.petqua.exception.order.OrderExceptionType.PAYMENT_PRICE_NOT_MATCH +import com.petqua.exception.payment.FailPaymentCode.PAY_PROCESS_ABORTED +import com.petqua.exception.payment.FailPaymentCode.PAY_PROCESS_CANCELED +import com.petqua.exception.payment.FailPaymentExceptionType.INVALID_CODE +import com.petqua.exception.payment.FailPaymentExceptionType.ORDER_NUMBER_MISSING_EXCEPTION +import com.petqua.exception.payment.PaymentExceptionType.UNAUTHORIZED_KEY +import com.petqua.test.ApiTestConfig +import com.petqua.test.fixture.order +import com.petqua.test.fixture.succeedPaymentRequest +import io.kotest.assertions.assertSoftly +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.mockk.every +import org.springframework.http.HttpStatus.BAD_REQUEST +import org.springframework.http.HttpStatus.FORBIDDEN +import org.springframework.http.HttpStatus.NOT_FOUND +import org.springframework.http.HttpStatus.NO_CONTENT +import org.springframework.http.HttpStatus.OK +import org.springframework.http.HttpStatus.UNAUTHORIZED +import org.springframework.web.reactive.function.client.WebClientResponseException +import java.math.BigDecimal.ONE + +class PaymentControllerTest( + private val orderRepository: OrderRepository, + private val paymentRepository: TossPaymentRepository, + private val orderPaymentRepository: OrderPaymentRepository, + @SpykBean private val tossPaymentsApiClient: TossPaymentsApiClient, +) : ApiTestConfig() { + + init { + + Given("결제 성공을 처리할 때") { + val accessToken = signInAsMember().accessToken + val memberId = getMemberIdByAccessToken(accessToken) + + val order = orderRepository.save( + order( + memberId = memberId, + orderNumber = OrderNumber.from("202402211607020ORDERNUMBER"), + totalAmount = ONE + ) + ) + + When("유효한 요청이면") { + val response = requestSucceedPayment( + accessToken = accessToken, + succeedPaymentRequest = succeedPaymentRequest( + orderId = order.orderNumber.value, + amount = order.totalAmount + ) + ) + + Then("NO CONTENT 를 응답한다") { + response.statusCode shouldBe NO_CONTENT.value() + } + + Then("Payment 객체를 저장한다") { + val payments = paymentRepository.findAll() + + assertSoftly { + payments.size shouldBe 1 + val payment = payments[0] + + payment.orderNumber shouldBe order.orderNumber + payment.totalAmount shouldBe order.totalAmount.setScale(2) + } + } + + Then("Order의 상태를 변경한다") { + val updatedOrder = orderRepository.findByIdOrThrow(order.id) + + assertSoftly { + updatedOrder.status shouldBe OrderStatus.PAYMENT_CONFIRMED + } + } + + Then("OrderPayment 객체를 저장한다") { + val orderPayments = orderPaymentRepository.findAll() + + assertSoftly { + orderPayments.size shouldBe 1 + val payment = orderPayments[0] + + payment.orderId shouldBe order.id + payment.tossPaymentId shouldBe paymentRepository.findAll()[0].id + } + } + } + + When("존재하지 않는 주문이면") { + val orderNumber = "wrongOrderNumber" + + val response = requestSucceedPayment( + accessToken = accessToken, + succeedPaymentRequest = succeedPaymentRequest( + orderId = orderNumber, + amount = order.totalAmount + ) + ) + + Then("예외를 응답한다") { + val errorResponse = response.`as`(ExceptionResponse::class.java) + + assertSoftly(response) { + statusCode shouldBe NOT_FOUND.value() + errorResponse.message shouldBe ORDER_NOT_FOUND.errorMessage() + } + } + } + + When("결제할 수 없는 주문이면") { + val invalidOrder = orderRepository.save( + order( + memberId = memberId, + orderNumber = OrderNumber.from("202402211607021ORDERNUMBER"), + totalAmount = ONE, + status = OrderStatus.PAYMENT_CONFIRMED, + ) + ) + + val response = requestSucceedPayment( + accessToken = accessToken, + succeedPaymentRequest = succeedPaymentRequest( + orderId = invalidOrder.orderNumber.value, + amount = invalidOrder.totalAmount + ) + ) + + Then("예외를 응답한다") { + val errorResponse = response.`as`(ExceptionResponse::class.java) + + assertSoftly(response) { + statusCode shouldBe BAD_REQUEST.value() + errorResponse.message shouldBe ORDER_CAN_NOT_PAY.errorMessage() + } + } + } + + When("권한이 없는 회원의 요청이면") { + val otherAccessToken = signInAsMember().accessToken + + val response = requestSucceedPayment( + accessToken = otherAccessToken, + succeedPaymentRequest = succeedPaymentRequest( + orderId = order.orderNumber.value, + amount = order.totalAmount + ) + ) + + Then("예외를 응답한다") { + val errorResponse = response.`as`(ExceptionResponse::class.java) + + assertSoftly(response) { + statusCode shouldBe FORBIDDEN.value() + errorResponse.message shouldBe FORBIDDEN_ORDER.errorMessage() + } + } + } + + When("주문의 총가격과 결제 금액이 다르면") { + val wrongAmount = order.totalAmount + ONE + + val response = requestSucceedPayment( + accessToken = accessToken, + succeedPaymentRequest = succeedPaymentRequest( + orderId = order.orderNumber.value, + amount = wrongAmount + ) + ) + + Then("예외를 응답한다") { + val errorResponse = response.`as`(ExceptionResponse::class.java) + + assertSoftly(response) { + statusCode shouldBe BAD_REQUEST.value() + errorResponse.message shouldBe PAYMENT_PRICE_NOT_MATCH.errorMessage() + } + } + } + + When("PG사와 통신할 때 예외가 발생하면") { + every { + tossPaymentsApiClient.confirmPayment(any(String::class), any(PaymentConfirmRequestToPG::class)) + } throws WebClientResponseException( + 404, + "UNAUTHORIZED", + null, + "{\"code\":\"UNAUTHORIZED_KEY\",\"message\":\"인증되지 않은 시크릿 키 혹은 클라이언트 키 입니다.\",\"data\":null}".toByteArray(), + null + ) + + val response = requestSucceedPayment( + accessToken = accessToken, + succeedPaymentRequest = succeedPaymentRequest( + orderId = order.orderNumber.value, + amount = order.totalAmount + ) + ) + + Then("예외를 응답한다") { + val errorResponse = response.`as`(ExceptionResponse::class.java) + + assertSoftly(response) { + statusCode shouldBe UNAUTHORIZED.value() + errorResponse.message shouldBe UNAUTHORIZED_KEY.errorMessage() + } + } + } + } + + Given("사용자가 취소한 결제 실패를 처리할 때") { + val accessToken = signInAsMember().accessToken + val memberId = getMemberIdByAccessToken(accessToken) + + orderRepository.save( + order( + memberId = memberId, + orderNumber = OrderNumber.from("202402211607020ORDERNUMBER"), + totalAmount = ONE + ) + ) + + When("유효한 실패 내역이 입력되면") { + val response = requestFailPayment( + accessToken = accessToken, + failPaymentRequest = FailPaymentRequest( + code = PAY_PROCESS_CANCELED.name, + message = "사용자가 결제를 취소했습니다.", + orderId = null + ) + ) + + Then("해당 내용을 응답한다") { + val failPaymentResponse = response.`as`(FailPaymentResponse::class.java) + + assertSoftly(failPaymentResponse) { + response.statusCode shouldBe OK.value() + code shouldBe PAY_PROCESS_CANCELED + message shouldNotBe null + } + } + } + + When("유효하지 않은 실패 코드가 입력되면") { + val response = requestFailPayment( + accessToken = accessToken, + failPaymentRequest = FailPaymentRequest( + code = "INVALID_CODE", + message = "사용자가 결제를 취소했습니다.", + orderId = null + ) + ) + + Then("예외를 응답한다") { + val exceptionResponse = response.`as`(ExceptionResponse::class.java) + + assertSoftly(exceptionResponse) { + response.statusCode shouldBe BAD_REQUEST.value() + exceptionResponse.message shouldBe INVALID_CODE.errorMessage() + } + } + } + } + + Given("사용자가 취소하지 않은 결제 실패를 처리할 때") { + val accessToken = signInAsMember().accessToken + val memberId = getMemberIdByAccessToken(accessToken) + + val order = orderRepository.save( + order( + memberId = memberId, + orderNumber = OrderNumber.from("202402211607020ORDERNUMBER"), + totalAmount = ONE + ) + ) + + When("유효한 실패 내역이 입력되면") { + val response = requestFailPayment( + accessToken = accessToken, + failPaymentRequest = FailPaymentRequest( + code = PAY_PROCESS_ABORTED.name, + message = "시스템 오류로 결제가 실패했습니다.", + orderId = order.orderNumber.value, + ) + ) + + Then("해당 내용을 응답한다") { + val failPaymentResponse = response.`as`(FailPaymentResponse::class.java) + + assertSoftly(failPaymentResponse) { + response.statusCode shouldBe OK.value() + code shouldBe PAY_PROCESS_ABORTED + message shouldNotBe null + } + } + } + + When("주문번호가 입력되지 않으면") { + val orderId = null + + val response = requestFailPayment( + accessToken = accessToken, + failPaymentRequest = FailPaymentRequest( + code = PAY_PROCESS_ABORTED.name, + message = "시스템 오류로 결제가 실패했습니다.", + orderId = orderId, + ) + ) + + Then("예외를 응답한다") { + val exceptionResponse = response.`as`(ExceptionResponse::class.java) + + assertSoftly(exceptionResponse) { + response.statusCode shouldBe BAD_REQUEST.value() + exceptionResponse.message shouldBe ORDER_NUMBER_MISSING_EXCEPTION.errorMessage() + } + } + } + + When("주문에 대한 권한이 없다면") { + val otherAccessToken = signInAsMember().accessToken + + val response = requestFailPayment( + accessToken = otherAccessToken, + failPaymentRequest = FailPaymentRequest( + code = PAY_PROCESS_ABORTED.name, + message = "시스템 오류로 결제가 실패했습니다.", + orderId = order.orderNumber.value, + ) + ) + + Then("예외를 응답한다") { + val exceptionResponse = response.`as`(ExceptionResponse::class.java) + + assertSoftly(exceptionResponse) { + response.statusCode shouldBe FORBIDDEN.value() + exceptionResponse.message shouldBe FORBIDDEN_ORDER.errorMessage() + } + } + } + + When("존재하지 않는 주문이면") { + val orderId = "wrongOrderId" + + val response = requestFailPayment( + accessToken = accessToken, + failPaymentRequest = FailPaymentRequest( + code = PAY_PROCESS_ABORTED.name, + message = "시스템 오류로 결제가 실패했습니다.", + orderId = orderId, + ) + ) + + Then("예외를 응답한다") { + val exceptionResponse = response.`as`(ExceptionResponse::class.java) + + assertSoftly(exceptionResponse) { + response.statusCode shouldBe NOT_FOUND.value() + exceptionResponse.message shouldBe ORDER_NOT_FOUND.errorMessage() + } + } + } + } + } +} diff --git a/src/test/kotlin/com/petqua/presentation/product/ProductControllerSteps.kt b/src/test/kotlin/com/petqua/presentation/product/ProductControllerSteps.kt index b551966a..08d2e57c 100644 --- a/src/test/kotlin/com/petqua/presentation/product/ProductControllerSteps.kt +++ b/src/test/kotlin/com/petqua/presentation/product/ProductControllerSteps.kt @@ -14,7 +14,7 @@ import io.restassured.response.Response fun requestReadProductById( productId: Long, - accessToken: String? = null + accessToken: String? = null, ): Response { return Given { log().all() @@ -59,10 +59,10 @@ fun requestReadProductKeyword( limit: Int = PAGING_LIMIT_CEILING, ): Response { return Given { - val paramMap = mutableMapOf().apply { - put("word", word) - put("limit", limit) - }.filterValues { it != null } + val paramMap = mapOf( + "word" to word, + "limit" to limit, + ).filterValues { it != null } log().all() params(paramMap) @@ -81,15 +81,15 @@ fun requestReadProductBySearch( sorter: String = Sorter.NONE.name, lastViewedId: Long = DEFAULT_LAST_VIEWED_ID, limit: Int = PAGING_LIMIT_CEILING, - accessToken: String? = null + accessToken: String? = null, ): Response { - val paramMap = mutableMapOf().apply { - put("word", word) - put("deliveryMethod", deliveryMethod.name) - put("sorter", sorter) - put("lastViewedId", lastViewedId) - put("limit", limit) - }.filterValues { it != null } + val paramMap = mapOf( + "word" to word, + "deliveryMethod" to deliveryMethod.name, + "sorter" to sorter, + "lastViewedId" to lastViewedId, + "limit" to limit, + ).filterValues { it != null } return Given { log().all() diff --git a/src/test/kotlin/com/petqua/presentation/product/category/CategoryControllerSteps.kt b/src/test/kotlin/com/petqua/presentation/product/category/CategoryControllerSteps.kt index 483d3f01..c49c70db 100644 --- a/src/test/kotlin/com/petqua/presentation/product/category/CategoryControllerSteps.kt +++ b/src/test/kotlin/com/petqua/presentation/product/category/CategoryControllerSteps.kt @@ -12,9 +12,9 @@ import io.restassured.response.Response fun requestReadSpecies( family: String? = null, ): Response { - val paramMap = mutableMapOf().apply { - put("family", family) - }.filterValues { it != null } + val paramMap = mapOf( + "family" to family + ).filterValues { it != null } return Given { log().all() @@ -37,14 +37,14 @@ fun requestReadProducts( limit: Int? = null, accessToken: String? = null, ): Response { - val paramMap = mutableMapOf().apply { - put("family", family) - put("species", species.takeIf { it.isNotEmpty() }) - put("deliveryMethod", deliveryMethod.name) - put("sorter", sorter) - put("lastViewedId", lastViewedId) - put("limit", limit) - }.filterValues { it != null } + val paramMap = mapOf( + "family" to family, + "species" to species.takeIf { it.isNotEmpty() }, + "deliveryMethod" to deliveryMethod.name, + "sorter" to sorter, + "lastViewedId" to lastViewedId, + "limit" to limit, + ).filterValues { it != null } return Given { log().all() diff --git a/src/test/kotlin/com/petqua/test/ApiTestConfig.kt b/src/test/kotlin/com/petqua/test/ApiTestConfig.kt index e27e40b7..1e4cab63 100644 --- a/src/test/kotlin/com/petqua/test/ApiTestConfig.kt +++ b/src/test/kotlin/com/petqua/test/ApiTestConfig.kt @@ -1,7 +1,7 @@ package com.petqua.test import com.petqua.presentation.auth.AuthExtractor -import com.petqua.test.config.OauthTestConfig +import com.petqua.test.config.ApiClientTestConfig import io.kotest.core.spec.style.BehaviorSpec import io.restassured.RestAssured import io.restassured.module.kotlin.extensions.Extract @@ -20,7 +20,7 @@ data class AuthResponse( val accessToken: String, ) -@Import(OauthTestConfig::class) +@Import(ApiClientTestConfig::class) @SpringBootTest(webEnvironment = RANDOM_PORT) abstract class ApiTestConfig() : BehaviorSpec() { diff --git a/src/test/kotlin/com/petqua/test/config/ApiClientTestConfig.kt b/src/test/kotlin/com/petqua/test/config/ApiClientTestConfig.kt new file mode 100644 index 00000000..94f53bed --- /dev/null +++ b/src/test/kotlin/com/petqua/test/config/ApiClientTestConfig.kt @@ -0,0 +1,24 @@ +package com.petqua.test.config + +import com.petqua.application.payment.infra.TossPaymentsApiClient +import com.petqua.domain.auth.oauth.kakao.KakaoOauthApiClient +import com.petqua.test.fake.FakeKakaoOauthApiClient +import com.petqua.test.fake.FakeTossPaymentsApiClient +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Profile + +@Configuration +@Profile("test") +class ApiClientTestConfig { + + @Bean + fun kakaoOauthApiClient(): KakaoOauthApiClient { + return FakeKakaoOauthApiClient() + } + + @Bean + fun tossPaymentsApiClient(): TossPaymentsApiClient { + return FakeTossPaymentsApiClient() + } +} diff --git a/src/test/kotlin/com/petqua/test/config/OauthTestConfig.kt b/src/test/kotlin/com/petqua/test/config/OauthTestConfig.kt deleted file mode 100644 index 7b79d216..00000000 --- a/src/test/kotlin/com/petqua/test/config/OauthTestConfig.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.petqua.test.config - -import com.petqua.domain.auth.oauth.kakao.KakaoOauthApiClient -import com.petqua.test.fake.FakeKakaoOauthApiClient -import org.springframework.boot.test.context.TestConfiguration -import org.springframework.context.annotation.Bean - -@TestConfiguration -class OauthTestConfig { - - @Bean - fun kakaoOauthApiClient(): KakaoOauthApiClient { - return FakeKakaoOauthApiClient() - } -} diff --git a/src/test/kotlin/com/petqua/test/fake/FakeTossPaymentsApiClient.kt b/src/test/kotlin/com/petqua/test/fake/FakeTossPaymentsApiClient.kt new file mode 100644 index 00000000..8f74de25 --- /dev/null +++ b/src/test/kotlin/com/petqua/test/fake/FakeTossPaymentsApiClient.kt @@ -0,0 +1,70 @@ +package com.petqua.test.fake + +import com.petqua.application.payment.CardResponseFromPG +import com.petqua.application.payment.PaymentConfirmRequestToPG +import com.petqua.application.payment.PaymentResponseFromPG +import com.petqua.application.payment.infra.TossPaymentsApiClient +import com.petqua.domain.payment.tosspayment.TossPaymentMethod +import com.petqua.domain.payment.tosspayment.TossPaymentStatus +import com.petqua.domain.payment.tosspayment.TossPaymentType +import java.math.BigDecimal + +class FakeTossPaymentsApiClient : TossPaymentsApiClient { + + override fun confirmPayment( + credentials: String, + paymentConfirmRequestToPG: PaymentConfirmRequestToPG, + ): PaymentResponseFromPG { + return PaymentResponseFromPG( + version = "version", + paymentKey = paymentConfirmRequestToPG.paymentKey, + type = TossPaymentType.NORMAL.name, + orderId = paymentConfirmRequestToPG.orderNumber.value, + orderName = "orderName", + mid = "mid", + currency = "currency", + method = TossPaymentMethod.CREDIT_CARD.name, + totalAmount = paymentConfirmRequestToPG.amount, + balanceAmount = paymentConfirmRequestToPG.amount, + status = TossPaymentStatus.DONE.name, + requestedAt = "2022-01-01T00:00:00+09:00", + approvedAt = "2022-01-01T00:00:00+09:00", + useEscrow = false, + lastTransactionKey = null, + suppliedAmount = paymentConfirmRequestToPG.amount, + vat = BigDecimal.ZERO, + cultureExpense = false, + taxFreeAmount = BigDecimal.ZERO, + taxExemptionAmount = BigDecimal.ZERO, + cancels = null, + isPartialCancelable = true, + card = CardResponseFromPG( + amount = paymentConfirmRequestToPG.amount, + issuerCode = "issuerCode", + acquirerCode = null, + number = "card_number", + installmentPlanMonths = 0, + approveNo = "approveNo", + useCardPoint = false, + cardType = "신용", + ownerType = "개인", + acquireStatus = "READY", + isInterestFree = false, + interestPayer = null, + ), + virtualAccount = null, + secret = null, + mobilePhone = null, + giftCertificate = null, + transfer = null, + receipt = null, + checkout = null, + easyPay = null, + country = "KR", + failure = null, + cashReceipt = null, + cashReceipts = null, + discount = null, + ) + } +} diff --git a/src/test/kotlin/com/petqua/test/fixture/OrderFixture.kt b/src/test/kotlin/com/petqua/test/fixture/OrderFixture.kt index 432f9e2b..59be3de4 100644 --- a/src/test/kotlin/com/petqua/test/fixture/OrderFixture.kt +++ b/src/test/kotlin/com/petqua/test/fixture/OrderFixture.kt @@ -5,12 +5,138 @@ import com.petqua.application.order.dto.SaveOrderCommand import com.petqua.common.util.setDefaultScale import com.petqua.domain.delivery.DeliveryMethod import com.petqua.domain.delivery.DeliveryMethod.COMMON +import com.petqua.domain.delivery.DeliveryMethod.SAFETY +import com.petqua.domain.order.Order +import com.petqua.domain.order.OrderName +import com.petqua.domain.order.OrderNumber +import com.petqua.domain.order.OrderProduct +import com.petqua.domain.order.OrderShippingAddress +import com.petqua.domain.order.OrderStatus +import com.petqua.domain.order.ShippingNumber import com.petqua.domain.product.option.Sex import com.petqua.domain.product.option.Sex.FEMALE import java.math.BigDecimal import java.math.BigDecimal.ONE import java.math.BigDecimal.ZERO +private const val DEFAULT_SCALE = 2 + +fun order( + id: Long = 0L, + memberId: Long = 0L, + orderNumber: OrderNumber = OrderNumber.from("202402211607020ORDERNUMBER"), + orderName: OrderName = OrderName("상품1"), + receiver: String = "receiver", + phoneNumber: String = "010-1234-5678", + zipCode: Int = 12345, + address: String = "서울시 강남구 역삼동 99번길", + detailAddress: String = "101동 101호", + requestMessage: String? = null, + quantity: Int = 1, + originalPrice: BigDecimal = ONE, + discountRate: Int = 0, + discountPrice: BigDecimal = originalPrice, + deliveryFee: BigDecimal = 3000.toBigDecimal(), + shippingNumber: ShippingNumber = ShippingNumber(""), + orderPrice: BigDecimal = discountPrice, + productId: Long = 0L, + productName: String = "orderProduct", + thumbnailUrl: String = "image.url", + storeId: Long = 0L, + storeName: String = "storeName", + deliveryMethod: DeliveryMethod = SAFETY, + sex: Sex = FEMALE, + isAbleToCancel: Boolean = true, + status: OrderStatus = OrderStatus.ORDER_CREATED, + totalAmount: BigDecimal = orderPrice + deliveryFee, +): Order { + return Order( + id = id, + memberId = memberId, + orderNumber = orderNumber, + orderName = orderName, + orderShippingAddress = orderShippingAddress( + receiver = receiver, + phoneNumber = phoneNumber, + zipCode = zipCode, + address = address, + detailAddress = detailAddress, + requestMessage = requestMessage, + ), + orderProduct = orderProduct( + quantity = quantity, + originalPrice = originalPrice, + discountRate = discountRate, + discountPrice = discountPrice, + deliveryFee = deliveryFee, + shippingNumber = shippingNumber, + orderPrice = orderPrice, + productId = productId, + productName = productName, + thumbnailUrl = thumbnailUrl, + storeId = storeId, + storeName = storeName, + deliveryMethod = deliveryMethod, + sex = sex, + ), + isAbleToCancel = isAbleToCancel, + status = status, + totalAmount = totalAmount, + ) +} + +fun orderShippingAddress( + receiver: String = "receiver", + phoneNumber: String = "010-1234-5678", + zipCode: Int = 12345, + address: String = "서울시 강남구 역삼동 99번길", + detailAddress: String = "101동 101호", + requestMessage: String?, +): OrderShippingAddress { + return OrderShippingAddress( + receiver = receiver, + phoneNumber = phoneNumber, + zipCode = zipCode, + address = address, + detailAddress = detailAddress, + requestMessage = requestMessage, + ) +} + +fun orderProduct( + quantity: Int = 1, + originalPrice: BigDecimal = ONE, + discountRate: Int = 0, + discountPrice: BigDecimal = originalPrice, + deliveryFee: BigDecimal = 3000.toBigDecimal(), + shippingNumber: ShippingNumber = ShippingNumber(""), + orderPrice: BigDecimal = discountPrice, + productId: Long = 0L, + productName: String = "orderProduct", + thumbnailUrl: String = "image.url", + storeId: Long = 0L, + storeName: String = "storeName", + deliveryMethod: DeliveryMethod = SAFETY, + sex: Sex = FEMALE, +): OrderProduct { + return OrderProduct( + quantity = quantity, + originalPrice = originalPrice, + discountRate = discountRate, + discountPrice = discountPrice, + deliveryFee = deliveryFee, + shippingNumber = shippingNumber, + orderPrice = orderPrice, + productId = productId, + productName = productName, + thumbnailUrl = thumbnailUrl, + storeId = storeId, + storeName = storeName, + deliveryMethod = deliveryMethod, + sex = sex, + ) +} + fun saveOrderCommand( memberId: Long = 0L, shippingAddressId: Long = 0L, diff --git a/src/test/kotlin/com/petqua/test/fixture/PaymentFixtures.kt b/src/test/kotlin/com/petqua/test/fixture/PaymentFixtures.kt new file mode 100644 index 00000000..53ecbca4 --- /dev/null +++ b/src/test/kotlin/com/petqua/test/fixture/PaymentFixtures.kt @@ -0,0 +1,55 @@ +package com.petqua.test.fixture + +import com.petqua.application.payment.FailPaymentCommand +import com.petqua.application.payment.SucceedPaymentCommand +import com.petqua.domain.order.OrderNumber +import com.petqua.domain.payment.tosspayment.TossPaymentType +import com.petqua.domain.payment.tosspayment.TossPaymentType.NORMAL +import com.petqua.presentation.payment.SucceedPaymentRequest +import java.math.BigDecimal +import java.math.BigDecimal.ONE +import java.math.BigDecimal.ZERO + +fun succeedPaymentCommand( + memberId: Long = 0L, + paymentType: TossPaymentType = NORMAL, + orderNumber: OrderNumber = OrderNumber.from("202402211607020ORDERNUMBER"), + paymentKey: String = "paymentKey", + amount: BigDecimal = ZERO, +): SucceedPaymentCommand { + return SucceedPaymentCommand( + memberId = memberId, + paymentType = paymentType, + orderNumber = orderNumber, + paymentKey = paymentKey, + amount = amount, + ) +} + +fun succeedPaymentRequest( + paymentType: String = NORMAL.name, + orderId: String = "orderId", + paymentKey: String = "paymentKey", + amount: BigDecimal = ONE, +): SucceedPaymentRequest { + return SucceedPaymentRequest( + paymentType = paymentType, + orderId = orderId, + paymentKey = paymentKey, + amount = amount, + ) +} + +fun failPaymentCommand( + memberId: Long, + code: String, + message: String, + orderNumber: String?, +): FailPaymentCommand { + return FailPaymentCommand.of( + memberId = memberId, + code = code, + message = message, + orderId = orderNumber, + ) +} diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 54beb9e2..351df777 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -34,3 +34,9 @@ logging: org.hibernate.orm.jdbc.bind: TRACE org.apache.coyote.http11: debug root: info + +payment: + toss-payments: + secret-key: test-secret-key # for test + success-url: ${base-url}/orders/payment/success + fail-url: ${base-url}/orders/payment/fail