diff --git a/src/main/java/roomescape/common/exception/ErrorType.kt b/src/main/java/roomescape/common/exception/ErrorType.kt index abc8e03d..8d19efe4 100644 --- a/src/main/java/roomescape/common/exception/ErrorType.kt +++ b/src/main/java/roomescape/common/exception/ErrorType.kt @@ -32,7 +32,7 @@ enum class ErrorType( RESERVATION_NOT_FOUND("예약(Reservation) 정보가 존재하지 않습니다."), RESERVATION_TIME_NOT_FOUND("예약 시간(ReservationTime) 정보가 존재하지 않습니다."), THEME_NOT_FOUND("테마(Theme) 정보가 존재하지 않습니다."), - PAYMENT_NOT_POUND("결제(Payment) 정보가 존재하지 않습니다."), + PAYMENT_NOT_FOUND("결제(Payment) 정보가 존재하지 않습니다."), // 405 Method Not Allowed METHOD_NOT_ALLOWED("지원하지 않는 HTTP Method 입니다."), @@ -52,7 +52,8 @@ enum class ErrorType( // Payment Error PAYMENT_ERROR("결제(취소)에 실패했습니다. 결제(취소) 정보를 확인해주세요."), - PAYMENT_SERVER_ERROR("결제 서버에서 에러가 발생하였습니다. 잠시 후 다시 시도해주세요."); + PAYMENT_SERVER_ERROR("결제 서버에서 에러가 발생하였습니다. 잠시 후 다시 시도해주세요.") + ; companion object { @JvmStatic diff --git a/src/main/java/roomescape/payment/PaymentConfig.java b/src/main/java/roomescape/payment/PaymentConfig.java deleted file mode 100644 index bed158a8..00000000 --- a/src/main/java/roomescape/payment/PaymentConfig.java +++ /dev/null @@ -1,38 +0,0 @@ -package roomescape.payment; - -import java.nio.charset.StandardCharsets; -import java.time.Duration; -import java.util.Base64; - -import org.springframework.boot.context.properties.EnableConfigurationProperties; -import org.springframework.boot.web.client.ClientHttpRequestFactories; -import org.springframework.boot.web.client.ClientHttpRequestFactorySettings; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.http.client.ClientHttpRequestFactory; -import org.springframework.web.client.RestClient; - -import roomescape.payment.client.PaymentProperties; - -@Configuration -@EnableConfigurationProperties(PaymentProperties.class) -public class PaymentConfig { - - @Bean - public RestClient.Builder restClientBuilder(PaymentProperties paymentProperties) { - ClientHttpRequestFactorySettings settings = ClientHttpRequestFactorySettings.DEFAULTS - .withReadTimeout(Duration.ofSeconds(paymentProperties.getReadTimeout())) - .withConnectTimeout(Duration.ofSeconds(paymentProperties.getConnectTimeout())); - ClientHttpRequestFactory requestFactory = ClientHttpRequestFactories.get(settings); - - return RestClient.builder().baseUrl("https://api.tosspayments.com") - .defaultHeader("Authorization", getAuthorizations(paymentProperties.getConfirmSecretKey())) - .requestFactory(requestFactory); - } - - private String getAuthorizations(String secretKey) { - Base64.Encoder encoder = Base64.getEncoder(); - byte[] encodedBytes = encoder.encode((secretKey + ":").getBytes(StandardCharsets.UTF_8)); - return "Basic " + new String(encodedBytes); - } -} diff --git a/src/main/java/roomescape/payment/business/PaymentService.kt b/src/main/java/roomescape/payment/business/PaymentService.kt new file mode 100644 index 00000000..1b4e6deb --- /dev/null +++ b/src/main/java/roomescape/payment/business/PaymentService.kt @@ -0,0 +1,108 @@ +package roomescape.payment.business + +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import roomescape.common.exception.ErrorType +import roomescape.common.exception.RoomescapeException +import roomescape.payment.infrastructure.persistence.CanceledPaymentEntity +import roomescape.payment.infrastructure.persistence.CanceledPaymentRepository +import roomescape.payment.infrastructure.persistence.PaymentEntity +import roomescape.payment.infrastructure.persistence.PaymentRepository +import roomescape.payment.web.PaymentApprove +import roomescape.payment.web.PaymentCancel +import roomescape.payment.web.ReservationPaymentResponse +import roomescape.payment.web.toReservationPaymentResponse +import roomescape.reservation.domain.Reservation +import java.time.OffsetDateTime + +@Service +class PaymentService( + private val paymentRepository: PaymentRepository, + private val canceledPaymentRepository: CanceledPaymentRepository +) { + @Transactional + fun savePayment( + paymentResponse: PaymentApprove.Response, + reservation: Reservation + ): ReservationPaymentResponse = PaymentEntity( + orderId = paymentResponse.orderId, + paymentKey = paymentResponse.paymentKey, + totalAmount = paymentResponse.totalAmount, + reservation = reservation, + approvedAt = paymentResponse.approvedAt + ).also { + paymentRepository.save(it) + }.toReservationPaymentResponse() + + @Transactional(readOnly = true) + fun isReservationPaid( + reservationId: Long + ): Boolean = paymentRepository.existsByReservationId(reservationId) + + @Transactional + fun saveCanceledPayment( + cancelInfo: PaymentCancel.Response, + approvedAt: OffsetDateTime, + paymentKey: String + ): CanceledPaymentEntity = CanceledPaymentEntity( + paymentKey = paymentKey, + cancelReason = cancelInfo.cancelReason, + cancelAmount = cancelInfo.cancelAmount, + approvedAt = approvedAt, + canceledAt = cancelInfo.canceledAt + ).also { canceledPaymentRepository.save(it) } + + + @Transactional + fun cancelPaymentByAdmin(reservationId: Long): PaymentCancel.Request { + val paymentKey: String = paymentRepository.findPaymentKeyByReservationId(reservationId) + ?: throw RoomescapeException( + ErrorType.PAYMENT_NOT_FOUND, + "[reservationId: $reservationId]", + HttpStatus.NOT_FOUND + ) + // 취소 시간은 현재 시간으로 일단 생성한 뒤, 결제 취소 완료 후 해당 시간으로 변경합니다. + val canceled: CanceledPaymentEntity = cancelPayment(paymentKey) + + return PaymentCancel.Request(paymentKey, canceled.cancelAmount, canceled.cancelReason) + } + + private fun cancelPayment( + paymentKey: String, + cancelReason: String = "고객 요청", + canceledAt: OffsetDateTime = OffsetDateTime.now() + ): CanceledPaymentEntity { + val paymentEntity: PaymentEntity = paymentRepository.findByPaymentKey(paymentKey) + ?.also { paymentRepository.delete(it) } + ?: throw RoomescapeException( + ErrorType.PAYMENT_NOT_FOUND, + "[paymentKey: $paymentKey]", + HttpStatus.NOT_FOUND + ) + + return CanceledPaymentEntity( + paymentKey = paymentKey, + cancelReason = cancelReason, + cancelAmount = paymentEntity.totalAmount, + approvedAt = paymentEntity.approvedAt, + canceledAt = canceledAt + ).also { + canceledPaymentRepository.save(it) + } + } + + @Transactional + fun updateCanceledTime( + paymentKey: String, + canceledAt: OffsetDateTime + ) { + canceledPaymentRepository.findByPaymentKey(paymentKey)?.let { + it.canceledAt = canceledAt + } ?: throw RoomescapeException( + ErrorType.PAYMENT_NOT_FOUND, + "[paymentKey: $paymentKey]", + HttpStatus.NOT_FOUND + ) + } +} diff --git a/src/main/java/roomescape/payment/client/PaymentProperties.java b/src/main/java/roomescape/payment/client/PaymentProperties.java deleted file mode 100644 index 24584579..00000000 --- a/src/main/java/roomescape/payment/client/PaymentProperties.java +++ /dev/null @@ -1,29 +0,0 @@ -package roomescape.payment.client; - -import org.springframework.boot.context.properties.ConfigurationProperties; - -@ConfigurationProperties(prefix = "payment") -public class PaymentProperties { - - private final String confirmSecretKey; - private final int readTimeout; - private final int connectTimeout; - - public PaymentProperties(String confirmSecretKey, int readTimeout, int connectTimeout) { - this.confirmSecretKey = confirmSecretKey; - this.readTimeout = readTimeout; - this.connectTimeout = connectTimeout; - } - - public String getConfirmSecretKey() { - return confirmSecretKey; - } - - public int getReadTimeout() { - return readTimeout; - } - - public int getConnectTimeout() { - return connectTimeout; - } -} diff --git a/src/main/java/roomescape/payment/client/TossPaymentClient.java b/src/main/java/roomescape/payment/client/TossPaymentClient.java deleted file mode 100644 index 9ba7c8bc..00000000 --- a/src/main/java/roomescape/payment/client/TossPaymentClient.java +++ /dev/null @@ -1,98 +0,0 @@ -package roomescape.payment.client; - -import java.io.IOException; -import java.io.InputStream; -import java.util.Map; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.http.HttpStatusCode; -import org.springframework.http.MediaType; -import org.springframework.http.client.ClientHttpResponse; -import org.springframework.stereotype.Component; -import org.springframework.web.client.RestClient; - -import com.fasterxml.jackson.databind.ObjectMapper; - -import roomescape.common.exception.ErrorType; -import roomescape.common.exception.RoomescapeException; -import roomescape.payment.dto.request.PaymentCancelRequest; -import roomescape.payment.dto.request.PaymentRequest; -import roomescape.payment.dto.response.PaymentCancelResponse; -import roomescape.payment.dto.response.PaymentResponse; -import roomescape.payment.dto.response.TossPaymentErrorResponse; - -@Component -public class TossPaymentClient { - - private static final Logger log = LoggerFactory.getLogger(TossPaymentClient.class); - - private final RestClient restClient; - - public TossPaymentClient(RestClient.Builder restClientBuilder) { - this.restClient = restClientBuilder.build(); - } - - public PaymentResponse confirmPayment(PaymentRequest paymentRequest) { - logPaymentInfo(paymentRequest); - return restClient.post() - .uri("/v1/payments/confirm") - .contentType(MediaType.APPLICATION_JSON) - .body(paymentRequest) - .retrieve() - .onStatus(status -> status.is4xxClientError() || status.is5xxServerError(), - (req, res) -> handlePaymentError(res)) - .body(PaymentResponse.class); - } - - public PaymentCancelResponse cancelPayment(PaymentCancelRequest cancelRequest) { - logPaymentCancelInfo(cancelRequest); - Map param = Map.of("cancelReason", cancelRequest.cancelReason()); - - return restClient.post() - .uri("/v1/payments/{paymentKey}/cancel", cancelRequest.paymentKey()) - .contentType(MediaType.APPLICATION_JSON) - .body(param) - .retrieve() - .onStatus(status -> status.is4xxClientError() || status.is5xxServerError(), - (req, res) -> handlePaymentError(res)) - .body(PaymentCancelResponse.class); - } - - private void logPaymentInfo(PaymentRequest paymentRequest) { - log.info("결제 승인 요청: paymentKey={}, orderId={}, amount={}, paymentType={}", - paymentRequest.paymentKey(), paymentRequest.orderId(), paymentRequest.amount(), - paymentRequest.paymentType()); - } - - private void logPaymentCancelInfo(PaymentCancelRequest cancelRequest) { - log.info("결제 취소 요청: paymentKey={}, amount={}, cancelReason={}", - cancelRequest.paymentKey(), cancelRequest.amount(), cancelRequest.cancelReason()); - } - - private void handlePaymentError(ClientHttpResponse res) - throws IOException { - HttpStatusCode statusCode = res.getStatusCode(); - ErrorType errorType = getErrorTypeByStatusCode(statusCode); - TossPaymentErrorResponse errorResponse = getErrorResponse(res); - - throw new RoomescapeException(errorType, - String.format("[ErrorCode = %s, ErrorMessage = %s]", errorResponse.code(), errorResponse.message()), - statusCode); - } - - private TossPaymentErrorResponse getErrorResponse(ClientHttpResponse res) throws IOException { - InputStream body = res.getBody(); - ObjectMapper objectMapper = new ObjectMapper(); - TossPaymentErrorResponse errorResponse = objectMapper.readValue(body, TossPaymentErrorResponse.class); - body.close(); - return errorResponse; - } - - private ErrorType getErrorTypeByStatusCode(HttpStatusCode statusCode) { - if (statusCode.is4xxClientError()) { - return ErrorType.PAYMENT_ERROR; - } - return ErrorType.PAYMENT_SERVER_ERROR; - } -} diff --git a/src/main/java/roomescape/payment/domain/CanceledPayment.java b/src/main/java/roomescape/payment/domain/CanceledPayment.java deleted file mode 100644 index 5d4750f9..00000000 --- a/src/main/java/roomescape/payment/domain/CanceledPayment.java +++ /dev/null @@ -1,67 +0,0 @@ -package roomescape.payment.domain; - -import java.time.OffsetDateTime; - -import org.springframework.http.HttpStatus; - -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import roomescape.common.exception.ErrorType; -import roomescape.common.exception.RoomescapeException; - -@Entity -public class CanceledPayment { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - private String paymentKey; - private String cancelReason; - private Long cancelAmount; - private OffsetDateTime approvedAt; - private OffsetDateTime canceledAt; - - protected CanceledPayment() { - } - - public CanceledPayment(String paymentKey, String cancelReason, Long cancelAmount, OffsetDateTime approvedAt, - OffsetDateTime canceledAt) { - validateDate(approvedAt, canceledAt); - this.paymentKey = paymentKey; - this.cancelReason = cancelReason; - this.cancelAmount = cancelAmount; - this.approvedAt = approvedAt; - this.canceledAt = canceledAt; - } - - private void validateDate(OffsetDateTime approvedAt, OffsetDateTime canceledAt) { - if (canceledAt.isBefore(approvedAt)) { - throw new RoomescapeException(ErrorType.CANCELED_BEFORE_PAYMENT, - String.format("[approvedAt: %s, canceledAt: %s]", approvedAt, canceledAt), - HttpStatus.CONFLICT); - } - } - - public String getCancelReason() { - return cancelReason; - } - - public Long getCancelAmount() { - return cancelAmount; - } - - public OffsetDateTime getApprovedAt() { - return approvedAt; - } - - public OffsetDateTime getCanceledAt() { - return canceledAt; - } - - public void setCanceledAt(OffsetDateTime canceledAt) { - this.canceledAt = canceledAt; - } -} diff --git a/src/main/java/roomescape/payment/domain/Payment.java b/src/main/java/roomescape/payment/domain/Payment.java deleted file mode 100644 index 42094431..00000000 --- a/src/main/java/roomescape/payment/domain/Payment.java +++ /dev/null @@ -1,108 +0,0 @@ -package roomescape.payment.domain; - -import java.time.OffsetDateTime; - -import org.springframework.http.HttpStatus; - -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.FetchType; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.OneToOne; -import roomescape.common.exception.ErrorType; -import roomescape.common.exception.RoomescapeException; -import roomescape.reservation.domain.Reservation; - -@Entity -public class Payment { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(nullable = false) - private String orderId; - - @Column(nullable = false) - private String paymentKey; - - @Column(nullable = false) - private Long totalAmount; - - @OneToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "reservation_id", nullable = false) - private Reservation reservation; - - @Column(nullable = false) - private OffsetDateTime approvedAt; - - protected Payment() { - } - - public Payment(String orderId, String paymentKey, Long totalAmount, Reservation reservation, - OffsetDateTime approvedAt) { - validate(orderId, paymentKey, totalAmount, reservation, approvedAt); - this.orderId = orderId; - this.paymentKey = paymentKey; - this.totalAmount = totalAmount; - this.reservation = reservation; - this.approvedAt = approvedAt; - } - - private void validate(String orderId, String paymentKey, Long totalAmount, Reservation reservation, - OffsetDateTime approvedAt) { - validateIsNullOrBlank(orderId, "orderId"); - validateIsNullOrBlank(paymentKey, "paymentKey"); - validateIsInvalidAmount(totalAmount); - validateIsNull(reservation, "reservation"); - validateIsNull(approvedAt, "approvedAt"); - } - - private void validateIsNullOrBlank(String input, String fieldName) { - if (input == null || input.isBlank()) { - throw new RoomescapeException(ErrorType.REQUEST_DATA_BLANK, String.format("[value : %s]", fieldName), - HttpStatus.BAD_REQUEST); - } - } - - private void validateIsInvalidAmount(Long totalAmount) { - if (totalAmount == null || totalAmount < 0) { - throw new RoomescapeException(ErrorType.INVALID_REQUEST_DATA, - String.format("[totalAmount : %d]", totalAmount), HttpStatus.BAD_REQUEST); - } - } - - private void validateIsNull(T value, String fieldName) { - if (value == null) { - throw new RoomescapeException(ErrorType.REQUEST_DATA_BLANK, String.format("[value : %s]", fieldName), - HttpStatus.BAD_REQUEST); - } - } - - public Long getId() { - return id; - } - - public String getOrderId() { - return orderId; - } - - public String getPaymentKey() { - return paymentKey; - } - - public Long getTotalAmount() { - return totalAmount; - } - - public Reservation getReservation() { - return reservation; - } - - public OffsetDateTime getApprovedAt() { - return approvedAt; - } -} diff --git a/src/main/java/roomescape/payment/domain/repository/CanceledPaymentRepository.java b/src/main/java/roomescape/payment/domain/repository/CanceledPaymentRepository.java deleted file mode 100644 index e3e05a47..00000000 --- a/src/main/java/roomescape/payment/domain/repository/CanceledPaymentRepository.java +++ /dev/null @@ -1,12 +0,0 @@ -package roomescape.payment.domain.repository; - -import java.util.Optional; - -import org.springframework.data.jpa.repository.JpaRepository; - -import roomescape.payment.domain.CanceledPayment; - -public interface CanceledPaymentRepository extends JpaRepository { - - Optional findByPaymentKey(String paymentKey); -} diff --git a/src/main/java/roomescape/payment/domain/repository/PaymentRepository.java b/src/main/java/roomescape/payment/domain/repository/PaymentRepository.java deleted file mode 100644 index 7d55352f..00000000 --- a/src/main/java/roomescape/payment/domain/repository/PaymentRepository.java +++ /dev/null @@ -1,14 +0,0 @@ -package roomescape.payment.domain.repository; - -import java.util.Optional; - -import org.springframework.data.jpa.repository.JpaRepository; - -import roomescape.payment.domain.Payment; - -public interface PaymentRepository extends JpaRepository { - - Optional findByReservationId(Long reservationId); - - Optional findByPaymentKey(String paymentKey); -} diff --git a/src/main/java/roomescape/payment/dto/request/PaymentCancelRequest.java b/src/main/java/roomescape/payment/dto/request/PaymentCancelRequest.java deleted file mode 100644 index 4641575b..00000000 --- a/src/main/java/roomescape/payment/dto/request/PaymentCancelRequest.java +++ /dev/null @@ -1,4 +0,0 @@ -package roomescape.payment.dto.request; - -public record PaymentCancelRequest(String paymentKey, Long amount, String cancelReason) { -} diff --git a/src/main/java/roomescape/payment/dto/request/PaymentRequest.java b/src/main/java/roomescape/payment/dto/request/PaymentRequest.java deleted file mode 100644 index d7f2ea82..00000000 --- a/src/main/java/roomescape/payment/dto/request/PaymentRequest.java +++ /dev/null @@ -1,4 +0,0 @@ -package roomescape.payment.dto.request; - -public record PaymentRequest(String paymentKey, String orderId, Long amount, String paymentType) { -} diff --git a/src/main/java/roomescape/payment/dto/response/PaymentCancelResponse.java b/src/main/java/roomescape/payment/dto/response/PaymentCancelResponse.java deleted file mode 100644 index 1c6e2e0e..00000000 --- a/src/main/java/roomescape/payment/dto/response/PaymentCancelResponse.java +++ /dev/null @@ -1,14 +0,0 @@ -package roomescape.payment.dto.response; - -import java.time.OffsetDateTime; - -import com.fasterxml.jackson.databind.annotation.JsonDeserialize; - -@JsonDeserialize(using = PaymentCancelResponseDeserializer.class) -public record PaymentCancelResponse( - String cancelStatus, - String cancelReason, - Long cancelAmount, - OffsetDateTime canceledAt -) { -} diff --git a/src/main/java/roomescape/payment/dto/response/PaymentCancelResponseDeserializer.java b/src/main/java/roomescape/payment/dto/response/PaymentCancelResponseDeserializer.java deleted file mode 100644 index 96bba21d..00000000 --- a/src/main/java/roomescape/payment/dto/response/PaymentCancelResponseDeserializer.java +++ /dev/null @@ -1,36 +0,0 @@ -package roomescape.payment.dto.response; - -import java.io.IOException; -import java.time.OffsetDateTime; -import java.time.format.DateTimeFormatter; - -import com.fasterxml.jackson.core.JsonParser; -import com.fasterxml.jackson.databind.DeserializationContext; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.deser.std.StdDeserializer; - -public class PaymentCancelResponseDeserializer extends StdDeserializer { - - private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern( - "yyyy-MM-dd'T'HH:mm:ssXXX"); - - public PaymentCancelResponseDeserializer() { - this(null); - } - - public PaymentCancelResponseDeserializer(Class vc) { - super(vc); - } - - @Override - public PaymentCancelResponse deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) - throws IOException { - JsonNode cancels = (JsonNode)jsonParser.getCodec().readTree(jsonParser).get("cancels").get(0); - return new PaymentCancelResponse( - cancels.get("cancelStatus").asText(), - cancels.get("cancelReason").asText(), - cancels.get("cancelAmount").asLong(), - OffsetDateTime.parse(cancels.get("canceledAt").asText()) - ); - } -} diff --git a/src/main/java/roomescape/payment/dto/response/PaymentResponse.java b/src/main/java/roomescape/payment/dto/response/PaymentResponse.java deleted file mode 100644 index afee9601..00000000 --- a/src/main/java/roomescape/payment/dto/response/PaymentResponse.java +++ /dev/null @@ -1,11 +0,0 @@ -package roomescape.payment.dto.response; - -import java.time.OffsetDateTime; - -public record PaymentResponse( - String paymentKey, - String orderId, - OffsetDateTime approvedAt, - Long totalAmount -) { -} diff --git a/src/main/java/roomescape/payment/dto/response/ReservationPaymentResponse.java b/src/main/java/roomescape/payment/dto/response/ReservationPaymentResponse.java deleted file mode 100644 index 479d25e8..00000000 --- a/src/main/java/roomescape/payment/dto/response/ReservationPaymentResponse.java +++ /dev/null @@ -1,15 +0,0 @@ -package roomescape.payment.dto.response; - -import java.time.OffsetDateTime; - -import roomescape.payment.domain.Payment; -import roomescape.reservation.dto.response.ReservationResponse; - -public record ReservationPaymentResponse(Long id, String orderId, String paymentKey, Long totalAmount, - ReservationResponse reservation, OffsetDateTime approvedAt) { - - public static ReservationPaymentResponse from(Payment saved) { - return new ReservationPaymentResponse(saved.getId(), saved.getOrderId(), saved.getPaymentKey(), - saved.getTotalAmount(), ReservationResponse.from(saved.getReservation()), saved.getApprovedAt()); - } -} diff --git a/src/main/java/roomescape/payment/dto/response/TossPaymentErrorResponse.java b/src/main/java/roomescape/payment/dto/response/TossPaymentErrorResponse.java deleted file mode 100644 index 2e066de9..00000000 --- a/src/main/java/roomescape/payment/dto/response/TossPaymentErrorResponse.java +++ /dev/null @@ -1,4 +0,0 @@ -package roomescape.payment.dto.response; - -public record TossPaymentErrorResponse(String code, String message) { -} diff --git a/src/main/java/roomescape/payment/infrastructure/client/PaymentCancelResponseDeserializer.kt b/src/main/java/roomescape/payment/infrastructure/client/PaymentCancelResponseDeserializer.kt new file mode 100644 index 00000000..075e7e5e --- /dev/null +++ b/src/main/java/roomescape/payment/infrastructure/client/PaymentCancelResponseDeserializer.kt @@ -0,0 +1,31 @@ +package roomescape.payment.infrastructure.client + +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.core.TreeNode +import com.fasterxml.jackson.databind.DeserializationContext +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.deser.std.StdDeserializer +import roomescape.payment.web.PaymentCancel +import java.io.IOException +import java.time.OffsetDateTime + +class PaymentCancelResponseDeserializer( + vc: Class? = null +) : StdDeserializer(vc) { + @Throws(IOException::class) + override fun deserialize( + jsonParser: JsonParser, + deserializationContext: DeserializationContext? + ): PaymentCancel.Response { + val cancels: JsonNode = jsonParser.codec.readTree(jsonParser) + .get("cancels") + .get(0) as JsonNode + + return PaymentCancel.Response( + cancels.get("cancelStatus").asText(), + cancels.get("cancelReason").asText(), + cancels.get("cancelAmount").asLong(), + OffsetDateTime.parse(cancels.get("canceledAt").asText()) + ) + } +} \ No newline at end of file diff --git a/src/main/java/roomescape/payment/infrastructure/client/PaymentConfig.kt b/src/main/java/roomescape/payment/infrastructure/client/PaymentConfig.kt new file mode 100644 index 00000000..a7d7cb94 --- /dev/null +++ b/src/main/java/roomescape/payment/infrastructure/client/PaymentConfig.kt @@ -0,0 +1,39 @@ +package roomescape.payment.infrastructure.client + +import org.springframework.boot.context.properties.EnableConfigurationProperties +import org.springframework.boot.http.client.ClientHttpRequestFactoryBuilder +import org.springframework.boot.http.client.ClientHttpRequestFactorySettings +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.web.client.RestClient +import java.nio.charset.StandardCharsets +import java.time.Duration +import java.util.* + +@Configuration +@EnableConfigurationProperties(PaymentProperties::class) +class PaymentConfig { + + @Bean + fun tossPaymentClientBuilder( + paymentProperties: PaymentProperties, + ): RestClient.Builder { + val settings: ClientHttpRequestFactorySettings = ClientHttpRequestFactorySettings.defaults().also { + it.withReadTimeout(Duration.ofSeconds(paymentProperties.readTimeout.toLong())) + it.withConnectTimeout(Duration.ofSeconds(paymentProperties.connectTimeout.toLong())) + } + val requestFactory = ClientHttpRequestFactoryBuilder.jdk().build(settings) + + return RestClient.builder() + .baseUrl(paymentProperties.apiBaseUrl) + .defaultHeader("Authorization", getAuthorizations(paymentProperties.confirmSecretKey)) + .requestFactory(requestFactory) + } + + private fun getAuthorizations(secretKey: String): String { + val encodedSecretKey = Base64.getEncoder() + .encodeToString("$secretKey:".toByteArray(StandardCharsets.UTF_8)) + + return "Basic $encodedSecretKey" + } +} diff --git a/src/main/java/roomescape/payment/infrastructure/client/PaymentProperties.kt b/src/main/java/roomescape/payment/infrastructure/client/PaymentProperties.kt new file mode 100644 index 00000000..06758eb8 --- /dev/null +++ b/src/main/java/roomescape/payment/infrastructure/client/PaymentProperties.kt @@ -0,0 +1,11 @@ +package roomescape.payment.infrastructure.client + +import org.springframework.boot.context.properties.ConfigurationProperties + +@ConfigurationProperties(prefix = "payment") +data class PaymentProperties( + @JvmField val apiBaseUrl: String, + @JvmField val confirmSecretKey: String, + @JvmField val readTimeout: Int, + @JvmField val connectTimeout: Int +) diff --git a/src/main/java/roomescape/payment/infrastructure/client/TossPaymentClient.kt b/src/main/java/roomescape/payment/infrastructure/client/TossPaymentClient.kt new file mode 100644 index 00000000..e46b95da --- /dev/null +++ b/src/main/java/roomescape/payment/infrastructure/client/TossPaymentClient.kt @@ -0,0 +1,113 @@ +package roomescape.payment.infrastructure.client + +import com.fasterxml.jackson.databind.ObjectMapper +import io.github.oshai.kotlinlogging.KLogger +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.http.HttpRequest +import org.springframework.http.HttpStatus +import org.springframework.http.HttpStatusCode +import org.springframework.http.MediaType +import org.springframework.http.client.ClientHttpResponse +import org.springframework.stereotype.Component +import org.springframework.web.client.RestClient +import roomescape.common.exception.ErrorType +import roomescape.common.exception.RoomescapeException +import roomescape.payment.web.PaymentApprove +import roomescape.payment.web.PaymentCancel +import java.io.IOException +import java.util.Map + +@Component +class TossPaymentClient( + private val log: KLogger = KotlinLogging.logger {}, + tossPaymentClientBuilder: RestClient.Builder +) { + companion object { + private const val CONFIRM_URL: String = "/v1/payments/confirm" + private const val CANCEL_URL: String = "/v1/payments/{paymentKey}/cancel" + } + + private val tossPaymentClient: RestClient = tossPaymentClientBuilder.build() + + fun confirmPayment(paymentRequest: PaymentApprove.Request): PaymentApprove.Response { + logPaymentInfo(paymentRequest) + + return tossPaymentClient.post() + .uri(CONFIRM_URL) + .contentType(MediaType.APPLICATION_JSON) + .body(paymentRequest) + .retrieve() + .onStatus( + { status: HttpStatusCode -> status.is4xxClientError || status.is5xxServerError }, + { req: HttpRequest, res: ClientHttpResponse -> handlePaymentError(res) } + ) + .body(PaymentApprove.Response::class.java) + ?: throw RoomescapeException(ErrorType.PAYMENT_SERVER_ERROR, HttpStatus.INTERNAL_SERVER_ERROR) + } + + fun cancelPayment(cancelRequest: PaymentCancel.Request): PaymentCancel.Response { + logPaymentCancelInfo(cancelRequest) + val param = Map.of("cancelReason", cancelRequest.cancelReason) + + return tossPaymentClient.post() + .uri(CANCEL_URL, cancelRequest.paymentKey) + .contentType(MediaType.APPLICATION_JSON) + .body(param) + .retrieve() + .onStatus( + { status: HttpStatusCode -> status.is4xxClientError || status.is5xxServerError }, + { req: HttpRequest, res: ClientHttpResponse -> handlePaymentError(res) } + ) + .body(PaymentCancel.Response::class.java) + ?: throw RoomescapeException(ErrorType.PAYMENT_SERVER_ERROR, HttpStatus.INTERNAL_SERVER_ERROR) + } + + private fun logPaymentInfo(paymentRequest: PaymentApprove.Request) { + log.info { + "결제 승인 요청: paymentKey=${paymentRequest.paymentKey}, orderId=${paymentRequest.orderId}, " + + "amount=${paymentRequest.amount}, paymentType=${paymentRequest.paymentType}" + } + } + + private fun logPaymentCancelInfo(cancelRequest: PaymentCancel.Request) { + log.info { + "결제 취소 요청: paymentKey=${cancelRequest.paymentKey}, amount=${cancelRequest.amount}, " + + "cancelReason=${cancelRequest.cancelReason}" + } + } + + @Throws(IOException::class) + private fun handlePaymentError( + res: ClientHttpResponse + ): Nothing { + val statusCode = res.statusCode + val errorType = getErrorTypeByStatusCode(statusCode) + val errorResponse = getErrorResponse(res) + + throw RoomescapeException( + errorType, + "[ErrorCode = ${errorResponse.code}, ErrorMessage = ${errorResponse.message}]", + statusCode + ) + } + + @Throws(IOException::class) + private fun getErrorResponse( + res: ClientHttpResponse + ): TossPaymentErrorResponse { + val body = res.body + val objectMapper = ObjectMapper() + val errorResponse = objectMapper.readValue(body, TossPaymentErrorResponse::class.java) + body.close() + return errorResponse + } + + private fun getErrorTypeByStatusCode( + statusCode: HttpStatusCode + ): ErrorType { + if (statusCode.is4xxClientError) { + return ErrorType.PAYMENT_ERROR + } + return ErrorType.PAYMENT_SERVER_ERROR + } +} diff --git a/src/main/java/roomescape/payment/infrastructure/client/TossPaymentErrorResponse.kt b/src/main/java/roomescape/payment/infrastructure/client/TossPaymentErrorResponse.kt new file mode 100644 index 00000000..aebddc49 --- /dev/null +++ b/src/main/java/roomescape/payment/infrastructure/client/TossPaymentErrorResponse.kt @@ -0,0 +1,7 @@ +package roomescape.payment.infrastructure.client + +@JvmRecord +data class TossPaymentErrorResponse( + val code: String, + val message: String +) diff --git a/src/main/java/roomescape/payment/infrastructure/persistence/CanceledPaymentEntity.kt b/src/main/java/roomescape/payment/infrastructure/persistence/CanceledPaymentEntity.kt new file mode 100644 index 00000000..fb19ca0a --- /dev/null +++ b/src/main/java/roomescape/payment/infrastructure/persistence/CanceledPaymentEntity.kt @@ -0,0 +1,18 @@ +package roomescape.payment.infrastructure.persistence + +import jakarta.persistence.* +import java.time.OffsetDateTime + +@Entity +@Table(name = "canceled_payment") +class CanceledPaymentEntity( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + var id: Long? = null, + + var paymentKey: String, + var cancelReason: String, + var cancelAmount: Long, + var approvedAt: OffsetDateTime, + var canceledAt: OffsetDateTime, +) diff --git a/src/main/java/roomescape/payment/infrastructure/persistence/CanceledPaymentRepository.kt b/src/main/java/roomescape/payment/infrastructure/persistence/CanceledPaymentRepository.kt new file mode 100644 index 00000000..70466653 --- /dev/null +++ b/src/main/java/roomescape/payment/infrastructure/persistence/CanceledPaymentRepository.kt @@ -0,0 +1,7 @@ +package roomescape.payment.infrastructure.persistence + +import org.springframework.data.jpa.repository.JpaRepository + +interface CanceledPaymentRepository : JpaRepository { + fun findByPaymentKey(paymentKey: String): CanceledPaymentEntity? +} diff --git a/src/main/java/roomescape/payment/infrastructure/persistence/PaymentEntity.kt b/src/main/java/roomescape/payment/infrastructure/persistence/PaymentEntity.kt new file mode 100644 index 00000000..8de5d4da --- /dev/null +++ b/src/main/java/roomescape/payment/infrastructure/persistence/PaymentEntity.kt @@ -0,0 +1,29 @@ +package roomescape.payment.infrastructure.persistence + +import jakarta.persistence.* +import roomescape.reservation.domain.Reservation +import java.time.OffsetDateTime + +@Entity +@Table(name = "payment") +class PaymentEntity( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + var id: Long? = null, + + @Column(nullable = false) + var orderId: String, + + @Column(nullable = false) + var paymentKey: String, + + @Column(nullable = false) + var totalAmount: Long, + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "reservation_id", nullable = false) + var reservation: Reservation, + + @Column(nullable = false) + var approvedAt: OffsetDateTime +) \ No newline at end of file diff --git a/src/main/java/roomescape/payment/infrastructure/persistence/PaymentRepository.kt b/src/main/java/roomescape/payment/infrastructure/persistence/PaymentRepository.kt new file mode 100644 index 00000000..bf83f375 --- /dev/null +++ b/src/main/java/roomescape/payment/infrastructure/persistence/PaymentRepository.kt @@ -0,0 +1,14 @@ +package roomescape.payment.infrastructure.persistence + +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query + +interface PaymentRepository : JpaRepository { + + fun existsByReservationId(reservationId: Long): Boolean + + @Query("SELECT p.paymentKey FROM PaymentEntity p WHERE p.reservation.id = :reservationId") + fun findPaymentKeyByReservationId(reservationId: Long): String? + + fun findByPaymentKey(paymentKey: String): PaymentEntity? +} diff --git a/src/main/java/roomescape/payment/service/PaymentService.java b/src/main/java/roomescape/payment/service/PaymentService.java deleted file mode 100644 index e4761559..00000000 --- a/src/main/java/roomescape/payment/service/PaymentService.java +++ /dev/null @@ -1,82 +0,0 @@ -package roomescape.payment.service; - -import java.time.OffsetDateTime; -import java.util.Optional; - -import org.springframework.http.HttpStatus; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import roomescape.common.exception.ErrorType; -import roomescape.common.exception.RoomescapeException; -import roomescape.payment.domain.CanceledPayment; -import roomescape.payment.domain.Payment; -import roomescape.payment.domain.repository.CanceledPaymentRepository; -import roomescape.payment.domain.repository.PaymentRepository; -import roomescape.payment.dto.request.PaymentCancelRequest; -import roomescape.payment.dto.response.PaymentCancelResponse; -import roomescape.payment.dto.response.PaymentResponse; -import roomescape.payment.dto.response.ReservationPaymentResponse; -import roomescape.reservation.domain.Reservation; - -@Service -@Transactional -public class PaymentService { - - private final PaymentRepository paymentRepository; - private final CanceledPaymentRepository canceledPaymentRepository; - - public PaymentService(PaymentRepository paymentRepository, CanceledPaymentRepository canceledPaymentRepository) { - this.paymentRepository = paymentRepository; - this.canceledPaymentRepository = canceledPaymentRepository; - } - - public ReservationPaymentResponse savePayment(PaymentResponse paymentResponse, Reservation reservation) { - Payment payment = new Payment(paymentResponse.orderId(), paymentResponse.paymentKey(), - paymentResponse.totalAmount(), reservation, paymentResponse.approvedAt()); - Payment saved = paymentRepository.save(payment); - return ReservationPaymentResponse.from(saved); - } - - @Transactional(readOnly = true) - public Optional findPaymentByReservationId(Long reservationId) { - return paymentRepository.findByReservationId(reservationId); - } - - public void saveCanceledPayment(PaymentCancelResponse cancelInfo, OffsetDateTime approvedAt, String paymentKey) { - canceledPaymentRepository.save(new CanceledPayment( - paymentKey, cancelInfo.cancelReason(), cancelInfo.cancelAmount(), approvedAt, cancelInfo.canceledAt())); - } - - public PaymentCancelRequest cancelPaymentByAdmin(Long reservationId) { - String paymentKey = findPaymentByReservationId(reservationId) - .orElseThrow(() -> new RoomescapeException(ErrorType.PAYMENT_NOT_POUND, - String.format("[reservationId: %d]", reservationId), HttpStatus.NOT_FOUND)) - .getPaymentKey(); - // 취소 시간은 현재 시간으로 일단 생성한 뒤, 결제 취소 완료 후 해당 시간으로 변경합니다. - CanceledPayment canceled = cancelPayment(paymentKey, "고객 요청", OffsetDateTime.now()); - - return new PaymentCancelRequest(paymentKey, canceled.getCancelAmount(), canceled.getCancelReason()); - } - - private CanceledPayment cancelPayment(String paymentKey, String cancelReason, OffsetDateTime canceledAt) { - Payment payment = paymentRepository.findByPaymentKey(paymentKey) - .orElseThrow(() -> throwPaymentNotFoundByPaymentKey(paymentKey)); - paymentRepository.delete(payment); - - return canceledPaymentRepository.save(new CanceledPayment(paymentKey, cancelReason, payment.getTotalAmount(), - payment.getApprovedAt(), canceledAt)); - } - - public void updateCanceledTime(String paymentKey, OffsetDateTime canceledAt) { - CanceledPayment canceledPayment = canceledPaymentRepository.findByPaymentKey(paymentKey) - .orElseThrow(() -> throwPaymentNotFoundByPaymentKey(paymentKey)); - canceledPayment.setCanceledAt(canceledAt); - } - - private RoomescapeException throwPaymentNotFoundByPaymentKey(String paymentKey) { - return new RoomescapeException( - ErrorType.PAYMENT_NOT_POUND, String.format("[paymentKey: %s]", paymentKey), - HttpStatus.NOT_FOUND); - } -} diff --git a/src/main/java/roomescape/payment/web/PaymentDTO.kt b/src/main/java/roomescape/payment/web/PaymentDTO.kt new file mode 100644 index 00000000..8b4b12e2 --- /dev/null +++ b/src/main/java/roomescape/payment/web/PaymentDTO.kt @@ -0,0 +1,65 @@ +package roomescape.payment.web + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.databind.annotation.JsonDeserialize +import roomescape.payment.infrastructure.client.PaymentCancelResponseDeserializer +import roomescape.payment.infrastructure.persistence.PaymentEntity +import roomescape.reservation.dto.response.ReservationResponse +import java.time.OffsetDateTime + +class PaymentApprove { + @JvmRecord + data class Request( + @JvmField val paymentKey: String, + @JvmField val orderId: String, + @JvmField val amount: Long, + @JvmField val paymentType: String + ) + + @JvmRecord + @JsonIgnoreProperties(ignoreUnknown = true) + data class Response( + @JvmField val paymentKey: String, + @JvmField val orderId: String, + @JvmField val approvedAt: OffsetDateTime, + @JvmField val totalAmount: Long + ) +} + +class PaymentCancel { + @JvmRecord + data class Request( + @JvmField val paymentKey: String, + @JvmField val amount: Long, + @JvmField val cancelReason: String + ) + + @JvmRecord + @JsonDeserialize(using = PaymentCancelResponseDeserializer::class) + data class Response( + @JvmField val cancelStatus: String, + @JvmField val cancelReason: String, + @JvmField val cancelAmount: Long, + @JvmField val canceledAt: OffsetDateTime + ) +} + + +@JvmRecord +data class ReservationPaymentResponse( + val id: Long, + val orderId: String, + val paymentKey: String, + val totalAmount: Long, + val reservation: ReservationResponse, + val approvedAt: OffsetDateTime +) + +fun PaymentEntity.toReservationPaymentResponse(): ReservationPaymentResponse = ReservationPaymentResponse( + id = this.id!!, + orderId = this.orderId, + paymentKey = this.paymentKey, + totalAmount = this.totalAmount, + reservation = ReservationResponse.from(this.reservation), + approvedAt = this.approvedAt +) \ No newline at end of file diff --git a/src/main/java/roomescape/reservation/controller/ReservationController.java b/src/main/java/roomescape/reservation/controller/ReservationController.java index 89dcb95a..f5df5f57 100644 --- a/src/main/java/roomescape/reservation/controller/ReservationController.java +++ b/src/main/java/roomescape/reservation/controller/ReservationController.java @@ -30,11 +30,9 @@ import roomescape.auth.web.support.MemberId; import roomescape.common.dto.response.RoomescapeApiResponse; import roomescape.common.dto.response.RoomescapeErrorResponse; import roomescape.common.exception.RoomescapeException; -import roomescape.payment.client.TossPaymentClient; -import roomescape.payment.dto.request.PaymentCancelRequest; -import roomescape.payment.dto.request.PaymentRequest; -import roomescape.payment.dto.response.PaymentCancelResponse; -import roomescape.payment.dto.response.PaymentResponse; +import roomescape.payment.infrastructure.client.TossPaymentClient; +import roomescape.payment.web.PaymentApprove; +import roomescape.payment.web.PaymentCancel; import roomescape.reservation.dto.request.AdminReservationRequest; import roomescape.reservation.dto.request.ReservationRequest; import roomescape.reservation.dto.request.WaitingRequest; @@ -120,13 +118,13 @@ public class ReservationController { return RoomescapeApiResponse.success(); } - PaymentCancelRequest paymentCancelRequest = reservationWithPaymentService.removeReservationWithPayment( + PaymentCancel.Request paymentCancelRequest = reservationWithPaymentService.removeReservationWithPayment( reservationId, memberId); - PaymentCancelResponse paymentCancelResponse = paymentClient.cancelPayment(paymentCancelRequest); + PaymentCancel.Response paymentCancelResponse = paymentClient.cancelPayment(paymentCancelRequest); - reservationWithPaymentService.updateCanceledTime(paymentCancelRequest.paymentKey(), - paymentCancelResponse.canceledAt()); + reservationWithPaymentService.updateCanceledTime(paymentCancelRequest.paymentKey, + paymentCancelResponse.canceledAt); return RoomescapeApiResponse.success(); } @@ -144,21 +142,21 @@ public class ReservationController { @MemberId @Parameter(hidden = true) Long memberId, HttpServletResponse response ) { - PaymentRequest paymentRequest = reservationRequest.getPaymentRequest(); - PaymentResponse paymentResponse = paymentClient.confirmPayment(paymentRequest); + PaymentApprove.Request paymentRequest = reservationRequest.getPaymentRequest(); + PaymentApprove.Response paymentResponse = paymentClient.confirmPayment(paymentRequest); try { ReservationResponse reservationResponse = reservationWithPaymentService.addReservationWithPayment( reservationRequest, paymentResponse, memberId); return getCreatedReservationResponse(reservationResponse, response); } catch (RoomescapeException e) { - PaymentCancelRequest cancelRequest = new PaymentCancelRequest(paymentRequest.paymentKey(), - paymentRequest.amount(), e.getMessage()); + PaymentCancel.Request cancelRequest = new PaymentCancel.Request(paymentRequest.paymentKey, + paymentRequest.amount, e.getMessage()); - PaymentCancelResponse paymentCancelResponse = paymentClient.cancelPayment(cancelRequest); + PaymentCancel.Response paymentCancelResponse = paymentClient.cancelPayment(cancelRequest); - reservationWithPaymentService.saveCanceledPayment(paymentCancelResponse, paymentResponse.approvedAt(), - paymentRequest.paymentKey()); + reservationWithPaymentService.saveCanceledPayment(paymentCancelResponse, paymentResponse.approvedAt, + paymentRequest.paymentKey); throw e; } } diff --git a/src/main/java/roomescape/reservation/domain/repository/ReservationRepository.java b/src/main/java/roomescape/reservation/domain/repository/ReservationRepository.java index e89d7279..af83b3dc 100644 --- a/src/main/java/roomescape/reservation/domain/repository/ReservationRepository.java +++ b/src/main/java/roomescape/reservation/domain/repository/ReservationRepository.java @@ -54,7 +54,7 @@ public interface ReservationRepository extends JpaRepository, ) FROM Reservation r JOIN r.theme t - LEFT JOIN Payment p + LEFT JOIN PaymentEntity p ON p.reservation = r WHERE r.member.id = :memberId """) diff --git a/src/main/java/roomescape/reservation/dto/request/ReservationRequest.java b/src/main/java/roomescape/reservation/dto/request/ReservationRequest.java index 28f1a363..e15b00fb 100644 --- a/src/main/java/roomescape/reservation/dto/request/ReservationRequest.java +++ b/src/main/java/roomescape/reservation/dto/request/ReservationRequest.java @@ -6,7 +6,7 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotNull; -import roomescape.payment.dto.request.PaymentRequest; +import roomescape.payment.web.PaymentApprove; @Schema(name = "회원의 예약 저장 요청", description = "회원의 예약 요청시 사용됩니다.") public record ReservationRequest( @@ -30,7 +30,7 @@ public record ReservationRequest( ) { @JsonIgnore - public PaymentRequest getPaymentRequest() { - return new PaymentRequest(paymentKey, orderId, amount, paymentType); + public PaymentApprove.Request getPaymentRequest() { + return new PaymentApprove.Request(paymentKey, orderId, amount, paymentType); } } diff --git a/src/main/java/roomescape/reservation/service/ReservationWithPaymentService.java b/src/main/java/roomescape/reservation/service/ReservationWithPaymentService.java index 785182a8..853e6e30 100644 --- a/src/main/java/roomescape/reservation/service/ReservationWithPaymentService.java +++ b/src/main/java/roomescape/reservation/service/ReservationWithPaymentService.java @@ -5,11 +5,10 @@ import java.time.OffsetDateTime; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import roomescape.payment.dto.request.PaymentCancelRequest; -import roomescape.payment.dto.response.PaymentCancelResponse; -import roomescape.payment.dto.response.PaymentResponse; -import roomescape.payment.dto.response.ReservationPaymentResponse; -import roomescape.payment.service.PaymentService; +import roomescape.payment.business.PaymentService; +import roomescape.payment.web.PaymentApprove; +import roomescape.payment.web.PaymentCancel; +import roomescape.payment.web.ReservationPaymentResponse; import roomescape.reservation.domain.Reservation; import roomescape.reservation.dto.request.ReservationRequest; import roomescape.reservation.dto.response.ReservationResponse; @@ -27,7 +26,8 @@ public class ReservationWithPaymentService { this.paymentService = paymentService; } - public ReservationResponse addReservationWithPayment(ReservationRequest request, PaymentResponse paymentInfo, + public ReservationResponse addReservationWithPayment(ReservationRequest request, + PaymentApprove.Response paymentInfo, Long memberId) { Reservation reservation = reservationService.addReservation(request, memberId); ReservationPaymentResponse reservationPaymentResponse = paymentService.savePayment(paymentInfo, reservation); @@ -35,19 +35,19 @@ public class ReservationWithPaymentService { return reservationPaymentResponse.reservation(); } - public void saveCanceledPayment(PaymentCancelResponse cancelInfo, OffsetDateTime approvedAt, String paymentKey) { + public void saveCanceledPayment(PaymentCancel.Response cancelInfo, OffsetDateTime approvedAt, String paymentKey) { paymentService.saveCanceledPayment(cancelInfo, approvedAt, paymentKey); } - public PaymentCancelRequest removeReservationWithPayment(Long reservationId, Long memberId) { - PaymentCancelRequest paymentCancelRequest = paymentService.cancelPaymentByAdmin(reservationId); + public PaymentCancel.Request removeReservationWithPayment(Long reservationId, Long memberId) { + PaymentCancel.Request paymentCancelRequest = paymentService.cancelPaymentByAdmin(reservationId); reservationService.removeReservationById(reservationId, memberId); return paymentCancelRequest; } @Transactional(readOnly = true) public boolean isNotPaidReservation(Long reservationId) { - return paymentService.findPaymentByReservationId(reservationId).isEmpty(); + return !paymentService.isReservationPaid(reservationId); } public void updateCanceledTime(String paymentKey, OffsetDateTime canceledAt) { diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index b704da5f..82422963 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -25,6 +25,7 @@ security: expire-length: 1800000 # 30 분 payment: + api-base-url: https://api.tosspayments.com confirm-secret-key: test_gsk_docs_OaPz8L5KdmQXkzRz3y47BMw6 read-timeout: 3 connect-timeout: 30 diff --git a/src/test/java/roomescape/member/controller/MemberControllerTest.kt b/src/test/java/roomescape/member/controller/MemberControllerTest.kt index 7f24f6d8..7eddc94e 100644 --- a/src/test/java/roomescape/member/controller/MemberControllerTest.kt +++ b/src/test/java/roomescape/member/controller/MemberControllerTest.kt @@ -11,6 +11,7 @@ import roomescape.member.web.MemberController import roomescape.member.web.MembersResponse import roomescape.util.MemberFixture import roomescape.util.RoomescapeApiTest +import kotlin.random.Random @WebMvcTest(controllers = [MemberController::class]) class MemberControllerTest( @@ -22,9 +23,9 @@ class MemberControllerTest( val endpoint = "/members" every { memberRepository.findAll() } returns listOf( - MemberFixture.create(name = "name1"), - MemberFixture.create(name = "name2"), - MemberFixture.create(name = "name3"), + MemberFixture.create(id = Random.nextLong(), name = "name1"), + MemberFixture.create(id = Random.nextLong(), name = "name2"), + MemberFixture.create(id = Random.nextLong(), name = "name3"), ) `when`("관리자가 보내면") { diff --git a/src/test/java/roomescape/payment/client/SampleTossPaymentConst.java b/src/test/java/roomescape/payment/SampleTossPaymentConst.kt similarity index 66% rename from src/test/java/roomescape/payment/client/SampleTossPaymentConst.java rename to src/test/java/roomescape/payment/SampleTossPaymentConst.kt index 688cc27f..ba1db53e 100644 --- a/src/test/java/roomescape/payment/client/SampleTossPaymentConst.java +++ b/src/test/java/roomescape/payment/SampleTossPaymentConst.kt @@ -1,44 +1,73 @@ -package roomescape.payment.client; +package roomescape.payment -import roomescape.payment.dto.request.PaymentCancelRequest; -import roomescape.payment.dto.request.PaymentRequest; +import roomescape.payment.SampleTossPaymentConst.amount +import roomescape.payment.web.PaymentApprove +import roomescape.payment.web.PaymentCancel +import kotlin.math.roundToLong -public class SampleTossPaymentConst { +object SampleTossPaymentConst { + @JvmField + val paymentKey: String = "5EnNZRJGvaBX7zk2yd8ydw26XvwXkLrx9POLqKQjmAw4b0e1" - public static final PaymentRequest paymentRequest = new PaymentRequest( - "5EnNZRJGvaBX7zk2yd8ydw26XvwXkLrx9POLqKQjmAw4b0e1", "MC4wODU4ODQwMzg4NDk0", 1000L, "카드"); + @JvmField + val orderId: String = "MC4wODU4ODQwMzg4NDk0" - public static final String paymentRequestJson = """ + @JvmField + val amount: Long = 1000L + + @JvmField + val paymentType: String = "카드" + + @JvmField + val cancelReason: String = "테스트 결제 취소" + + @JvmField + val paymentRequest: PaymentApprove.Request = PaymentApprove.Request( + paymentKey, + orderId, + amount, + paymentType + ) + + @JvmField + val paymentRequestJson: String = """ { - "paymentKey": "5EnNZRJGvaBX7zk2yd8ydw26XvwXkLrx9POLqKQjmAw4b0e1", - "orderId": "MC4wODU4ODQwMzg4NDk0", - "amount": 1000, - "paymentType": "카드" + "paymentKey": "$paymentKey", + "orderId": "$orderId", + "amount": $amount, + "paymentType": "$paymentType" } - """; + """.trimIndent() - public static final PaymentCancelRequest cancelRequest = new PaymentCancelRequest( - "5EnNZRJGvaBX7zk2yd8ydw26XvwXkLrx9POLqKQjmAw4b0e1", 1000L, "테스트 결제 취소"); + @JvmField + val cancelRequest: PaymentCancel.Request = PaymentCancel.Request( + paymentKey, + amount, + cancelReason + ) - public static final String cancelRequestJson = """ + @JvmField + val cancelRequestJson: String = """ { - "cancelReason": "테스트 결제 취소" + "cancelReason": "$cancelReason" } - """; + """.trimIndent() - public static final String tossPaymentErrorJson = """ + @JvmField + val tossPaymentErrorJson: String = """ { "code": "ERROR_CODE", "message": "Error message" - } - """; + } + """.trimIndent() - public static final String confirmJson = """ + @JvmField + val confirmJson: String = """ { "mId": "tosspayments", "lastTransactionKey": "9C62B18EEF0DE3EB7F4422EB6D14BC6E", - "paymentKey": "5EnNZRJGvaBX7zk2yd8ydw26XvwXkLrx9POLqKQjmAw4b0e1", - "orderId": "MC4wODU4ODQwMzg4NDk0", + "paymentKey": "$paymentKey", + "orderId": "$orderId", "orderName": "토스 티셔츠 외 2건", "taxExemptionAmount": 0, "status": "DONE", @@ -88,22 +117,23 @@ public class SampleTossPaymentConst { "url": "https://api.tosspayments.com/v1/payments/5EnNZRJGvaBX7zk2yd8ydw26XvwXkLrx9POLqKQjmAw4b0e1/checkout" }, "currency": "KRW", - "totalAmount": 1000, - "balanceAmount": 1000, - "suppliedAmount": 909, - "vat": 91, + "totalAmount": $amount, + "balanceAmount": $amount, + "suppliedAmount": ${(amount / 1.1).roundToLong()}, + "vat": ${amount - (amount / 1.1).roundToLong()}, "taxFreeAmount": 0, - "method": "카드", + "method": "$paymentType", "version": "2022-11-16" } - """; + """.trimIndent() - public static final String cancelJson = """ + @JvmField + val cancelJson: String = """ { "mId": "tosspayments", "lastTransactionKey": "090A796806E726BBB929F4A2CA7DB9A7", - "paymentKey": "5EnNZRJGvaBX7zk2yd8ydw26XvwXkLrx9POLqKQjmAw4b0e1", - "orderId": "MC4wODU4ODQwMzg4NDk0", + "paymentKey": "$paymentKey", + "orderId": "$orderId", "orderName": "토스 티셔츠 외 2건", "taxExemptionAmount": 0, "status": "CANCELED", @@ -135,12 +165,12 @@ public class SampleTossPaymentConst { "cancels": [ { "transactionKey": "090A796806E726BBB929F4A2CA7DB9A7", - "cancelReason": "테스트 결제 취소", + "cancelReason": "$cancelReason", "taxExemptionAmount": 0, "canceledAt": "2024-02-13T12:20:23+09:00", "easyPayDiscountAmount": 0, "receiptKey": null, - "cancelAmount": 1000, + "cancelAmount": $amount, "taxFreeAmount": 0, "refundableAmount": 0, "cancelStatus": "DONE", @@ -166,13 +196,17 @@ public class SampleTossPaymentConst { "url": "https://api.tosspayments.com/v1/payments/5EnNZRJGvaBX7zk2yd8ydw26XvwXkLrx9POLqKQjmAw4b0e1/checkout" }, "currency": "KRW", - "totalAmount": 1000, + "totalAmount": $amount, "balanceAmount": 0, "suppliedAmount": 0, "vat": 0, "taxFreeAmount": 0, - "method": "카드", + "method": "$paymentType", "version": "2022-11-16" } - """; + """.trimIndent() } + +fun main() { + println((amount / 1.1).roundToLong()) +} \ No newline at end of file diff --git a/src/test/java/roomescape/payment/business/PaymentServiceKTest.kt b/src/test/java/roomescape/payment/business/PaymentServiceKTest.kt new file mode 100644 index 00000000..e72b228c --- /dev/null +++ b/src/test/java/roomescape/payment/business/PaymentServiceKTest.kt @@ -0,0 +1,129 @@ +package roomescape.payment.business + +import io.kotest.assertions.assertSoftly +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import org.springframework.http.HttpStatus +import roomescape.common.exception.ErrorType +import roomescape.common.exception.RoomescapeException +import roomescape.payment.infrastructure.persistence.CanceledPaymentRepository +import roomescape.payment.infrastructure.persistence.PaymentRepository +import roomescape.payment.web.PaymentCancel +import roomescape.util.PaymentFixture +import java.time.OffsetDateTime + +class PaymentServiceKTest : FunSpec({ + val paymentRepository: PaymentRepository = mockk() + val canceledPaymentRepository: CanceledPaymentRepository = mockk() + + val paymentService = PaymentService(paymentRepository, canceledPaymentRepository) + + context("cancelPaymentByAdmin") { + val reservationId = 1L + test("reservationId로 paymentKey를 찾을 수 없으면 예외를 던진다.") { + every { paymentRepository.findPaymentKeyByReservationId(reservationId) } returns null + + val exception = shouldThrow { + paymentService.cancelPaymentByAdmin(reservationId) + } + + assertSoftly(exception) { + this.errorType shouldBe ErrorType.PAYMENT_NOT_FOUND + this.httpStatus shouldBe HttpStatus.NOT_FOUND + } + } + + context("reservationId로 paymentKey를 찾고난 후") { + val paymentKey = "test-payment-key" + + every { + paymentRepository.findPaymentKeyByReservationId(reservationId) + } returns paymentKey + + test("해당 paymentKey로 paymentEntity를 찾을 수 없으면 예외를 던진다.") { + every { + paymentRepository.findByPaymentKey(paymentKey) + } returns null + + val exception = shouldThrow { + paymentService.cancelPaymentByAdmin(reservationId) + } + + assertSoftly(exception) { + this.errorType shouldBe ErrorType.PAYMENT_NOT_FOUND + this.httpStatus shouldBe HttpStatus.NOT_FOUND + } + } + + test("해당 paymentKey로 paymentEntity를 찾고, cancelPaymentEntity를 저장한다.") { + val paymentEntity = PaymentFixture.create(paymentKey = paymentKey) + + every { + paymentRepository.findByPaymentKey(paymentKey) + } returns paymentEntity.also { + every { + paymentRepository.delete(it) + } just runs + } + + every { + canceledPaymentRepository.save(any()) + } returns PaymentFixture.createCanceled( + id = 1L, + paymentKey = paymentKey, + cancelAmount = paymentEntity.totalAmount, + ) + + val result: PaymentCancel.Request = paymentService.cancelPaymentByAdmin(reservationId) + + assertSoftly(result) { + this.paymentKey shouldBe paymentKey + this.amount shouldBe paymentEntity.totalAmount + this.cancelReason shouldBe "고객 요청" + } + } + } + } + + context("updateCanceledTime") { + val paymentKey = "test-payment-key" + val canceledAt = OffsetDateTime.now() + + test("paymentKey로 canceledPaymentEntity를 찾을 수 없으면 예외를 던진다.") { + every { + canceledPaymentRepository.findByPaymentKey(paymentKey) + } returns null + + val exception = shouldThrow { + paymentService.updateCanceledTime(paymentKey, canceledAt) + } + + assertSoftly(exception) { + this.errorType shouldBe ErrorType.PAYMENT_NOT_FOUND + this.httpStatus shouldBe HttpStatus.NOT_FOUND + } + } + + test("paymentKey로 canceledPaymentEntity를 찾고, canceledAt을 업데이트한다.") { + val canceledPaymentEntity = PaymentFixture.createCanceled( + paymentKey = paymentKey, + canceledAt = canceledAt.minusMinutes(1) + ) + + every { + canceledPaymentRepository.findByPaymentKey(paymentKey) + } returns canceledPaymentEntity + + paymentService.updateCanceledTime(paymentKey, canceledAt) + + assertSoftly(canceledPaymentEntity) { + this.canceledAt shouldBe canceledAt + } + } + } +}) diff --git a/src/test/java/roomescape/payment/service/PaymentServiceTest.java b/src/test/java/roomescape/payment/business/PaymentServiceTest.java similarity index 78% rename from src/test/java/roomescape/payment/service/PaymentServiceTest.java rename to src/test/java/roomescape/payment/business/PaymentServiceTest.java index 19f28cde..10a5dbb8 100644 --- a/src/test/java/roomescape/payment/service/PaymentServiceTest.java +++ b/src/test/java/roomescape/payment/business/PaymentServiceTest.java @@ -1,4 +1,4 @@ -package roomescape.payment.service; +package roomescape.payment.business; import static org.assertj.core.api.Assertions.*; @@ -13,19 +13,20 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.jdbc.Sql; import org.springframework.test.context.jdbc.Sql.ExecutionPhase; +import roomescape.common.exception.RoomescapeException; import roomescape.member.infrastructure.persistence.Member; import roomescape.member.infrastructure.persistence.MemberRepository; import roomescape.member.infrastructure.persistence.Role; -import roomescape.payment.domain.repository.CanceledPaymentRepository; -import roomescape.payment.dto.request.PaymentCancelRequest; -import roomescape.payment.dto.response.PaymentResponse; -import roomescape.payment.dto.response.ReservationPaymentResponse; +import roomescape.payment.infrastructure.persistence.CanceledPaymentEntity; +import roomescape.payment.infrastructure.persistence.CanceledPaymentRepository; +import roomescape.payment.web.PaymentApprove; +import roomescape.payment.web.PaymentCancel; +import roomescape.payment.web.ReservationPaymentResponse; import roomescape.reservation.domain.Reservation; import roomescape.reservation.domain.ReservationStatus; import roomescape.reservation.domain.ReservationTime; import roomescape.reservation.domain.repository.ReservationRepository; import roomescape.reservation.domain.repository.ReservationTimeRepository; -import roomescape.common.exception.RoomescapeException; import roomescape.theme.domain.Theme; import roomescape.theme.domain.repository.ThemeRepository; @@ -50,7 +51,8 @@ class PaymentServiceTest { @DisplayName("결제 정보를 저장한다.") void savePayment() { // given - PaymentResponse paymentInfo = new PaymentResponse("payment-key", "order-id", OffsetDateTime.now(), 10000L); + PaymentApprove.Response paymentInfo = new PaymentApprove.Response("payment-key", "order-id", + OffsetDateTime.now(), 10000L); LocalDateTime localDateTime = LocalDateTime.now().plusHours(1L); LocalDate date = localDateTime.toLocalDate(); ReservationTime time = reservationTimeRepository.save(new ReservationTime(localDateTime.toLocalTime())); @@ -64,14 +66,15 @@ class PaymentServiceTest { // then assertThat(reservationPaymentResponse.reservation().id()).isEqualTo(reservation.getId()); - assertThat(reservationPaymentResponse.paymentKey()).isEqualTo(paymentInfo.paymentKey()); + assertThat(reservationPaymentResponse.paymentKey()).isEqualTo(paymentInfo.paymentKey); } @Test @DisplayName("예약 ID로 결제 정보를 제거하고, 결제 취소 테이블에 취소 정보를 저장한다.") void cancelPaymentByAdmin() { // given - PaymentResponse paymentInfo = new PaymentResponse("payment-key", "order-id", OffsetDateTime.now(), 10000L); + PaymentApprove.Response paymentInfo = new PaymentApprove.Response("payment-key", "order-id", + OffsetDateTime.now(), 10000L); LocalDateTime localDateTime = LocalDateTime.now().plusHours(1L); LocalDate date = localDateTime.toLocalDate(); ReservationTime time = reservationTimeRepository.save(new ReservationTime(localDateTime.toLocalTime())); @@ -83,13 +86,13 @@ class PaymentServiceTest { paymentService.savePayment(paymentInfo, reservation); // when - PaymentCancelRequest paymentCancelRequest = paymentService.cancelPaymentByAdmin(reservation.getId()); + PaymentCancel.Request paymentCancelRequest = paymentService.cancelPaymentByAdmin(reservation.getId()); // then - assertThat(canceledPaymentRepository.findByPaymentKey("payment-key")).isNotEmpty(); - assertThat(paymentCancelRequest.paymentKey()).isEqualTo(paymentInfo.paymentKey()); - assertThat(paymentCancelRequest.cancelReason()).isEqualTo("고객 요청"); - assertThat(paymentCancelRequest.amount()).isEqualTo(10000L); + assertThat(canceledPaymentRepository.findByPaymentKey("payment-key")).isNotNull(); + assertThat(paymentCancelRequest.paymentKey).isEqualTo(paymentInfo.paymentKey); + assertThat(paymentCancelRequest.cancelReason).isEqualTo("고객 요청"); + assertThat(paymentCancelRequest.amount).isEqualTo(10000L); } @Test @@ -107,7 +110,8 @@ class PaymentServiceTest { @DisplayName("결제 취소 정보에 있는 취소 시간을 업데이트한다.") void updateCanceledTime() { // given - PaymentResponse paymentInfo = new PaymentResponse("payment-key", "order-id", OffsetDateTime.now(), 10000L); + PaymentApprove.Response paymentInfo = new PaymentApprove.Response("payment-key", "order-id", + OffsetDateTime.now(), 10000L); LocalDateTime localDateTime = LocalDateTime.now().plusHours(1L); LocalDate date = localDateTime.toLocalDate(); ReservationTime time = reservationTimeRepository.save(new ReservationTime(localDateTime.toLocalTime())); @@ -121,11 +125,13 @@ class PaymentServiceTest { // when OffsetDateTime canceledAt = OffsetDateTime.now().plusHours(2L); - paymentService.updateCanceledTime(paymentInfo.paymentKey(), canceledAt); + paymentService.updateCanceledTime(paymentInfo.paymentKey, canceledAt); // then - canceledPaymentRepository.findByPaymentKey(paymentInfo.paymentKey()) - .ifPresent(canceledPayment -> assertThat(canceledPayment.getCanceledAt()).isEqualTo(canceledAt)); + CanceledPaymentEntity canceledPayment = canceledPaymentRepository.findByPaymentKey(paymentInfo.paymentKey); + + assertThat(canceledPayment).isNotNull(); + assertThat(canceledPayment.getCanceledAt()).isEqualTo(canceledAt); } @Test diff --git a/src/test/java/roomescape/payment/client/TossPaymentClientTest.java b/src/test/java/roomescape/payment/client/TossPaymentClientTest.java deleted file mode 100644 index 98418893..00000000 --- a/src/test/java/roomescape/payment/client/TossPaymentClientTest.java +++ /dev/null @@ -1,116 +0,0 @@ -package roomescape.payment.client; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; -import static org.springframework.test.web.client.match.MockRestRequestMatchers.*; -import static org.springframework.test.web.client.response.MockRestResponseCreators.*; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.client.RestClientTest; -import org.springframework.http.HttpMethod; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.test.web.client.MockRestServiceServer; - -import roomescape.payment.dto.request.PaymentCancelRequest; -import roomescape.payment.dto.request.PaymentRequest; -import roomescape.payment.dto.response.PaymentCancelResponse; -import roomescape.payment.dto.response.PaymentResponse; -import roomescape.common.exception.ErrorType; -import roomescape.common.exception.RoomescapeException; - -@RestClientTest(TossPaymentClient.class) -class TossPaymentClientTest { - - @Autowired - private TossPaymentClient tossPaymentClient; - - @Autowired - private MockRestServiceServer mockServer; - - @Test - @DisplayName("결제를 승인한다.") - void confirmPayment() { - // given - mockServer.expect(requestTo("/v1/payments/confirm")) - .andExpect(method(HttpMethod.POST)) - .andExpect(content().contentType(MediaType.APPLICATION_JSON)) - .andExpect(content().json(SampleTossPaymentConst.paymentRequestJson)) - .andRespond(withStatus(HttpStatus.OK) - .contentType(MediaType.APPLICATION_JSON) - .body(SampleTossPaymentConst.confirmJson)); - - // when - PaymentRequest paymentRequest = SampleTossPaymentConst.paymentRequest; - PaymentResponse paymentResponse = tossPaymentClient.confirmPayment(paymentRequest); - - // then - assertThat(paymentResponse.paymentKey()).isEqualTo(paymentRequest.paymentKey()); - assertThat(paymentResponse.orderId()).isEqualTo(paymentRequest.orderId()); - } - - @Test - @DisplayName("결제를 취소한다.") - void cancelPayment() { - // given - mockServer.expect(requestTo("/v1/payments/5EnNZRJGvaBX7zk2yd8ydw26XvwXkLrx9POLqKQjmAw4b0e1/cancel")) - .andExpect(method(HttpMethod.POST)) - .andExpect(content().contentType(MediaType.APPLICATION_JSON)) - .andExpect(content().json(SampleTossPaymentConst.cancelRequestJson)) - .andRespond(withStatus(HttpStatus.OK) - .contentType(MediaType.APPLICATION_JSON) - .body(SampleTossPaymentConst.cancelJson)); - - // when - PaymentCancelRequest cancelRequest = SampleTossPaymentConst.cancelRequest; - PaymentCancelResponse paymentCancelResponse = tossPaymentClient.cancelPayment(cancelRequest); - - // then - assertThat(paymentCancelResponse.cancelStatus()).isEqualTo("DONE"); - assertThat(paymentCancelResponse.cancelReason()).isEqualTo(cancelRequest.cancelReason()); - } - - @Test - @DisplayName("결제 승인 중 400 에러가 발생한다.") - void confirmPaymentWithError() { - // given - mockServer.expect(requestTo("/v1/payments/confirm")) - .andExpect(method(HttpMethod.POST)) - .andExpect(content().contentType(MediaType.APPLICATION_JSON)) - .andExpect(content().json(SampleTossPaymentConst.paymentRequestJson)) - .andRespond(withStatus(HttpStatus.BAD_REQUEST) - .contentType(MediaType.APPLICATION_JSON) - .body(SampleTossPaymentConst.tossPaymentErrorJson)); - - // when & then - assertThatThrownBy(() -> tossPaymentClient.confirmPayment(SampleTossPaymentConst.paymentRequest)) - .isInstanceOf(RoomescapeException.class) - .hasFieldOrPropertyWithValue("errorType", ErrorType.PAYMENT_ERROR) - .hasFieldOrPropertyWithValue("invalidValue", - "[ErrorCode = ERROR_CODE, ErrorMessage = Error message]") - .hasFieldOrPropertyWithValue("httpStatus", HttpStatus.BAD_REQUEST); - } - - @Test - @DisplayName("결제 취소 중 500 에러가 발생한다.") - void cancelPaymentWithError() { - // given - mockServer.expect(requestTo("/v1/payments/5EnNZRJGvaBX7zk2yd8ydw26XvwXkLrx9POLqKQjmAw4b0e1/cancel")) - .andExpect(method(HttpMethod.POST)) - .andExpect(content().contentType(MediaType.APPLICATION_JSON)) - .andExpect(content().json(SampleTossPaymentConst.cancelRequestJson)) - .andRespond(withStatus(HttpStatus.INTERNAL_SERVER_ERROR) - .contentType(MediaType.APPLICATION_JSON) - .body(SampleTossPaymentConst.tossPaymentErrorJson)); - - // when & then - assertThatThrownBy(() -> tossPaymentClient.cancelPayment(SampleTossPaymentConst.cancelRequest)) - .isInstanceOf(RoomescapeException.class) - .hasFieldOrPropertyWithValue("errorType", ErrorType.PAYMENT_SERVER_ERROR) - .hasFieldOrPropertyWithValue("invalidValue", - "[ErrorCode = ERROR_CODE, ErrorMessage = Error message]") - .hasFieldOrPropertyWithValue("httpStatus", HttpStatus.INTERNAL_SERVER_ERROR); - } -} diff --git a/src/test/java/roomescape/payment/domain/CanceledPaymentTest.java b/src/test/java/roomescape/payment/domain/CanceledPaymentTest.java deleted file mode 100644 index 019c4da3..00000000 --- a/src/test/java/roomescape/payment/domain/CanceledPaymentTest.java +++ /dev/null @@ -1,22 +0,0 @@ -package roomescape.payment.domain; - -import static org.assertj.core.api.Assertions.*; - -import java.time.OffsetDateTime; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -import roomescape.common.exception.RoomescapeException; - -class CanceledPaymentTest { - - @Test - @DisplayName("취소 날짜가 승인 날짜 이전이면 예외가 발생한다") - void invalidDate() { - OffsetDateTime approvedAt = OffsetDateTime.now(); - OffsetDateTime canceledAt = approvedAt.minusMinutes(1L); - assertThatThrownBy(() -> new CanceledPayment("payment-key", "reason", 10000L, approvedAt, canceledAt)) - .isInstanceOf(RoomescapeException.class); - } -} \ No newline at end of file diff --git a/src/test/java/roomescape/payment/domain/PaymentTest.java b/src/test/java/roomescape/payment/domain/PaymentTest.java deleted file mode 100644 index 7b2030d7..00000000 --- a/src/test/java/roomescape/payment/domain/PaymentTest.java +++ /dev/null @@ -1,78 +0,0 @@ -package roomescape.payment.domain; - -import static org.assertj.core.api.Assertions.*; - -import java.time.LocalDate; -import java.time.LocalTime; -import java.time.OffsetDateTime; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.CsvSource; -import org.junit.jupiter.params.provider.NullAndEmptySource; -import org.junit.jupiter.params.provider.NullSource; - -import roomescape.member.infrastructure.persistence.Member; -import roomescape.member.infrastructure.persistence.Role; -import roomescape.reservation.domain.Reservation; -import roomescape.reservation.domain.ReservationStatus; -import roomescape.reservation.domain.ReservationTime; -import roomescape.common.exception.RoomescapeException; -import roomescape.theme.domain.Theme; - -class PaymentTest { - - private Reservation reservation; - - @BeforeEach - void setUp() { - LocalDate now = LocalDate.now(); - ReservationTime reservationTime = new ReservationTime(LocalTime.now()); - Theme theme = new Theme("name", "desc", "thumb"); - Member member = new Member(null, "name", "email", "password", Role.MEMBER); - - reservation = new Reservation(now, reservationTime, theme, member, ReservationStatus.CONFIRMED); - } - - @ParameterizedTest - @DisplayName("paymentKey가 null 또는 빈값이면 예외가 발생한다.") - @NullAndEmptySource - void invalidPaymentKey(String paymentKey) { - assertThatThrownBy(() -> new Payment("order-id", paymentKey, 10000L, reservation, OffsetDateTime.now())) - .isInstanceOf(RoomescapeException.class); - } - - @ParameterizedTest - @DisplayName("orderId가 null 또는 빈값이면 예외가 발생한다.") - @NullAndEmptySource - void invalidOrderId(String orderId) { - assertThatThrownBy(() -> new Payment(orderId, "payment-key", 10000L, reservation, OffsetDateTime.now())) - .isInstanceOf(RoomescapeException.class); - } - - @ParameterizedTest - @DisplayName("amount가 null 또는 0 이하면 예외가 발생한다.") - @CsvSource(value = {"null", "-1"}, nullValues = {"null"}) - void invalidOrderId(Long totalAmount) { - assertThatThrownBy( - () -> new Payment("orderId", "payment-key", totalAmount, reservation, OffsetDateTime.now())) - .isInstanceOf(RoomescapeException.class); - } - - @ParameterizedTest - @DisplayName("Reservation이 null이면 예외가 발생한다.") - @NullSource - void invalidReservation(Reservation reservation) { - assertThatThrownBy(() -> new Payment("orderId", "payment-key", 10000L, reservation, OffsetDateTime.now())) - .isInstanceOf(RoomescapeException.class); - } - - @ParameterizedTest - @DisplayName("승인 날짜가 null이면 예외가 발생한다.") - @NullSource - void invalidApprovedAt(OffsetDateTime approvedAt) { - assertThatThrownBy(() -> new Payment("orderId", "payment-key", 10000L, reservation, approvedAt)) - .isInstanceOf(RoomescapeException.class); - } -} diff --git a/src/test/java/roomescape/payment/dto/response/PaymentCancelResponseDeserializerTest.java b/src/test/java/roomescape/payment/dto/response/PaymentCancelResponseDeserializerTest.java deleted file mode 100644 index 7ada8331..00000000 --- a/src/test/java/roomescape/payment/dto/response/PaymentCancelResponseDeserializerTest.java +++ /dev/null @@ -1,52 +0,0 @@ -package roomescape.payment.dto.response; - -import static org.junit.jupiter.api.Assertions.*; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.module.SimpleModule; - -class PaymentCancelResponseDeserializerTest { - - private ObjectMapper objectMapper; - - @BeforeEach - void setUp() { - objectMapper = new ObjectMapper(); - SimpleModule simpleModule = new SimpleModule(); - simpleModule.addDeserializer(PaymentCancelResponse.class, new PaymentCancelResponseDeserializer()); - objectMapper.registerModule(simpleModule); - } - - @Test - @DisplayName("결제 취소 정보를 역직렬화하여 PaymentCancelResponse 객체를 생성한다.") - void deserialize() { - // given - String json = """ - { - "notUsedField": "notUsedValue", - "cancels": [ - { - "cancelStatus": "CANCELLED", - "cancelReason": "테스트 결제 취소", - "cancelAmount": 10000, - "canceledAt": "2021-07-01T10:10:10+09:00", - "notUsedField": "notUsedValue" - } - ] - }"""; - - // when - PaymentCancelResponse response = assertDoesNotThrow( - () -> objectMapper.readValue(json, PaymentCancelResponse.class)); - - // then - assertEquals("CANCELLED", response.cancelStatus()); - assertEquals("테스트 결제 취소", response.cancelReason()); - assertEquals(10000, response.cancelAmount()); - assertEquals("2021-07-01T10:10:10+09:00", response.canceledAt().toString()); - } -} diff --git a/src/test/java/roomescape/payment/infrastructure/client/TossPaymentClientTest.kt b/src/test/java/roomescape/payment/infrastructure/client/TossPaymentClientTest.kt new file mode 100644 index 00000000..a36e409e --- /dev/null +++ b/src/test/java/roomescape/payment/infrastructure/client/TossPaymentClientTest.kt @@ -0,0 +1,139 @@ +package roomescape.payment.infrastructure.client + +import io.kotest.assertions.assertSoftly +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.client.RestClientTest +import org.springframework.http.HttpMethod +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.test.web.client.MockRestServiceServer +import org.springframework.test.web.client.ResponseActions +import org.springframework.test.web.client.match.MockRestRequestMatchers.* +import org.springframework.test.web.client.response.MockRestResponseCreators.withStatus +import org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess +import roomescape.common.exception.ErrorType +import roomescape.common.exception.RoomescapeException +import roomescape.payment.SampleTossPaymentConst +import roomescape.payment.web.PaymentApprove +import roomescape.payment.web.PaymentCancel + +@RestClientTest(TossPaymentClient::class) +class TossPaymentClientTest( + @Autowired val client: TossPaymentClient, + @Autowired val mockServer: MockRestServiceServer +) : FunSpec() { + + init { + context("결제 승인 요청") { + fun commonAction(): ResponseActions = mockServer.expect { + requestTo("/v1/payments/confirm") + }.andExpect { + method(HttpMethod.POST) + }.andExpect { + content().contentType(MediaType.APPLICATION_JSON) + }.andExpect { + content().json(SampleTossPaymentConst.paymentRequestJson) + } + + test("성공 응답") { + commonAction().andRespond { + withSuccess() + .contentType(MediaType.APPLICATION_JSON) + .body(SampleTossPaymentConst.confirmJson) + .createResponse(it) + } + + // when + val paymentRequest = SampleTossPaymentConst.paymentRequest + val paymentResponse: PaymentApprove.Response = client.confirmPayment(paymentRequest) + + assertSoftly(paymentResponse) { + this.paymentKey shouldBe paymentRequest.paymentKey + this.orderId shouldBe paymentRequest.orderId + this.totalAmount shouldBe paymentRequest.amount + } + } + + test("400 에러 발생") { + commonAction().andRespond { + withStatus(HttpStatus.BAD_REQUEST) + .contentType(MediaType.APPLICATION_JSON) + .body(SampleTossPaymentConst.tossPaymentErrorJson) + .createResponse(it) + } + + // when + val paymentRequest = SampleTossPaymentConst.paymentRequest + + // then + val exception = shouldThrow { + client.confirmPayment(paymentRequest) + } + + assertSoftly(exception) { + this.errorType shouldBe ErrorType.PAYMENT_ERROR + this.invalidValue shouldBe "[ErrorCode = ERROR_CODE, ErrorMessage = Error message]" + this.httpStatus shouldBe HttpStatus.BAD_REQUEST + } + + } + } + + context("결제 취소 요청") { + fun commonAction(): ResponseActions = mockServer.expect { + requestTo("/v1/payments/${SampleTossPaymentConst.paymentKey}/cancel") + }.andExpect { + method(HttpMethod.POST) + }.andExpect { + content().contentType(MediaType.APPLICATION_JSON) + }.andExpect { + content().json(SampleTossPaymentConst.cancelRequestJson) + } + + test("성공 응답") { + commonAction().andRespond { + withSuccess() + .contentType(MediaType.APPLICATION_JSON) + .body(SampleTossPaymentConst.cancelJson) + .createResponse(it) + } + + // when + val cancelRequest: PaymentCancel.Request = SampleTossPaymentConst.cancelRequest + val cancelResponse: PaymentCancel.Response = client.cancelPayment(cancelRequest) + + assertSoftly(cancelResponse) { + this.cancelStatus shouldBe "DONE" + this.cancelReason shouldBe cancelRequest.cancelReason + this.cancelAmount shouldBe cancelRequest.amount + } + } + + test("500 에러 발생") { + commonAction().andRespond { + withStatus(HttpStatus.INTERNAL_SERVER_ERROR) + .contentType(MediaType.APPLICATION_JSON) + .body(SampleTossPaymentConst.tossPaymentErrorJson) + .createResponse(it) + } + + // when + val cancelRequest: PaymentCancel.Request = SampleTossPaymentConst.cancelRequest + + // then + val exception = shouldThrow { + client.cancelPayment(cancelRequest) + } + + assertSoftly(exception) { + this.errorType shouldBe ErrorType.PAYMENT_SERVER_ERROR + this.invalidValue shouldBe "[ErrorCode = ERROR_CODE, ErrorMessage = Error message]" + this.httpStatus shouldBe HttpStatus.INTERNAL_SERVER_ERROR + } + } + } + } +} diff --git a/src/test/java/roomescape/payment/infrastructure/persistence/CanceledPaymentRepositoryTest.kt b/src/test/java/roomescape/payment/infrastructure/persistence/CanceledPaymentRepositoryTest.kt new file mode 100644 index 00000000..1043a737 --- /dev/null +++ b/src/test/java/roomescape/payment/infrastructure/persistence/CanceledPaymentRepositoryTest.kt @@ -0,0 +1,37 @@ +package roomescape.payment.infrastructure.persistence + +import io.kotest.assertions.assertSoftly +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import roomescape.util.PaymentFixture +import java.util.* + +@DataJpaTest +class CanceledPaymentRepositoryTest( + @Autowired val canceledPaymentRepository: CanceledPaymentRepository, +): FunSpec() { + init { + context("paymentKey로 CanceledPaymentEntity 조회") { + val paymentKey = "test-payment-key" + beforeTest { + PaymentFixture.createCanceled(paymentKey = paymentKey) + .also { canceledPaymentRepository.save(it) } + } + + test("정상 반환") { + canceledPaymentRepository.findByPaymentKey(paymentKey)?.let { + assertSoftly(it) { + this.paymentKey shouldBe paymentKey + } + } ?: throw AssertionError("Unexpected null value") + } + + test("null 반환") { + canceledPaymentRepository.findByPaymentKey(UUID.randomUUID().toString()) + .also { it shouldBe null } + } + } + } +} diff --git a/src/test/java/roomescape/payment/infrastructure/persistence/PaymentRepositoryTest.kt b/src/test/java/roomescape/payment/infrastructure/persistence/PaymentRepositoryTest.kt new file mode 100644 index 00000000..ab3217d0 --- /dev/null +++ b/src/test/java/roomescape/payment/infrastructure/persistence/PaymentRepositoryTest.kt @@ -0,0 +1,103 @@ +package roomescape.payment.infrastructure.persistence + +import io.kotest.assertions.assertSoftly +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import jakarta.persistence.EntityManager +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import roomescape.util.PaymentFixture +import roomescape.util.ReservationFixture + +@DataJpaTest +class PaymentRepositoryTest( + @Autowired val paymentRepository: PaymentRepository, + @Autowired val entityManager: EntityManager +) : FunSpec() { + + var reservationId: Long = 0L + + init { + context("existsByReservationId") { + beforeTest { + reservationId = setupReservation() + PaymentFixture.create(reservationId = reservationId) + .also { paymentRepository.save(it) } + } + + test("true") { + paymentRepository.existsByReservationId(reservationId) + .also { it shouldBe true } + } + + test("false") { + paymentRepository.existsByReservationId(reservationId + 1) + .also { it shouldBe false } + } + } + + context("findPaymentKeyByReservationId") { + lateinit var paymentKey: String + + beforeTest { + reservationId = setupReservation() + paymentKey = PaymentFixture.create(reservationId = reservationId) + .also { paymentRepository.save(it) } + .paymentKey + } + + test("정상 반환") { + paymentRepository.findPaymentKeyByReservationId(reservationId) + ?.let { it shouldBe paymentKey } + ?: throw AssertionError("Unexpected null value") + } + + test("null 반환") { + paymentRepository.findPaymentKeyByReservationId(reservationId + 1) + .also { it shouldBe null } + } + } + + context("findByPaymentKey") { + lateinit var payment: PaymentEntity + + beforeTest { + reservationId = setupReservation() + payment = PaymentFixture.create(reservationId = reservationId) + .also { paymentRepository.save(it) } + } + + test("정상 반환") { + paymentRepository.findByPaymentKey(payment.paymentKey) + ?.also { + assertSoftly(it) { + this.id shouldBe payment.id + this.orderId shouldBe payment.orderId + this.paymentKey shouldBe payment.paymentKey + this.totalAmount shouldBe payment.totalAmount + this.reservation.id shouldBe payment.reservation.id + this.approvedAt shouldBe payment.approvedAt + } + } + ?: throw AssertionError("Unexpected null value") + } + + test("null 반환") { + paymentRepository.findByPaymentKey("non-existent-key") + .also { it shouldBe null } + } + } + } + + private fun setupReservation(): Long { + return ReservationFixture.create().also { + entityManager.persist(it.member) + entityManager.persist(it.theme) + entityManager.persist(it.reservationTime) + entityManager.persist(it) + + entityManager.flush() + entityManager.clear() + }.id + } +} diff --git a/src/test/java/roomescape/payment/web/support/PaymentCancelResponseDeserializerTest.kt b/src/test/java/roomescape/payment/web/support/PaymentCancelResponseDeserializerTest.kt new file mode 100644 index 00000000..97908a0a --- /dev/null +++ b/src/test/java/roomescape/payment/web/support/PaymentCancelResponseDeserializerTest.kt @@ -0,0 +1,36 @@ +package roomescape.payment.web.support + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.module.SimpleModule +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import io.kotest.assertions.assertSoftly +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe +import roomescape.payment.SampleTossPaymentConst +import roomescape.payment.infrastructure.client.PaymentCancelResponseDeserializer +import roomescape.payment.web.PaymentCancel + +class PaymentCancelResponseDeserializerTest : StringSpec({ + + val objectMapper: ObjectMapper = jacksonObjectMapper().registerModule( + SimpleModule().addDeserializer( + PaymentCancel.Response::class.java, + PaymentCancelResponseDeserializer() + ) + ) + + "결제 취소 응답을 역직렬화하여 PaymentCancelResponse 객체를 생성한다" { + val cancelResponseJson: String = SampleTossPaymentConst.cancelJson + val cancelResponse: PaymentCancel.Response = objectMapper.readValue( + cancelResponseJson, + PaymentCancel.Response::class.java + ) + + assertSoftly(cancelResponse) { + cancelResponse.cancelStatus shouldBe "DONE" + cancelResponse.cancelReason shouldBe SampleTossPaymentConst.cancelReason + cancelResponse.cancelAmount shouldBe SampleTossPaymentConst.amount + cancelResponse.canceledAt.toString() shouldBe "2024-02-13T12:20:23+09:00" + } + } +}) diff --git a/src/test/java/roomescape/reservation/controller/ReservationControllerTest.java b/src/test/java/roomescape/reservation/controller/ReservationControllerTest.java index b5f625b1..a026ee41 100644 --- a/src/test/java/roomescape/reservation/controller/ReservationControllerTest.java +++ b/src/test/java/roomescape/reservation/controller/ReservationControllerTest.java @@ -2,6 +2,7 @@ package roomescape.reservation.controller; import static org.assertj.core.api.Assertions.*; import static org.hamcrest.Matchers.*; +import static org.mockito.ArgumentMatchers.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; @@ -35,15 +36,13 @@ import io.restassured.http.Header; import roomescape.member.infrastructure.persistence.Member; import roomescape.member.infrastructure.persistence.MemberRepository; import roomescape.member.infrastructure.persistence.Role; -import roomescape.payment.client.TossPaymentClient; -import roomescape.payment.domain.CanceledPayment; -import roomescape.payment.domain.Payment; -import roomescape.payment.domain.repository.CanceledPaymentRepository; -import roomescape.payment.domain.repository.PaymentRepository; -import roomescape.payment.dto.request.PaymentCancelRequest; -import roomescape.payment.dto.request.PaymentRequest; -import roomescape.payment.dto.response.PaymentCancelResponse; -import roomescape.payment.dto.response.PaymentResponse; +import roomescape.payment.infrastructure.client.TossPaymentClient; +import roomescape.payment.infrastructure.persistence.CanceledPaymentEntity; +import roomescape.payment.infrastructure.persistence.CanceledPaymentRepository; +import roomescape.payment.infrastructure.persistence.PaymentEntity; +import roomescape.payment.infrastructure.persistence.PaymentRepository; +import roomescape.payment.web.PaymentApprove; +import roomescape.payment.web.PaymentCancel; import roomescape.reservation.domain.Reservation; import roomescape.reservation.domain.ReservationStatus; import roomescape.reservation.domain.ReservationTime; @@ -104,8 +103,8 @@ public class ReservationControllerTest { "paymentType", "DEFAULT" ); - when(paymentClient.confirmPayment(any(PaymentRequest.class))).thenReturn( - new PaymentResponse("pk", "oi", OffsetDateTime.of(date, time, ZoneOffset.ofHours(9)), 1000L)); + when(paymentClient.confirmPayment(any(PaymentApprove.Request.class))).thenReturn( + new PaymentApprove.Response("pk", "oi", OffsetDateTime.of(date, time, ZoneOffset.ofHours(9)), 1000L)); RestAssured.given().log().all() .contentType(ContentType.JSON) @@ -399,12 +398,12 @@ public class ReservationControllerTest { Reservation saved = reservationRepository.save( new Reservation(date, time, theme, member, ReservationStatus.CONFIRMED)); - Payment savedPayment = paymentRepository.save( - new Payment("pk", "oi", 1000L, saved, OffsetDateTime.now().minusHours(1L))); + PaymentEntity savedPaymentEntity = paymentRepository.save( + new PaymentEntity(null, "pk", "oi", 1000L, saved, OffsetDateTime.now().minusHours(1L))); // when - when(paymentClient.cancelPayment(any(PaymentCancelRequest.class))) - .thenReturn(new PaymentCancelResponse("pk", "고객 요청", savedPayment.getTotalAmount(), + when(paymentClient.cancelPayment(any(PaymentCancel.Request.class))) + .thenReturn(new PaymentCancel.Response("pk", "고객 요청", savedPaymentEntity.getTotalAmount(), OffsetDateTime.now())); // then @@ -432,11 +431,11 @@ public class ReservationControllerTest { String paymentKey = "pk"; OffsetDateTime canceledAt = OffsetDateTime.now().plusHours(1L).withNano(0); OffsetDateTime approvedAt = OffsetDateTime.of(localDateTime, ZoneOffset.ofHours(9)); - when(paymentClient.confirmPayment(any(PaymentRequest.class))) - .thenReturn(new PaymentResponse(paymentKey, "oi", approvedAt, 1000L)); + when(paymentClient.confirmPayment(any(PaymentApprove.Request.class))) + .thenReturn(new PaymentApprove.Response(paymentKey, "oi", approvedAt, 1000L)); - when(paymentClient.cancelPayment(any(PaymentCancelRequest.class))) - .thenReturn(new PaymentCancelResponse(paymentKey, "고객 요청", 1000L, canceledAt)); + when(paymentClient.cancelPayment(any(PaymentCancel.Request.class))) + .thenReturn(new PaymentCancel.Response(paymentKey, "고객 요청", 1000L, canceledAt)); RestAssured.given().log().all() .contentType(ContentType.JSON) @@ -448,12 +447,12 @@ public class ReservationControllerTest { .statusCode(400); // then - Optional canceledPaymentOptional = canceledPaymentRepository.findByPaymentKey(paymentKey); - assertThat(canceledPaymentOptional).isNotNull(); - assertThat(canceledPaymentOptional.get().getCanceledAt()).isEqualTo(canceledAt); - assertThat(canceledPaymentOptional.get().getCancelReason()).isEqualTo("고객 요청"); - assertThat(canceledPaymentOptional.get().getCancelAmount()).isEqualTo(1000L); - assertThat(canceledPaymentOptional.get().getApprovedAt()).isEqualTo(approvedAt); + CanceledPaymentEntity canceledPayment = canceledPaymentRepository.findByPaymentKey(paymentKey); + assertThat(canceledPayment).isNotNull(); + assertThat(canceledPayment.getCanceledAt()).isEqualTo(canceledAt); + assertThat(canceledPayment.getCancelReason()).isEqualTo("고객 요청"); + assertThat(canceledPayment.getCancelAmount()).isEqualTo(1000L); + assertThat(canceledPayment.getApprovedAt()).isEqualTo(approvedAt); } @DisplayName("테마만을 이용하여 예약을 조회한다.") diff --git a/src/test/java/roomescape/reservation/service/ReservationWithPaymentServiceTest.java b/src/test/java/roomescape/reservation/service/ReservationWithPaymentServiceTest.java index fe0bec73..b0f589c3 100644 --- a/src/test/java/roomescape/reservation/service/ReservationWithPaymentServiceTest.java +++ b/src/test/java/roomescape/reservation/service/ReservationWithPaymentServiceTest.java @@ -16,10 +16,11 @@ import org.springframework.test.context.jdbc.Sql.ExecutionPhase; import roomescape.member.infrastructure.persistence.Member; import roomescape.member.infrastructure.persistence.MemberRepository; import roomescape.member.infrastructure.persistence.Role; -import roomescape.payment.domain.repository.CanceledPaymentRepository; -import roomescape.payment.domain.repository.PaymentRepository; -import roomescape.payment.dto.request.PaymentCancelRequest; -import roomescape.payment.dto.response.PaymentResponse; +import roomescape.payment.infrastructure.persistence.CanceledPaymentRepository; +import roomescape.payment.infrastructure.persistence.PaymentEntity; +import roomescape.payment.infrastructure.persistence.PaymentRepository; +import roomescape.payment.web.PaymentApprove; +import roomescape.payment.web.PaymentCancel; import roomescape.reservation.domain.Reservation; import roomescape.reservation.domain.ReservationStatus; import roomescape.reservation.domain.ReservationTime; @@ -53,7 +54,8 @@ class ReservationWithPaymentServiceTest { @DisplayName("예약과 결제 정보를 추가한다.") void addReservationWithPayment() { // given - PaymentResponse paymentInfo = new PaymentResponse("payment-key", "order-id", OffsetDateTime.now(), 10000L); + PaymentApprove.Response paymentInfo = new PaymentApprove.Response("payment-key", "order-id", + OffsetDateTime.now(), 10000L); LocalDateTime localDateTime = LocalDateTime.now().plusDays(1L).withNano(0); LocalDate date = localDateTime.toLocalDate(); ReservationTime time = reservationTimeRepository.save(new ReservationTime(localDateTime.toLocalTime())); @@ -75,20 +77,21 @@ class ReservationWithPaymentServiceTest { assertThat(reservation.getReservationTime().getId()).isEqualTo(time.getId()); assertThat(reservation.getReservationStatus()).isEqualTo(ReservationStatus.CONFIRMED); }); - paymentRepository.findByPaymentKey("payment-key") - .ifPresent(payment -> { - assertThat(payment.getReservation().getId()).isEqualTo(reservationResponse.id()); - assertThat(payment.getPaymentKey()).isEqualTo("payment-key"); - assertThat(payment.getOrderId()).isEqualTo("order-id"); - assertThat(payment.getTotalAmount()).isEqualTo(10000L); - }); + + PaymentEntity payment = paymentRepository.findByPaymentKey("payment-key"); + assertThat(payment).isNotNull(); + assertThat(payment.getReservation().getId()).isEqualTo(reservationResponse.id()); + assertThat(payment.getPaymentKey()).isEqualTo("payment-key"); + assertThat(payment.getOrderId()).isEqualTo("order-id"); + assertThat(payment.getTotalAmount()).isEqualTo(10000L); } @Test @DisplayName("예약 ID를 이용하여 예약과 결제 정보를 제거하고, 결제 취소 정보를 저장한다.") void removeReservationWithPayment() { // given - PaymentResponse paymentInfo = new PaymentResponse("payment-key", "order-id", OffsetDateTime.now(), 10000L); + PaymentApprove.Response paymentInfo = new PaymentApprove.Response("payment-key", "order-id", + OffsetDateTime.now(), 10000L); LocalDateTime localDateTime = LocalDateTime.now().plusDays(1L).withNano(0); LocalDate date = localDateTime.toLocalDate(); ReservationTime time = reservationTimeRepository.save(new ReservationTime(localDateTime.toLocalTime())); @@ -101,21 +104,22 @@ class ReservationWithPaymentServiceTest { reservationRequest, paymentInfo, member.getId()); // when - PaymentCancelRequest paymentCancelRequest = reservationWithPaymentService.removeReservationWithPayment( + PaymentCancel.Request paymentCancelRequest = reservationWithPaymentService.removeReservationWithPayment( reservationResponse.id(), member.getId()); // then - assertThat(paymentCancelRequest.cancelReason()).isEqualTo("고객 요청"); + assertThat(paymentCancelRequest.cancelReason).isEqualTo("고객 요청"); assertThat(reservationRepository.findById(reservationResponse.id())).isEmpty(); - assertThat(paymentRepository.findByPaymentKey("payment-key")).isEmpty(); - assertThat(canceledPaymentRepository.findByPaymentKey("payment-key")).isNotEmpty(); + assertThat(paymentRepository.findByPaymentKey("payment-key")).isNull(); + assertThat(canceledPaymentRepository.findByPaymentKey("payment-key")).isNotNull(); } @Test @DisplayName("결제 정보가 없으면 True를 반환한다.") void isNotPaidReservation() { // given - PaymentResponse paymentInfo = new PaymentResponse("payment-key", "order-id", OffsetDateTime.now(), 10000L); + PaymentApprove.Response paymentInfo = new PaymentApprove.Response("payment-key", "order-id", + OffsetDateTime.now(), 10000L); LocalDateTime localDateTime = LocalDateTime.now().plusHours(1L); LocalDate date = localDateTime.toLocalDate(); ReservationTime time = reservationTimeRepository.save(new ReservationTime(localDateTime.toLocalTime())); @@ -136,7 +140,8 @@ class ReservationWithPaymentServiceTest { @DisplayName("결제 정보가 있으면 False를 반환한다.") void isPaidReservation() { // given - PaymentResponse paymentInfo = new PaymentResponse("payment-key", "order-id", OffsetDateTime.now(), 10000L); + PaymentApprove.Response paymentInfo = new PaymentApprove.Response("payment-key", "order-id", + OffsetDateTime.now(), 10000L); LocalDateTime localDateTime = LocalDateTime.now().plusDays(1L).withNano(0); LocalDate date = localDateTime.toLocalDate(); ReservationTime time = reservationTimeRepository.save(new ReservationTime(localDateTime.toLocalTime())); diff --git a/src/test/java/roomescape/util/Fixtures.kt b/src/test/java/roomescape/util/Fixtures.kt index 4ae7a6b6..abe061ac 100644 --- a/src/test/java/roomescape/util/Fixtures.kt +++ b/src/test/java/roomescape/util/Fixtures.kt @@ -1,37 +1,81 @@ package roomescape.util -import roomescape.member.infrastructure.persistence.Member -import roomescape.member.infrastructure.persistence.Role import roomescape.auth.infrastructure.jwt.JwtHandler import roomescape.auth.web.LoginRequest -import java.util.concurrent.atomic.AtomicLong +import roomescape.member.infrastructure.persistence.Member +import roomescape.member.infrastructure.persistence.Role +import roomescape.payment.infrastructure.persistence.CanceledPaymentEntity +import roomescape.payment.infrastructure.persistence.PaymentEntity +import roomescape.payment.web.PaymentApprove +import roomescape.payment.web.PaymentCancel +import roomescape.reservation.domain.Reservation +import roomescape.reservation.domain.ReservationStatus +import roomescape.reservation.domain.ReservationTime +import roomescape.theme.domain.Theme +import java.time.LocalDate +import java.time.LocalTime +import java.time.OffsetDateTime +import kotlin.random.Random object MemberFixture { const val NOT_LOGGED_IN_USERID: Long = 0 - val idCounter: AtomicLong = AtomicLong(1L) - fun create( - id: Long? = idCounter.incrementAndGet(), + id: Long? = null, name: String = "sangdol", account: String = "default", password: String = "password", role: Role = Role.ADMIN ): Member = Member(id, name, "$account@email.com", password, role) - fun admin(): Member = create(account = "admin", role = Role.ADMIN) + fun admin(): Member = create( + id = 2L, + account = "admin", + role = Role.ADMIN + ) fun adminLoginRequest(): LoginRequest = LoginRequest( email = admin().email, password = admin().password ) - fun user(): Member = create(account = "user", role = Role.MEMBER) + fun user(): Member = create( + id = 1L, + account = "user", + role = Role.MEMBER + ) fun userLoginRequest(): LoginRequest = LoginRequest( email = user().email, password = user().password ) } +object ReservationTimeFixture { + fun create( + id: Long? = null, + startAt: LocalTime = LocalTime.now().plusHours(1), + ): ReservationTime = ReservationTime(id, startAt) +} + +object ThemeFixture { + fun create( + id: Long? = null, + name: String = "Default Theme", + description: String = "Default Description", + thumbnail: String = "https://example.com/default-thumbnail.jpg" + ): Theme = Theme(id, name, description, thumbnail) +} + +object ReservationFixture { + fun create( + id: Long? = null, + date: LocalDate = LocalDate.now().plusWeeks(1), + theme: Theme = ThemeFixture.create(), + reservationTime: ReservationTime = ReservationTimeFixture.create(), + member: Member = MemberFixture.create(), + status: ReservationStatus = ReservationStatus.CONFIRMED_PAYMENT_REQUIRED + ): Reservation = Reservation(id, date, reservationTime, theme, member, status) +} + object JwtFixture { const val SECRET_KEY: String = "daijawligagaf@LIJ$@U)9nagnalkkgalijaddljfi" const val EXPIRATION_TIME: Long = 1000 * 60 * 60 @@ -41,3 +85,70 @@ object JwtFixture { expirationTime: Long = EXPIRATION_TIME ): JwtHandler = JwtHandler(secretKey, expirationTime) } + +object PaymentFixture { + const val PAYMENT_KEY: String = "paymentKey" + const val ORDER_ID: String = "orderId" + const val AMOUNT: Long = 10000L + + fun create( + id: Long? = null, + orderId: String = ORDER_ID, + paymentKey: String = PAYMENT_KEY, + totalAmount: Long = AMOUNT, + reservationId: Long = Random.nextLong(), + approvedAt: OffsetDateTime = OffsetDateTime.now() + ): PaymentEntity = PaymentEntity( + id = id, + orderId = orderId, + paymentKey = paymentKey, + totalAmount = totalAmount, + reservation = ReservationFixture.create(id = reservationId), + approvedAt = approvedAt + ) + + fun createCanceled( + id: Long? = null, + paymentKey: String = PAYMENT_KEY, + cancelReason: String = "Test Cancel", + cancelAmount: Long = AMOUNT, + approvedAt: OffsetDateTime = OffsetDateTime.now(), + canceledAt: OffsetDateTime = approvedAt.plusHours(1) + ): CanceledPaymentEntity = CanceledPaymentEntity( + id = id, + paymentKey = paymentKey, + cancelReason = cancelReason, + cancelAmount = cancelAmount, + approvedAt = approvedAt, + canceledAt = canceledAt + + ) + + fun createApproveRequest(): PaymentApprove.Request = PaymentApprove.Request( + paymentKey = PAYMENT_KEY, + orderId = ORDER_ID, + amount = AMOUNT, + paymentType = "CARD" + ) + + fun createApproveResponse(): PaymentApprove.Response = PaymentApprove.Response( + paymentKey = PAYMENT_KEY, + orderId = ORDER_ID, + approvedAt = OffsetDateTime.now(), + totalAmount = AMOUNT + ) + + fun createCancelRequest(): PaymentCancel.Request = PaymentCancel.Request( + paymentKey = PAYMENT_KEY, + amount = AMOUNT, + cancelReason = "Test Cancel" + ) + + fun createCancelResponse(): PaymentCancel.Response = PaymentCancel.Response( + cancelStatus = "SUCCESS", + cancelReason = "Test Cancel", + cancelAmount = AMOUNT, + canceledAt = OffsetDateTime.now().plusMinutes(1) + ) +} + diff --git a/src/test/resources/application.yaml b/src/test/resources/application.yaml index c9b0197a..9bc65da2 100644 --- a/src/test/resources/application.yaml +++ b/src/test/resources/application.yaml @@ -9,15 +9,6 @@ spring: sql: init: data-locations: - h2: - console: - enabled: true - path: /h2-console - datasource: - driver-class-name: org.h2.Driver - url: jdbc:h2:mem:database-test - username: sa - password: security: jwt: @@ -25,3 +16,14 @@ security: secret-key: daijawligagaf@LIJ$@U)9nagnalkkgalijaddljfi access: expire-length: 1800000 # 30 분 + +payment: + api-base-url: https://api.tosspayments.com + confirm-secret-key: test_gsk_docs_OaPz8L5KdmQXkzRz3y47BMw6 + read-timeout: 3 + connect-timeout: 30 + +logging: + level: + org.springframework.orm.jpa: DEBUG + org.springframework.transaction: DEBUG