Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package roomescape.controller.dto.response;

import roomescape.domain.PaymentStatus;
import roomescape.service.dto.BookingPaymentInfo;

public record BookingPaymentResponse(
String orderId,
Long amount,
String paymentKey,
PaymentStatus status,
String failureCode,
String failureMessage
) {

public static BookingPaymentResponse from(BookingPaymentInfo payment) {
if (payment == null) {
return null;
}
return new BookingPaymentResponse(
payment.orderId(),
payment.amount(),
payment.paymentKey(),
payment.status(),
payment.failureCode(),
payment.failureMessage()
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ public record BookingStatusResponse(
ReservationThemeResponse theme,
BookingType bookingType,
ReservationStatus reservationStatus,
Long turn
Long turn,
BookingPaymentResponse payment
) {

public static BookingStatusResponse from(BookingStatus bookingStatus) {
Expand All @@ -25,7 +26,8 @@ public static BookingStatusResponse from(BookingStatus bookingStatus) {
ReservationThemeResponse.from(bookingStatus.theme()),
bookingStatus.bookingType(),
bookingStatus.reservationStatus(),
bookingStatus.turn()
bookingStatus.turn(),
BookingPaymentResponse.from(bookingStatus.payment())
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ModelAttribute;
import roomescape.controller.view.dto.PaymentFailRequest;
import roomescape.controller.view.dto.PaymentReservationView;
import roomescape.controller.view.dto.PaymentSuccessRequest;
import roomescape.domain.Reservation;
import roomescape.service.payment.PaymentAmountMismatchException;
import roomescape.service.payment.PaymentGatewayException;
Expand All @@ -14,6 +16,8 @@
@Controller
public class PaymentSuccessController {

private static final String PAYMENT_CONFIRMATION_UNKNOWN = "PAYMENT_CONFIRMATION_UNKNOWN";

private final PaymentService paymentService;

public PaymentSuccessController(PaymentService paymentService) {
Expand All @@ -22,44 +26,37 @@ public PaymentSuccessController(PaymentService paymentService) {

@GetMapping("/payments/success")
public String success(
@RequestParam String paymentKey,
@RequestParam String orderId,
@RequestParam Long amount,
@RequestParam(required = false) String name,
@ModelAttribute PaymentSuccessRequest request,
Model model
) {
try {
PaymentResult result = paymentService.confirm(paymentKey, orderId, amount);
PaymentResult result = paymentService.confirm(request.paymentKey(), request.orderId(), request.amount());
model.addAttribute("result", result);
addReservationViewByOrderId(model, orderId);
model.addAttribute("name", name);
addReservationViewByOrderId(model, request.orderId());
model.addAttribute("name", request.name());
return "payment-success";
} catch (PaymentAmountMismatchException e) {
return failView(model, "AMOUNT_MISMATCH", e.getMessage(), orderId, name,
paymentService.findReservationByOrderId(orderId));
return failView(model, "AMOUNT_MISMATCH", e.getMessage(), request.orderId(), request.name(),
paymentService.findReservationByOrderId(request.orderId()));
} catch (PaymentGatewayException e) {
return failView(model, e.getCode(), e.getMessage(), orderId, name,
paymentService.findReservationByOrderId(orderId));
return failView(model, e.getCode(), e.getMessage(), request.orderId(), request.name(),
paymentService.findReservationByOrderId(request.orderId()));
}
}

@GetMapping("/payments/fail")
public String fail(
@RequestParam(required = false) String code,
@RequestParam(required = false) String message,
@RequestParam(required = false) String orderId,
@RequestParam(required = false) Long paymentId,
@RequestParam(required = false) String name,
@ModelAttribute PaymentFailRequest request,
Model model
) {
Reservation reservation = null;
if (paymentId != null) {
paymentService.fail(paymentId, code, message);
reservation = paymentService.findReservationByPaymentId(paymentId);
} else if (orderId != null) {
reservation = paymentService.findReservationByOrderId(orderId);
if (request.paymentId() != null) {
paymentService.fail(request.paymentId(), request.code(), request.message());
reservation = paymentService.findReservationByPaymentId(request.paymentId());
} else if (request.orderId() != null) {
reservation = paymentService.findReservationByOrderId(request.orderId());
}
return failView(model, code, message, orderId, name, reservation);
return failView(model, request.code(), request.message(), request.orderId(), request.name(), reservation);
}

private void addReservationViewByOrderId(Model model, String orderId) {
Expand All @@ -73,6 +70,7 @@ private String failView(Model model, String code, String message, String orderId
model.addAttribute("message", message);
model.addAttribute("orderId", orderId);
model.addAttribute("name", name);
model.addAttribute("confirmationUnknown", PAYMENT_CONFIRMATION_UNKNOWN.equals(code));
if (reservation != null) {
model.addAttribute("reservation", PaymentReservationView.from(reservation));
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package roomescape.controller.view.dto;

public record PaymentFailRequest(
String code,
String message,
String orderId,
Long paymentId,
String name
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package roomescape.controller.view.dto;

public record PaymentSuccessRequest(
String paymentKey,
String orderId,
Long amount,
String name
) {
}
54 changes: 47 additions & 7 deletions src/main/java/roomescape/domain/Payment.java
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,13 @@ public class Payment {
private final String failureCode;
private final String failureMessage;

public Payment(Long id, Long reservationId, String orderId, Long amount, String paymentKey,
PaymentStatus status, String failureCode, String failureMessage) {
private Payment(Long id, Long reservationId, String orderId, Long amount, String paymentKey,
PaymentStatus status, String failureCode, String failureMessage) {
validateReservationId(reservationId);
validateOrderId(orderId);
validateAmount(amount);
validateStatus(status);
validateStatusFields(status, paymentKey, failureCode);

this.id = id;
this.reservationId = reservationId;
Expand All @@ -34,24 +35,31 @@ public static Payment ready(Long reservationId, Long amount) {
return new Payment(null, reservationId, generateOrderId(), amount, null, PaymentStatus.READY, null, null);
}

public static Payment restore(Long id, Long reservationId, String orderId, Long amount, String paymentKey,
PaymentStatus status, String failureCode, String failureMessage) {
validateId(id);
return new Payment(id, reservationId, orderId, amount, paymentKey, status, failureCode, failureMessage);
}

public Payment withId(Long id) {
validateId(id);
return new Payment(id, reservationId, orderId, amount, paymentKey, status, failureCode, failureMessage);
}

public Payment confirm(String paymentKey) {
if (status != PaymentStatus.READY) {
throw new IllegalStateException("결제 대기 상태에서만 승인할 수 있습니다.");
if (!canConfirm()) {
throw new IllegalStateException("결제 대기 또는 확인 필요 상태에서만 승인할 수 있습니다.");
}
if (paymentKey == null || paymentKey.isBlank()) {
throw new IllegalArgumentException("paymentKey는 비어 있을 수 없습니다.");
}
return new Payment(id, reservationId, orderId, amount, paymentKey, PaymentStatus.CONFIRMED,
failureCode, failureMessage);
null, null);
}

public Payment fail(String failureCode, String failureMessage) {
if (status != PaymentStatus.READY) {
throw new IllegalStateException("결제 대기 상태에서만 실패 처리할 수 있습니다.");
if (!canConfirm()) {
throw new IllegalStateException("결제 대기 또는 확인 필요 상태에서만 실패 처리할 수 있습니다.");
}
PaymentStatus failedStatus = "PAY_PROCESS_CANCELED".equals(failureCode)
? PaymentStatus.CANCELED
Expand All @@ -60,6 +68,14 @@ public Payment fail(String failureCode, String failureMessage) {
failureCode, failureMessage);
}

public Payment checkRequired(String failureCode, String failureMessage) {
if (!canConfirm()) {
throw new IllegalStateException("결제 대기 또는 확인 필요 상태에서만 확인 필요 처리할 수 있습니다.");
}
return new Payment(id, reservationId, orderId, amount, paymentKey, PaymentStatus.CHECK_REQUIRED,
failureCode, failureMessage);
}

public Long getId() {
return id;
}
Expand Down Expand Up @@ -96,6 +112,12 @@ private static String generateOrderId() {
return "payment_" + UUID.randomUUID().toString().replace("-", "");
}

private static void validateId(Long id) {
if (id == null || id <= 0) {
throw new IllegalArgumentException("id는 양수여야 합니다.");
}
}

private void validateReservationId(Long reservationId) {
if (reservationId == null || reservationId <= 0) {
throw new IllegalArgumentException("reservationId는 양수여야 합니다.");
Expand All @@ -119,4 +141,22 @@ private void validateStatus(PaymentStatus status) {
throw new IllegalArgumentException("status는 비어 있을 수 없습니다.");
}
}

private void validateStatusFields(PaymentStatus status, String paymentKey, String failureCode) {
if (status == PaymentStatus.READY && (paymentKey != null || failureCode != null)) {
throw new IllegalArgumentException("결제 대기 상태는 paymentKey나 실패 코드를 가질 수 없습니다.");
}
if (status == PaymentStatus.CONFIRMED && (paymentKey == null || paymentKey.isBlank())) {
throw new IllegalArgumentException("승인된 결제는 paymentKey가 필요합니다.");
}
if ((status == PaymentStatus.FAILED || status == PaymentStatus.CANCELED
|| status == PaymentStatus.CHECK_REQUIRED)
&& (failureCode == null || failureCode.isBlank())) {
throw new IllegalArgumentException("실패, 취소 또는 확인 필요 결제는 실패 코드가 필요합니다.");
}
}

private boolean canConfirm() {
return status == PaymentStatus.READY || status == PaymentStatus.CHECK_REQUIRED;
}
}
1 change: 1 addition & 0 deletions src/main/java/roomescape/domain/PaymentStatus.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ public enum PaymentStatus {
READY,
FAILED,
CANCELED,
CHECK_REQUIRED,
CONFIRMED;

public static PaymentStatus fromTossStatus(String tossStatus) {
Expand Down
10 changes: 9 additions & 1 deletion src/main/java/roomescape/payment/client/TossClientConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpHeaders;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.web.client.RestClient;

@Configuration
Expand All @@ -14,13 +15,20 @@ public class TossClientConfig {
@Bean
RestClient tossRestClient(
@Value("${toss.base-url:https://api.tosspayments.com}") String baseUrl,
@Value("${toss.secret-key:}") String secretKey
@Value("${toss.secret-key:}") String secretKey,
@Value("${toss.connect-timeout-ms:1000}") int connectTimeoutMs,
@Value("${toss.read-timeout-ms:2000}") int readTimeoutMs
) {
String encodedCredentials = Base64.getEncoder()
.encodeToString((secretKey + ":").getBytes(StandardCharsets.UTF_8));
SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
requestFactory.setConnectTimeout(connectTimeoutMs);
requestFactory.setReadTimeout(readTimeoutMs);

return RestClient.builder()
.baseUrl(baseUrl)
.defaultHeader(HttpHeaders.AUTHORIZATION, "Basic " + encodedCredentials)
.requestFactory(requestFactory)
.build();
}
}
35 changes: 25 additions & 10 deletions src/main/java/roomescape/payment/client/TossPaymentGateway.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,12 @@
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientException;
import roomescape.domain.PaymentStatus;
import roomescape.service.payment.PaymentConfirmation;
import roomescape.service.payment.PaymentFailureCategory;
import roomescape.service.payment.PaymentGateway;
import roomescape.service.payment.PaymentGatewayException;
import roomescape.service.payment.PaymentResult;
import roomescape.payment.client.dto.ConfirmRequest;
import roomescape.payment.client.dto.TossErrorResponse;
Expand All @@ -28,16 +31,28 @@ public TossPaymentGateway(RestClient tossRestClient, ObjectMapper objectMapper)
public PaymentResult confirm(PaymentConfirmation confirmation) {
ConfirmRequest request = new ConfirmRequest(
confirmation.paymentKey(), confirmation.orderId(), confirmation.amount());
TossPaymentResponse response = tossRestClient.post()
.uri("/v1/payments/confirm")
.contentType(MediaType.APPLICATION_JSON)
.body(request)
.retrieve()
.onStatus(HttpStatusCode::isError, (requestHeaders, clientResponse) -> {
TossErrorResponse error = objectMapper.readValue(clientResponse.getBody(), TossErrorResponse.class);
throw TossPaymentException.of(clientResponse.getStatusCode(), error);
})
.body(TossPaymentResponse.class);
TossPaymentResponse response;
try {
response = tossRestClient.post()
.uri("/v1/payments/confirm")
.contentType(MediaType.APPLICATION_JSON)
.header("Idempotency-Key", confirmation.orderId())
.body(request)
.retrieve()
.onStatus(HttpStatusCode::isError, (requestHeaders, clientResponse) -> {
TossErrorResponse error = objectMapper.readValue(clientResponse.getBody(), TossErrorResponse.class);
throw TossPaymentException.of(clientResponse.getStatusCode(), error);
})
.body(TossPaymentResponse.class);
} catch (TossPaymentException e) {
throw e;
} catch (RestClientException e) {
throw new PaymentGatewayException(
PaymentFailureCategory.CONFIRMATION_UNKNOWN,
"PAYMENT_CONFIRMATION_UNKNOWN",
"결제 승인 결과를 확인할 수 없습니다."
);
}
return new PaymentResult(
response.paymentKey(),
response.orderId(),
Expand Down
2 changes: 1 addition & 1 deletion src/main/java/roomescape/repository/PaymentRepository.java
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ public class PaymentRepository {

private final JdbcTemplate jdbcTemplate;

private final RowMapper<Payment> paymentRowMapper = (resultSet, rowNum) -> new Payment(
private final RowMapper<Payment> paymentRowMapper = (resultSet, rowNum) -> Payment.restore(
resultSet.getLong("id"),
resultSet.getLong("reservation_id"),
resultSet.getString("order_id"),
Expand Down
Loading