[#11] Payment 도메인 코드 코틀린 마이그레이션 (#12)

<!-- 제목 양식 -->
<!-- [이슈번호] 작업 요약 (예시: [#10] Gitea 템플릿 생성) -->

## 📝 관련 이슈 및 PR
**PR과 관련된 이슈 번호**
- #11

##  작업 내용
<!-- 어떤 작업을 했는지 알려주세요! -->
payment 패키지 내 코드, 테스트를 코틀린으로 전환했고 일부 로직은 개선하였음. 전체적으로 구조를 개선하려고 했으나, 얽혀있는 예약 관련 로직이 많아 전체 코드의 코틀린 전환이 끝난 이후 개선할 예정

## 🧪 테스트
<!-- 어떤 테스트를 생각했고 진행했는지 알려주세요! -->
1. \@DataJpaTest를 이용하는 Repository 테스트를 추가
2. Service는 mocking 방식으로 수정하였고, 테스트가 불필요하다고 여겨지는 단순 로직(변환 또는 Repository만 사용하는 경우)은 제외하였음. (8577b68496)
- 전체 로직이 테스트되어있는 기존의 테스트는 유지하였고, 전체 코틀린 전환이 마무리 된 후 제거 예정

## 📚 참고 자료 및 기타
<!-- 참고한 자료, 또는 논의할 사항이 있다면 알려주세요! -->

Reviewed-on: #12
Co-authored-by: pricelees <priceelees@gmail.com>
Co-committed-by: pricelees <priceelees@gmail.com>
This commit is contained in:
이상진 2025-07-16 09:19:28 +00:00 committed by 이상진
parent 19da58c1f3
commit ed383c3092
48 changed files with 1195 additions and 955 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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<String, String> 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;
}
}

View File

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

View File

@ -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 <T> 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;
}
}

View File

@ -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<CanceledPayment, Long> {
Optional<CanceledPayment> findByPaymentKey(String paymentKey);
}

View File

@ -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<Payment, Long> {
Optional<Payment> findByReservationId(Long reservationId);
Optional<Payment> findByPaymentKey(String paymentKey);
}

View File

@ -1,4 +0,0 @@
package roomescape.payment.dto.request;
public record PaymentCancelRequest(String paymentKey, Long amount, String cancelReason) {
}

View File

@ -1,4 +0,0 @@
package roomescape.payment.dto.request;
public record PaymentRequest(String paymentKey, String orderId, Long amount, String paymentType) {
}

View File

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

View File

@ -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<PaymentCancelResponse> {
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())
);
}
}

View File

@ -1,11 +0,0 @@
package roomescape.payment.dto.response;
import java.time.OffsetDateTime;
public record PaymentResponse(
String paymentKey,
String orderId,
OffsetDateTime approvedAt,
Long totalAmount
) {
}

View File

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

View File

@ -1,4 +0,0 @@
package roomescape.payment.dto.response;
public record TossPaymentErrorResponse(String code, String message) {
}

View File

@ -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<PaymentCancel.Response>? = null
) : StdDeserializer<PaymentCancel.Response>(vc) {
@Throws(IOException::class)
override fun deserialize(
jsonParser: JsonParser,
deserializationContext: DeserializationContext?
): PaymentCancel.Response {
val cancels: JsonNode = jsonParser.codec.readTree<TreeNode>(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())
)
}
}

View File

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

View File

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

View File

@ -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<String, String>("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
}
}

View File

@ -0,0 +1,7 @@
package roomescape.payment.infrastructure.client
@JvmRecord
data class TossPaymentErrorResponse(
val code: String,
val message: String
)

View File

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

View File

@ -0,0 +1,7 @@
package roomescape.payment.infrastructure.persistence
import org.springframework.data.jpa.repository.JpaRepository
interface CanceledPaymentRepository : JpaRepository<CanceledPaymentEntity, Long> {
fun findByPaymentKey(paymentKey: String): CanceledPaymentEntity?
}

View File

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

View File

@ -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<PaymentEntity, Long> {
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?
}

View File

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

View File

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

View File

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

View File

@ -54,7 +54,7 @@ public interface ReservationRepository extends JpaRepository<Reservation, Long>,
)
FROM Reservation r
JOIN r.theme t
LEFT JOIN Payment p
LEFT JOIN PaymentEntity p
ON p.reservation = r
WHERE r.member.id = :memberId
""")

View File

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

View File

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

View File

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

View File

@ -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`("관리자가 보내면") {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<RoomescapeException> {
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<RoomescapeException> {
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
}
}
}
}
}

View File

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

View File

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

View File

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

View File

@ -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<CanceledPayment> 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("테마만을 이용하여 예약을 조회한다.")

View File

@ -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()));

View File

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

View File

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