package roomescape.payment import com.ninjasquad.springmockk.MockkBean import io.kotest.matchers.shouldBe import io.mockk.every import org.springframework.data.repository.findByIdOrNull import org.springframework.http.HttpMethod import org.springframework.http.HttpStatus import roomescape.auth.exception.AuthErrorCode import roomescape.payment.business.PaymentService import roomescape.payment.exception.PaymentErrorCode import roomescape.payment.infrastructure.client.CardDetail import roomescape.payment.infrastructure.client.EasyPayDetail import roomescape.payment.infrastructure.client.TosspayClient import roomescape.payment.infrastructure.client.TransferDetail import roomescape.payment.infrastructure.common.* import roomescape.payment.infrastructure.persistence.* import roomescape.payment.web.PaymentConfirmRequest import roomescape.payment.web.PaymentCreateResponse import roomescape.reservation.infrastructure.persistence.ReservationEntity import roomescape.supports.* class PaymentAPITest( @MockkBean private val tosspayClient: TosspayClient, private val paymentService: PaymentService, private val paymentRepository: PaymentRepository, private val paymentDetailRepository: PaymentDetailRepository, private val canceledPaymentRepository: CanceledPaymentRepository ) : FunSpecSpringbootTest() { init { context("결제를 승인한다.") { context("권한이 없으면 접근할 수 없다.") { val endpoint = "/payments?reservationId=$INVALID_PK" test("비회원") { runExceptionTest( method = HttpMethod.POST, endpoint = endpoint, expectedErrorCode = AuthErrorCode.TOKEN_NOT_FOUND ) } test("관리자") { runExceptionTest( token = authUtil.defaultAdminLogin(), method = HttpMethod.POST, endpoint = endpoint, expectedErrorCode = AuthErrorCode.ACCESS_DENIED ) } } val amount = 100_000 context("간편결제 + 카드로 ${amount}원을 결제한다.") { context("일시불") { test("토스페이 + 토스뱅크카드(신용)") { runConfirmTest( amount = amount, cardDetail = PaymentFixture.cardDetail( amount = amount, issuerCode = CardIssuerCode.TOSS_BANK, cardType = CardType.CREDIT, ), easyPayDetail = PaymentFixture.easypayDetail( amount = 0, provider = EasyPayCompanyCode.TOSSPAY ) ) } test("삼성페이 + 삼성카드(법인)") { runConfirmTest( amount = amount, cardDetail = PaymentFixture.cardDetail( amount = amount, issuerCode = CardIssuerCode.SAMSUNG, cardType = CardType.CREDIT, ownerType = CardOwnerType.CORPORATE ), easyPayDetail = PaymentFixture.easypayDetail( amount = 0, provider = EasyPayCompanyCode.SAMSUNGPAY ) ) } } context("할부") { val installmentPlanMonths = 12 test("네이버페이 + 신한카드 / 12개월") { runConfirmTest( amount = amount, cardDetail = PaymentFixture.cardDetail( amount = amount, issuerCode = CardIssuerCode.SHINHAN, installmentPlanMonths = installmentPlanMonths ), easyPayDetail = PaymentFixture.easypayDetail( amount = 0, provider = EasyPayCompanyCode.NAVERPAY ) ) } } context("간편결제사 포인트 일부 사용") { val point = (amount * 0.1).toInt() test("토스페이 + 국민카드(체크) / 일시불 / $point 포인트 사용") { runConfirmTest( amount = amount, cardDetail = PaymentFixture.cardDetail( amount = (amount - point), issuerCode = CardIssuerCode.KOOKMIN, cardType = CardType.CHECK ), easyPayDetail = PaymentFixture.easypayDetail( amount = 0, provider = EasyPayCompanyCode.TOSSPAY, discountAmount = point ) ) } } } context("간편결제사의 선불 충전금액으로 ${amount}원을 결제한다.") { test("토스페이 + 토스페이머니 / 전액") { runConfirmTest( easyPayDetail = PaymentFixture.easypayDetail( amount = amount, provider = EasyPayCompanyCode.TOSSPAY ) ) } val point = (amount * 0.05).toInt() test("카카오페이 + 카카오페이머니 / $point 사용") { runConfirmTest( easyPayDetail = PaymentFixture.easypayDetail( amount = (amount - point), provider = EasyPayCompanyCode.KAKAOPAY, discountAmount = point ) ) } } context("계좌이체로 결제한다.") { test("토스뱅크") { runConfirmTest( transferDetail = PaymentFixture.transferDetail(bankCode = BankCode.TOSS_BANK) ) } } context("지원하지 않는 결제수단으로 요청시 실패한다.") { val supportedMethod = listOf( PaymentMethod.CARD, PaymentMethod.EASY_PAY, PaymentMethod.TRANSFER, ) PaymentMethod.entries.filter { it !in supportedMethod }.forEach { test("결제 수단: ${it.koreanName}") { val reservation = dummyInitializer.createConfirmReservation( adminToken = authUtil.defaultAdminLogin(), reserverToken = authUtil.defaultUserLogin() ) val request = PaymentFixture.confirmRequest every { tosspayClient.confirm(request.paymentKey, request.orderId, request.amount) } returns PaymentFixture.confirmResponse( paymentKey = request.paymentKey, amount = request.amount, method = it, cardDetail = null, easyPayDetail = null, transferDetail = null, ) runExceptionTest( token = authUtil.defaultUserLogin(), method = HttpMethod.POST, endpoint = "/payments?reservationId=${reservation.id}", requestBody = PaymentFixture.confirmRequest, expectedErrorCode = PaymentErrorCode.NOT_SUPPORTED_PAYMENT_TYPE ) } } } } context("결제를 취소한다.") { context("권한이 없으면 접근할 수 없다.") { val endpoint = "/payments/cancel" test("비회원") { runExceptionTest( method = HttpMethod.POST, endpoint = endpoint, requestBody = PaymentFixture.cancelRequest, expectedErrorCode = AuthErrorCode.TOKEN_NOT_FOUND ) } test("관리자") { runExceptionTest( token = authUtil.defaultAdminLogin(), method = HttpMethod.POST, endpoint = endpoint, requestBody = PaymentFixture.cancelRequest, expectedErrorCode = AuthErrorCode.ACCESS_DENIED ) } } test("정상 취소") { val userToken = authUtil.defaultUserLogin() val confirmRequest = PaymentFixture.confirmRequest val reservation = dummyInitializer.createConfirmReservation( adminToken = authUtil.defaultAdminLogin(), reserverToken = userToken ) val paymentCreateResponse = createPayment( request = confirmRequest, reservationId = reservation.id ) every { tosspayClient.cancel( confirmRequest.paymentKey, confirmRequest.amount, cancelReason = "cancelReason" ) } returns PaymentFixture.cancelResponse(confirmRequest.amount) val requestBody = PaymentFixture.cancelRequest.copy(reservationId = reservation.id) runTest( token = userToken, using = { body(requestBody) }, on = { post("/payments/cancel") }, expect = { statusCode(HttpStatus.OK.value()) } ).also { val payment = paymentRepository.findByIdOrNull(paymentCreateResponse.paymentId) ?: throw AssertionError("Unexpected Exception Occurred.") val canceledPayment = canceledPaymentRepository.findByPaymentId(paymentCreateResponse.paymentId) ?: throw AssertionError("Unexpected Exception Occurred.") payment.status shouldBe PaymentStatus.CANCELED canceledPayment.paymentId shouldBe payment.id canceledPayment.cancelAmount shouldBe payment.totalAmount } } test("예약에 대한 결제 정보가 없으면 실패한다.") { val userToken = authUtil.defaultUserLogin() val reservation = dummyInitializer.createConfirmReservation( adminToken = authUtil.defaultAdminLogin(), reserverToken = userToken, ) runExceptionTest( token = userToken, method = HttpMethod.POST, endpoint = "/payments/cancel", requestBody = PaymentFixture.cancelRequest.copy(reservationId = reservation.id), expectedErrorCode = PaymentErrorCode.PAYMENT_NOT_FOUND ) } } } private fun createPayment( request: PaymentConfirmRequest, reservationId: Long, ): PaymentCreateResponse { every { tosspayClient.confirm(request.paymentKey, request.orderId, request.amount) } returns PaymentFixture.confirmResponse( request.paymentKey, request.amount, method = PaymentMethod.CARD, cardDetail = PaymentFixture.cardDetail(request.amount), easyPayDetail = null, transferDetail = null, ) return paymentService.confirm(reservationId, request) } fun runConfirmTest( cardDetail: CardDetail? = null, easyPayDetail: EasyPayDetail? = null, transferDetail: TransferDetail? = null, paymentKey: String = "paymentKey", amount: Int = 10000, ) { val request = PaymentFixture.confirmRequest.copy(paymentKey = paymentKey, amount = amount) val reservation: ReservationEntity = dummyInitializer.createPendingReservation( adminToken = authUtil.defaultAdminLogin(), reserverToken = authUtil.defaultUserLogin(), ) val method = if (easyPayDetail != null) { PaymentMethod.EASY_PAY } else if (cardDetail != null) { PaymentMethod.CARD } else if (transferDetail != null) { PaymentMethod.TRANSFER } else { throw AssertionError("결제타입 확인 필요.") } val clientResponse = PaymentFixture.confirmResponse( paymentKey, amount, method, cardDetail, easyPayDetail, transferDetail ) every { tosspayClient.confirm(request.paymentKey, request.orderId, request.amount) } returns clientResponse runTest( token = authUtil.defaultUserLogin(), using = { body(request) }, on = { post("/payments?reservationId=${reservation.id}") }, expect = { statusCode(HttpStatus.OK.value()) } ).also { val createdPayment = paymentRepository.findByIdOrNull(it.extract().path("data.paymentId")) ?: throw AssertionError("Unexpected Exception Occurred.") val createdPaymentDetail = paymentDetailRepository.findByIdOrNull(it.extract().path("data.detailId")) ?: throw AssertionError("Unexpected Exception Occurred.") createdPayment.status shouldBe clientResponse.status createdPayment.method shouldBe clientResponse.method createdPayment.reservationId shouldBe reservation.id when (createdPaymentDetail) { is PaymentCardDetailEntity -> { createdPaymentDetail.issuerCode shouldBe clientResponse.card!!.issuerCode createdPaymentDetail.cardType shouldBe clientResponse.card.cardType createdPaymentDetail.cardNumber shouldBe clientResponse.card.number createdPaymentDetail.suppliedAmount shouldBe clientResponse.suppliedAmount createdPaymentDetail.vat shouldBe clientResponse.vat createdPaymentDetail.amount shouldBe (clientResponse.totalAmount - (clientResponse.easyPay?.discountAmount ?: 0)) clientResponse.easyPay?.let { easypay -> createdPaymentDetail.easypayProviderCode shouldBe easypay.provider createdPaymentDetail.easypayDiscountAmount shouldBe easypay.discountAmount } } is PaymentBankTransferDetailEntity -> { createdPaymentDetail.suppliedAmount shouldBe clientResponse.suppliedAmount createdPaymentDetail.vat shouldBe clientResponse.vat createdPaymentDetail.bankCode shouldBe clientResponse.transfer!!.bankCode createdPaymentDetail.settlementStatus shouldBe clientResponse.transfer.settlementStatus } is PaymentEasypayPrepaidDetailEntity -> { createdPaymentDetail.suppliedAmount shouldBe clientResponse.suppliedAmount createdPaymentDetail.vat shouldBe clientResponse.vat createdPaymentDetail.easypayProviderCode shouldBe clientResponse.easyPay!!.provider createdPaymentDetail.amount shouldBe clientResponse.easyPay.amount createdPaymentDetail.discountAmount shouldBe clientResponse.easyPay.discountAmount } } } } }