From 84baad0b60102204b89a6d5b174648f571ef0a08 Mon Sep 17 00:00:00 2001 From: tjdakf Date: Thu, 25 Jun 2026 16:58:12 +0900 Subject: [PATCH 01/11] =?UTF-8?q?refactor:=20Payment=20=EC=83=9D=EC=84=B1?= =?UTF-8?q?=20=EA=B7=9C=EC=B9=99=20=EC=BA=A1=EC=8A=90=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/roomescape/domain/Payment.java | 31 +++++++++++++++++-- .../repository/PaymentRepository.java | 2 +- src/test/java/roomescape/MissionStepTest.java | 6 +++- .../user/ReservationControllerTest.java | 2 +- .../java/roomescape/domain/PaymentTest.java | 16 ++++++++++ .../repository/PaymentRepositoryTest.java | 3 +- .../service/PaymentServiceTest.java | 6 ++-- 7 files changed, 56 insertions(+), 10 deletions(-) diff --git a/src/main/java/roomescape/domain/Payment.java b/src/main/java/roomescape/domain/Payment.java index 207e1741a2..188f05b0fd 100644 --- a/src/main/java/roomescape/domain/Payment.java +++ b/src/main/java/roomescape/domain/Payment.java @@ -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; @@ -34,7 +35,14 @@ 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); } @@ -96,6 +104,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는 양수여야 합니다."); @@ -119,4 +133,17 @@ 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) + && (failureCode == null || failureCode.isBlank())) { + throw new IllegalArgumentException("실패 또는 취소된 결제는 실패 코드가 필요합니다."); + } + } } diff --git a/src/main/java/roomescape/repository/PaymentRepository.java b/src/main/java/roomescape/repository/PaymentRepository.java index 39a4f32648..0fcf163f80 100644 --- a/src/main/java/roomescape/repository/PaymentRepository.java +++ b/src/main/java/roomescape/repository/PaymentRepository.java @@ -15,7 +15,7 @@ public class PaymentRepository { private final JdbcTemplate jdbcTemplate; - private final RowMapper paymentRowMapper = (resultSet, rowNum) -> new Payment( + private final RowMapper paymentRowMapper = (resultSet, rowNum) -> Payment.restore( resultSet.getLong("id"), resultSet.getLong("reservation_id"), resultSet.getString("order_id"), diff --git a/src/test/java/roomescape/MissionStepTest.java b/src/test/java/roomescape/MissionStepTest.java index 0cada19c65..6bc06701d6 100644 --- a/src/test/java/roomescape/MissionStepTest.java +++ b/src/test/java/roomescape/MissionStepTest.java @@ -355,7 +355,11 @@ void setup() { .body("reservationId", is(1)) .body("paymentId", is(1)); - jdbcTemplate.update("UPDATE payment SET status = 'FAILED' WHERE id = 1;"); + jdbcTemplate.update(""" + UPDATE payment + SET status = 'FAILED', failure_code = 'REJECT_CARD_PAYMENT', failure_message = '카드가 거절되었습니다.' + WHERE id = 1; + """); RestAssured.given().log().all() .queryParam("name", "브라운") diff --git a/src/test/java/roomescape/controller/user/ReservationControllerTest.java b/src/test/java/roomescape/controller/user/ReservationControllerTest.java index 85a8c9ae66..7d0cf9591a 100644 --- a/src/test/java/roomescape/controller/user/ReservationControllerTest.java +++ b/src/test/java/roomescape/controller/user/ReservationControllerTest.java @@ -315,7 +315,7 @@ private Reservation updatedReservation() { } private Payment payment() { - return new Payment(1L, 1L, "payment_12345678901234567890123456789012", 20_000L, null, + return Payment.restore(1L, 1L, "payment_12345678901234567890123456789012", 20_000L, null, PaymentStatus.READY, null, null); } } diff --git a/src/test/java/roomescape/domain/PaymentTest.java b/src/test/java/roomescape/domain/PaymentTest.java index 8800fea08a..5c5b431fbd 100644 --- a/src/test/java/roomescape/domain/PaymentTest.java +++ b/src/test/java/roomescape/domain/PaymentTest.java @@ -23,4 +23,20 @@ class PaymentTest { .isInstanceOf(IllegalArgumentException.class) .hasMessage("reservationId는 양수여야 합니다."); } + + @Test + void 승인된_결제는_paymentKey가_필요하다() { + assertThatThrownBy(() -> Payment.restore(1L, 1L, "payment_confirmed_123456789012345", 20_000L, + null, PaymentStatus.CONFIRMED, null, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("승인된 결제는 paymentKey가 필요합니다."); + } + + @Test + void 실패한_결제는_실패_코드가_필요하다() { + assertThatThrownBy(() -> Payment.restore(1L, 1L, "payment_failed_123456789012345678", 20_000L, + null, PaymentStatus.FAILED, null, "카드 거절")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("실패 또는 취소된 결제는 실패 코드가 필요합니다."); + } } diff --git a/src/test/java/roomescape/repository/PaymentRepositoryTest.java b/src/test/java/roomescape/repository/PaymentRepositoryTest.java index 1efa3db03d..4301f9e15d 100644 --- a/src/test/java/roomescape/repository/PaymentRepositoryTest.java +++ b/src/test/java/roomescape/repository/PaymentRepositoryTest.java @@ -47,8 +47,7 @@ void setUp() { @Test void 예약의_가장_최근_결제를_조회한다() { Long reservationId = createPendingReservation(); - paymentRepository.insert(new Payment(null, reservationId, "payment_failed_12345678901234567890", 20_000L, - null, PaymentStatus.FAILED, "REJECT_CARD_PAYMENT", "카드 거절")); + paymentRepository.insert(Payment.ready(reservationId, 20_000L).fail("REJECT_CARD_PAYMENT", "카드 거절")); Payment latestPayment = paymentRepository.insert(Payment.ready(reservationId, 20_000L)); assertThat(paymentRepository.findLatestByReservationId(reservationId).orElseThrow().getId()) diff --git a/src/test/java/roomescape/service/PaymentServiceTest.java b/src/test/java/roomescape/service/PaymentServiceTest.java index 3748b8e4a5..9665d5f9a4 100644 --- a/src/test/java/roomescape/service/PaymentServiceTest.java +++ b/src/test/java/roomescape/service/PaymentServiceTest.java @@ -58,7 +58,7 @@ class PaymentServiceTest { void 실패한_결제_뒤에는_새_결제를_생성한다() { LocalDateTime now = LocalDateTime.of(2026, 6, 24, 12, 0); Reservation reservation = pendingReservation(1L, LocalDate.of(2099, 1, 1)); - Payment failedPayment = new Payment(1L, 1L, "payment_failed_12345678901234567890", 20_000L, null, + Payment failedPayment = Payment.restore(1L, 1L, "payment_failed_12345678901234567890", 20_000L, null, PaymentStatus.FAILED, "REJECT_CARD_PAYMENT", "카드가 거절되었습니다."); when(reservationService.findPendingByUser(1L, "브라운", now)).thenReturn(reservation); when(paymentRepository.findLatestByReservationId(1L)).thenReturn(Optional.of(failedPayment)); @@ -79,7 +79,7 @@ class PaymentServiceTest { void 준비된_결제가_있으면_기존_결제를_반환한다() { LocalDateTime now = LocalDateTime.of(2026, 6, 24, 12, 0); Reservation reservation = pendingReservation(1L, LocalDate.of(2099, 1, 1)); - Payment readyPayment = new Payment(1L, 1L, "payment_ready_123456789012345678901", 20_000L, null, + Payment readyPayment = Payment.restore(1L, 1L, "payment_ready_123456789012345678901", 20_000L, null, PaymentStatus.READY, null, null); when(reservationService.findPendingByUser(1L, "브라운", now)).thenReturn(reservation); when(paymentRepository.findLatestByReservationId(1L)).thenReturn(Optional.of(readyPayment)); @@ -214,7 +214,7 @@ private Reservation pendingReservation(Long id, LocalDate date) { } private Payment readyPayment() { - return new Payment(1L, 1L, "payment_ready_123456789012345678901", 20_000L, null, + return Payment.restore(1L, 1L, "payment_ready_123456789012345678901", 20_000L, null, PaymentStatus.READY, null, null); } } From 16662b9363f0cc461c2771817408e85f8e469970 Mon Sep 17 00:00:00 2001 From: tjdakf Date: Thu, 25 Jun 2026 17:20:40 +0900 Subject: [PATCH 02/11] =?UTF-8?q?refactor:=20=EA=B2=B0=EC=A0=9C=20?= =?UTF-8?q?=EC=BD=9C=EB=B0=B1=20=EC=9A=94=EC=B2=AD=20=ED=8C=8C=EB=9D=BC?= =?UTF-8?q?=EB=AF=B8=ED=84=B0=20DTO=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../view/PaymentSuccessController.java | 41 ++++++++----------- .../view/dto/PaymentFailRequest.java | 10 +++++ .../view/dto/PaymentSuccessRequest.java | 9 ++++ 3 files changed, 37 insertions(+), 23 deletions(-) create mode 100644 src/main/java/roomescape/controller/view/dto/PaymentFailRequest.java create mode 100644 src/main/java/roomescape/controller/view/dto/PaymentSuccessRequest.java diff --git a/src/main/java/roomescape/controller/view/PaymentSuccessController.java b/src/main/java/roomescape/controller/view/PaymentSuccessController.java index ec18894247..391d2d931c 100644 --- a/src/main/java/roomescape/controller/view/PaymentSuccessController.java +++ b/src/main/java/roomescape/controller/view/PaymentSuccessController.java @@ -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; @@ -22,44 +24,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) { diff --git a/src/main/java/roomescape/controller/view/dto/PaymentFailRequest.java b/src/main/java/roomescape/controller/view/dto/PaymentFailRequest.java new file mode 100644 index 0000000000..6f6c8dd7ea --- /dev/null +++ b/src/main/java/roomescape/controller/view/dto/PaymentFailRequest.java @@ -0,0 +1,10 @@ +package roomescape.controller.view.dto; + +public record PaymentFailRequest( + String code, + String message, + String orderId, + Long paymentId, + String name +) { +} diff --git a/src/main/java/roomescape/controller/view/dto/PaymentSuccessRequest.java b/src/main/java/roomescape/controller/view/dto/PaymentSuccessRequest.java new file mode 100644 index 0000000000..a063788ff7 --- /dev/null +++ b/src/main/java/roomescape/controller/view/dto/PaymentSuccessRequest.java @@ -0,0 +1,9 @@ +package roomescape.controller.view.dto; + +public record PaymentSuccessRequest( + String paymentKey, + String orderId, + Long amount, + String name +) { +} From 075e54f6626999fa12dee9112769a4970d4dfc4a Mon Sep 17 00:00:00 2001 From: tjdakf Date: Thu, 25 Jun 2026 21:36:09 +0900 Subject: [PATCH 03/11] =?UTF-8?q?feat:=20=ED=86=A0=EC=8A=A4=20=EA=B2=B0?= =?UTF-8?q?=EC=A0=9C=20API=20=ED=98=B8=EC=B6=9C=20=ED=83=80=EC=9E=84?= =?UTF-8?q?=EC=95=84=EC=9B=83=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../payment/client/TossClientConfig.java | 10 ++++- src/main/resources/application.properties | 2 + .../payment/client/TossClientConfigTest.java | 45 +++++++++++++++++++ src/test/resources/application.properties | 2 + 4 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 src/test/java/roomescape/payment/client/TossClientConfigTest.java diff --git a/src/main/java/roomescape/payment/client/TossClientConfig.java b/src/main/java/roomescape/payment/client/TossClientConfig.java index 60fcd9f140..4289ed696b 100644 --- a/src/main/java/roomescape/payment/client/TossClientConfig.java +++ b/src/main/java/roomescape/payment/client/TossClientConfig.java @@ -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 @@ -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(); } } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index bc223cdc71..e688e03709 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -9,3 +9,5 @@ spring.h2.console.path=/h2-console toss.base-url=https://api.tosspayments.com toss.client-key=test_gck_docs_Ovk5rk1EwkEbP0W43n07xlzm toss.secret-key=test_gsk_docs_OaPz8L5KdmQXkzRz3y47BMw6 +toss.connect-timeout-ms=1000 +toss.read-timeout-ms=2000 diff --git a/src/test/java/roomescape/payment/client/TossClientConfigTest.java b/src/test/java/roomescape/payment/client/TossClientConfigTest.java new file mode 100644 index 0000000000..c6b3fe46a3 --- /dev/null +++ b/src/test/java/roomescape/payment/client/TossClientConfigTest.java @@ -0,0 +1,45 @@ +package roomescape.payment.client; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.net.SocketTimeoutException; +import java.util.concurrent.TimeUnit; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestClient; + +class TossClientConfigTest { + + private MockWebServer server; + + @BeforeEach + void setUp() throws Exception { + server = new MockWebServer(); + server.start(); + } + + @AfterEach + void tearDown() throws Exception { + server.shutdown(); + } + + @Test + void 응답이_느리면_read_timeout으로_끊는다() { + server.enqueue(new MockResponse() + .setBody("slow response") + .setBodyDelay(1, TimeUnit.SECONDS)); + RestClient restClient = new TossClientConfig() + .tossRestClient(server.url("/").toString(), "test_secret_key", 500, 100); + + assertThatThrownBy(() -> restClient.get() + .uri("/") + .retrieve() + .body(String.class)) + .isInstanceOf(RestClientException.class) + .hasRootCauseInstanceOf(SocketTimeoutException.class); + } +} diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties index c0efa9b762..4445307ae6 100644 --- a/src/test/resources/application.properties +++ b/src/test/resources/application.properties @@ -1,2 +1,4 @@ spring.datasource.url=jdbc:h2:mem:database;NON_KEYWORDS=DATE spring.sql.init.data-locations=classpath:test-data.sql +toss.connect-timeout-ms=1000 +toss.read-timeout-ms=2000 From b3380afb6fc147dede600c8d71bf3a36eea8d134 Mon Sep 17 00:00:00 2001 From: tjdakf Date: Thu, 25 Jun 2026 22:00:04 +0900 Subject: [PATCH 04/11] =?UTF-8?q?feat:=20=EA=B2=B0=EC=A0=9C=20=EC=8A=B9?= =?UTF-8?q?=EC=9D=B8=20=ED=99=95=EC=9D=B8=20=ED=95=84=EC=9A=94=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/roomescape/domain/Payment.java | 13 ++++++++++-- .../java/roomescape/domain/PaymentStatus.java | 1 + .../roomescape/service/PaymentService.java | 3 +++ .../payment/PaymentFailureCategory.java | 1 + .../payment/PaymentGatewayException.java | 4 ++++ .../java/roomescape/domain/PaymentTest.java | 13 +++++++++++- .../service/PaymentServiceTest.java | 21 +++++++++++++++++++ 7 files changed, 53 insertions(+), 3 deletions(-) diff --git a/src/main/java/roomescape/domain/Payment.java b/src/main/java/roomescape/domain/Payment.java index 188f05b0fd..b577812114 100644 --- a/src/main/java/roomescape/domain/Payment.java +++ b/src/main/java/roomescape/domain/Payment.java @@ -68,6 +68,14 @@ public Payment fail(String failureCode, String failureMessage) { failureCode, failureMessage); } + public Payment checkRequired(String failureCode, String failureMessage) { + if (status != PaymentStatus.READY) { + throw new IllegalStateException("결제 대기 상태에서만 확인 필요 처리할 수 있습니다."); + } + return new Payment(id, reservationId, orderId, amount, paymentKey, PaymentStatus.CHECK_REQUIRED, + failureCode, failureMessage); + } + public Long getId() { return id; } @@ -141,9 +149,10 @@ private void validateStatusFields(PaymentStatus status, String paymentKey, Strin if (status == PaymentStatus.CONFIRMED && (paymentKey == null || paymentKey.isBlank())) { throw new IllegalArgumentException("승인된 결제는 paymentKey가 필요합니다."); } - if ((status == PaymentStatus.FAILED || status == PaymentStatus.CANCELED) + if ((status == PaymentStatus.FAILED || status == PaymentStatus.CANCELED + || status == PaymentStatus.CHECK_REQUIRED) && (failureCode == null || failureCode.isBlank())) { - throw new IllegalArgumentException("실패 또는 취소된 결제는 실패 코드가 필요합니다."); + throw new IllegalArgumentException("실패, 취소 또는 확인 필요 결제는 실패 코드가 필요합니다."); } } } diff --git a/src/main/java/roomescape/domain/PaymentStatus.java b/src/main/java/roomescape/domain/PaymentStatus.java index d2d2adee20..5f7415f8a4 100644 --- a/src/main/java/roomescape/domain/PaymentStatus.java +++ b/src/main/java/roomescape/domain/PaymentStatus.java @@ -4,6 +4,7 @@ public enum PaymentStatus { READY, FAILED, CANCELED, + CHECK_REQUIRED, CONFIRMED; public static PaymentStatus fromTossStatus(String tossStatus) { diff --git a/src/main/java/roomescape/service/PaymentService.java b/src/main/java/roomescape/service/PaymentService.java index d586abe5c3..0fb447b459 100644 --- a/src/main/java/roomescape/service/PaymentService.java +++ b/src/main/java/roomescape/service/PaymentService.java @@ -78,6 +78,9 @@ public PaymentResult confirm(String paymentKey, String orderId, Long amount) { if (e.isDefinitiveFailure()) { paymentRepository.update(payment.fail(e.getCode(), e.getMessage())); } + if (e.requiresConfirmationCheck()) { + paymentRepository.update(payment.checkRequired(e.getCode(), e.getMessage())); + } throw e; } if (result.status() != PaymentStatus.CONFIRMED) { diff --git a/src/main/java/roomescape/service/payment/PaymentFailureCategory.java b/src/main/java/roomescape/service/payment/PaymentFailureCategory.java index 27bca68913..e4cd29aae2 100644 --- a/src/main/java/roomescape/service/payment/PaymentFailureCategory.java +++ b/src/main/java/roomescape/service/payment/PaymentFailureCategory.java @@ -3,5 +3,6 @@ public enum PaymentFailureCategory { DEFINITIVE, UNKNOWN, + CONFIRMATION_UNKNOWN, CONFIGURATION } diff --git a/src/main/java/roomescape/service/payment/PaymentGatewayException.java b/src/main/java/roomescape/service/payment/PaymentGatewayException.java index 8d2535c466..280061f92a 100644 --- a/src/main/java/roomescape/service/payment/PaymentGatewayException.java +++ b/src/main/java/roomescape/service/payment/PaymentGatewayException.java @@ -22,4 +22,8 @@ public String getCode() { public boolean isDefinitiveFailure() { return failureCategory == PaymentFailureCategory.DEFINITIVE; } + + public boolean requiresConfirmationCheck() { + return failureCategory == PaymentFailureCategory.CONFIRMATION_UNKNOWN; + } } diff --git a/src/test/java/roomescape/domain/PaymentTest.java b/src/test/java/roomescape/domain/PaymentTest.java index 5c5b431fbd..740d683bad 100644 --- a/src/test/java/roomescape/domain/PaymentTest.java +++ b/src/test/java/roomescape/domain/PaymentTest.java @@ -37,6 +37,17 @@ class PaymentTest { assertThatThrownBy(() -> Payment.restore(1L, 1L, "payment_failed_123456789012345678", 20_000L, null, PaymentStatus.FAILED, null, "카드 거절")) .isInstanceOf(IllegalArgumentException.class) - .hasMessage("실패 또는 취소된 결제는 실패 코드가 필요합니다."); + .hasMessage("실패, 취소 또는 확인 필요 결제는 실패 코드가 필요합니다."); + } + + @Test + void 결제_승인_결과_확인이_필요한_상태로_변경한다() { + Payment payment = Payment.ready(1L, 20_000L); + + Payment checkRequiredPayment = payment.checkRequired( + "PAYMENT_CONFIRMATION_UNKNOWN", "결제 승인 결과를 확인할 수 없습니다."); + + assertThat(checkRequiredPayment.getStatus()).isEqualTo(PaymentStatus.CHECK_REQUIRED); + assertThat(checkRequiredPayment.getFailureCode()).isEqualTo("PAYMENT_CONFIRMATION_UNKNOWN"); } } diff --git a/src/test/java/roomescape/service/PaymentServiceTest.java b/src/test/java/roomescape/service/PaymentServiceTest.java index 9665d5f9a4..c275e802a1 100644 --- a/src/test/java/roomescape/service/PaymentServiceTest.java +++ b/src/test/java/roomescape/service/PaymentServiceTest.java @@ -180,6 +180,27 @@ class PaymentServiceTest { verify(reservationService, never()).confirmPayment(any()); } + @Test + void 승인_결과를_알_수_없으면_확인_필요_상태로_저장한다() { + Payment payment = readyPayment(); + PaymentConfirmation confirmation = new PaymentConfirmation("test_payment_key", payment.getOrderId(), 20_000L); + PaymentGatewayException exception = new PaymentGatewayException( + PaymentFailureCategory.CONFIRMATION_UNKNOWN, + "PAYMENT_CONFIRMATION_UNKNOWN", + "결제 승인 결과를 확인할 수 없습니다."); + when(paymentRepository.findByOrderId(payment.getOrderId())).thenReturn(Optional.of(payment)); + when(paymentGateway.confirm(confirmation)).thenThrow(exception); + + assertThatThrownBy(() -> paymentService.confirm("test_payment_key", payment.getOrderId(), 20_000L)) + .isSameAs(exception); + + ArgumentCaptor paymentCaptor = ArgumentCaptor.forClass(Payment.class); + verify(paymentRepository).update(paymentCaptor.capture()); + assertThat(paymentCaptor.getValue().getStatus()).isEqualTo(PaymentStatus.CHECK_REQUIRED); + assertThat(paymentCaptor.getValue().getFailureCode()).isEqualTo("PAYMENT_CONFIRMATION_UNKNOWN"); + verify(reservationService, never()).confirmPayment(any()); + } + @Test void 결제_실패를_저장하고_결제_대기_예약은_유지한다() { Payment payment = readyPayment(); From 87bae19e53cce7db55a725a98365659826a9ec0e Mon Sep 17 00:00:00 2001 From: tjdakf Date: Thu, 25 Jun 2026 22:03:58 +0900 Subject: [PATCH 05/11] =?UTF-8?q?feat:=20=EA=B2=B0=EC=A0=9C=20=EC=8A=B9?= =?UTF-8?q?=EC=9D=B8=20=ED=86=B5=EC=8B=A0=20=EC=98=88=EC=99=B8=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../payment/client/TossPaymentGateway.java | 34 +++++++++++++------ .../client/TossPaymentGatewayTest.java | 16 +++++++++ 2 files changed, 40 insertions(+), 10 deletions(-) diff --git a/src/main/java/roomescape/payment/client/TossPaymentGateway.java b/src/main/java/roomescape/payment/client/TossPaymentGateway.java index e152f1aeee..daeeb0128f 100644 --- a/src/main/java/roomescape/payment/client/TossPaymentGateway.java +++ b/src/main/java/roomescape/payment/client/TossPaymentGateway.java @@ -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; @@ -28,16 +31,27 @@ 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) + .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(), diff --git a/src/test/java/roomescape/payment/client/TossPaymentGatewayTest.java b/src/test/java/roomescape/payment/client/TossPaymentGatewayTest.java index 87de2c56da..404025061c 100644 --- a/src/test/java/roomescape/payment/client/TossPaymentGatewayTest.java +++ b/src/test/java/roomescape/payment/client/TossPaymentGatewayTest.java @@ -5,6 +5,7 @@ import okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.MockWebServer; import okhttp3.mockwebserver.RecordedRequest; +import okhttp3.mockwebserver.SocketPolicy; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -16,6 +17,7 @@ import roomescape.domain.PaymentStatus; import roomescape.service.payment.PaymentConfirmation; import roomescape.service.payment.PaymentFailureCategory; +import roomescape.service.payment.PaymentGatewayException; import roomescape.service.payment.PaymentResult; import java.util.stream.Stream; @@ -66,6 +68,20 @@ void tearDown() throws Exception { .contains("\"amount\":20000"); } + @Test + void 토스_통신_예외를_승인_결과_확인_필요_예외로_변환한다() { + server.enqueue(new MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AFTER_REQUEST)); + TossPaymentGateway gateway = new TossPaymentGateway(RestClient.builder() + .baseUrl(server.url("/").toString()) + .build(), new ObjectMapper()); + + org.assertj.core.api.Assertions.assertThatThrownBy(() -> gateway.confirm(new PaymentConfirmation( + "test_payment_key", "payment_123456789012345678901", 20_000L))) + .isInstanceOf(PaymentGatewayException.class) + .hasFieldOrPropertyWithValue("code", "PAYMENT_CONFIRMATION_UNKNOWN") + .hasFieldOrPropertyWithValue("failureCategory", PaymentFailureCategory.CONFIRMATION_UNKNOWN); + } + @ParameterizedTest @MethodSource("errorResponses") void 토스_오류를_코드에_맞는_예외로_변환한다(String code, int status, From 3531e7f5ee4a8601c4c4a98a2e83e56b9e2e9ec7 Mon Sep 17 00:00:00 2001 From: tjdakf Date: Thu, 25 Jun 2026 22:09:17 +0900 Subject: [PATCH 06/11] =?UTF-8?q?feat:=20=EA=B2=B0=EC=A0=9C=20=EC=8A=B9?= =?UTF-8?q?=EC=9D=B8=20=ED=99=95=EC=9D=B8=20=ED=95=84=EC=9A=94=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../view/PaymentSuccessController.java | 3 +++ .../resources/templates/payment-fail.html | 12 ++++++---- .../view/PaymentSuccessControllerTest.java | 22 +++++++++++++++++++ 3 files changed, 33 insertions(+), 4 deletions(-) diff --git a/src/main/java/roomescape/controller/view/PaymentSuccessController.java b/src/main/java/roomescape/controller/view/PaymentSuccessController.java index 391d2d931c..7ef82a5daf 100644 --- a/src/main/java/roomescape/controller/view/PaymentSuccessController.java +++ b/src/main/java/roomescape/controller/view/PaymentSuccessController.java @@ -16,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) { @@ -68,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)); } diff --git a/src/main/resources/templates/payment-fail.html b/src/main/resources/templates/payment-fail.html index f2da1fa699..9bc8b599bb 100644 --- a/src/main/resources/templates/payment-fail.html +++ b/src/main/resources/templates/payment-fail.html @@ -15,10 +15,13 @@
-

PAYMENT NOT COMPLETED

+

PAYMENT NOT COMPLETED

-

결제를 완료하지 못했어요

-

예약은 결제 대기 상태로 유지됩니다. 내 예약에서 다시 결제하거나 취소할 수 있어요.

+

결제를 완료하지 못했어요

+

예약은 결제 대기 상태로 유지됩니다. 내 예약에서 다시 결제하거나 취소할 수 있어요.

예약 정보

@@ -38,7 +41,8 @@

예약 정보

-

결제 실패 정보

+

결제 실패 정보

에러 코드
diff --git a/src/test/java/roomescape/controller/view/PaymentSuccessControllerTest.java b/src/test/java/roomescape/controller/view/PaymentSuccessControllerTest.java index 70c7cae3f2..d451430c9d 100644 --- a/src/test/java/roomescape/controller/view/PaymentSuccessControllerTest.java +++ b/src/test/java/roomescape/controller/view/PaymentSuccessControllerTest.java @@ -114,9 +114,31 @@ class PaymentSuccessControllerTest { .andExpect(status().isOk()) .andExpect(view().name("payment-fail")) .andExpect(model().attribute("code", "PAY_PROCESS_CANCELED")) + .andExpect(model().attribute("confirmationUnknown", false)) .andExpect(model().attribute("orderId", (Object) null)); } + @Test + void 결제_승인_결과를_알_수_없으면_확인_필요_상태로_렌더링한다() throws Exception { + String orderId = "payment_123456789012345678901"; + given(paymentService.confirm("test_payment_key", orderId, 20_000L)) + .willThrow(new PaymentGatewayException( + PaymentFailureCategory.CONFIRMATION_UNKNOWN, + "PAYMENT_CONFIRMATION_UNKNOWN", + "결제 승인 결과를 확인할 수 없습니다.")); + given(paymentService.findReservationByOrderId(orderId)).willReturn(reservation()); + + mockMvc.perform(get("/payments/success") + .param("paymentKey", "test_payment_key") + .param("orderId", orderId) + .param("amount", "20000")) + .andExpect(status().isOk()) + .andExpect(view().name("payment-fail")) + .andExpect(model().attribute("code", "PAYMENT_CONFIRMATION_UNKNOWN")) + .andExpect(model().attribute("confirmationUnknown", true)) + .andExpect(model().attribute("reservation", PaymentReservationView.from(reservation()))); + } + private Reservation reservation() { return new Reservation(1L, new Reserver("브라운"), new ReservationSlot(java.time.LocalDate.of(2099, 1, 1), From 8e64b2c5e5b76513d63a210845599cffbd417a55 Mon Sep 17 00:00:00 2001 From: tjdakf Date: Thu, 25 Jun 2026 22:21:22 +0900 Subject: [PATCH 07/11] =?UTF-8?q?feat:=20=ED=86=A0=EC=8A=A4=20=EA=B2=B0?= =?UTF-8?q?=EC=A0=9C=20=EC=8A=B9=EC=9D=B8=20=EC=9A=94=EC=B2=AD=EC=97=90=20?= =?UTF-8?q?=EB=A9=B1=EB=93=B1=ED=82=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/roomescape/payment/client/TossPaymentGateway.java | 1 + .../java/roomescape/payment/client/TossPaymentGatewayTest.java | 1 + 2 files changed, 2 insertions(+) diff --git a/src/main/java/roomescape/payment/client/TossPaymentGateway.java b/src/main/java/roomescape/payment/client/TossPaymentGateway.java index daeeb0128f..797dfcef91 100644 --- a/src/main/java/roomescape/payment/client/TossPaymentGateway.java +++ b/src/main/java/roomescape/payment/client/TossPaymentGateway.java @@ -36,6 +36,7 @@ public PaymentResult confirm(PaymentConfirmation confirmation) { response = tossRestClient.post() .uri("/v1/payments/confirm") .contentType(MediaType.APPLICATION_JSON) + .header("Idempotency-Key", confirmation.orderId()) .body(request) .retrieve() .onStatus(HttpStatusCode::isError, (requestHeaders, clientResponse) -> { diff --git a/src/test/java/roomescape/payment/client/TossPaymentGatewayTest.java b/src/test/java/roomescape/payment/client/TossPaymentGatewayTest.java index 404025061c..19a6bfa703 100644 --- a/src/test/java/roomescape/payment/client/TossPaymentGatewayTest.java +++ b/src/test/java/roomescape/payment/client/TossPaymentGatewayTest.java @@ -62,6 +62,7 @@ void tearDown() throws Exception { RecordedRequest request = server.takeRequest(); assertThat(request.getMethod()).isEqualTo("POST"); assertThat(request.getPath()).isEqualTo("/v1/payments/confirm"); + assertThat(request.getHeader("Idempotency-Key")).isEqualTo("payment_123456789012345678901"); assertThat(request.getBody().readUtf8()) .contains("\"paymentKey\":\"test_payment_key\"") .contains("\"orderId\":\"payment_123456789012345678901\"") From 9f715668ca3c19b10173236f385c5536d073e0bb Mon Sep 17 00:00:00 2001 From: tjdakf Date: Thu, 25 Jun 2026 22:23:30 +0900 Subject: [PATCH 08/11] =?UTF-8?q?feat:=20=ED=99=95=EC=9D=B8=20=ED=95=84?= =?UTF-8?q?=EC=9A=94=20=EA=B2=B0=EC=A0=9C=20=EC=8A=B9=EC=9D=B8=20=EC=9E=AC?= =?UTF-8?q?=EC=8B=9C=EB=8F=84=20=ED=97=88=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/roomescape/domain/Payment.java | 18 +++++++++------ .../roomescape/service/PaymentService.java | 8 +++++-- .../java/roomescape/domain/PaymentTest.java | 14 ++++++++++++ .../service/PaymentServiceTest.java | 22 +++++++++++++++++++ 4 files changed, 53 insertions(+), 9 deletions(-) diff --git a/src/main/java/roomescape/domain/Payment.java b/src/main/java/roomescape/domain/Payment.java index b577812114..79083f98ad 100644 --- a/src/main/java/roomescape/domain/Payment.java +++ b/src/main/java/roomescape/domain/Payment.java @@ -47,19 +47,19 @@ public Payment withId(Long id) { } 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 @@ -69,8 +69,8 @@ public Payment fail(String failureCode, String failureMessage) { } public Payment checkRequired(String failureCode, String failureMessage) { - if (status != PaymentStatus.READY) { - throw new IllegalStateException("결제 대기 상태에서만 확인 필요 처리할 수 있습니다."); + if (!canConfirm()) { + throw new IllegalStateException("결제 대기 또는 확인 필요 상태에서만 확인 필요 처리할 수 있습니다."); } return new Payment(id, reservationId, orderId, amount, paymentKey, PaymentStatus.CHECK_REQUIRED, failureCode, failureMessage); @@ -155,4 +155,8 @@ private void validateStatusFields(PaymentStatus status, String paymentKey, Strin throw new IllegalArgumentException("실패, 취소 또는 확인 필요 결제는 실패 코드가 필요합니다."); } } + + private boolean canConfirm() { + return status == PaymentStatus.READY || status == PaymentStatus.CHECK_REQUIRED; + } } diff --git a/src/main/java/roomescape/service/PaymentService.java b/src/main/java/roomescape/service/PaymentService.java index 0fb447b459..5c43ca14ac 100644 --- a/src/main/java/roomescape/service/PaymentService.java +++ b/src/main/java/roomescape/service/PaymentService.java @@ -66,9 +66,9 @@ public PaymentResult confirm(String paymentKey, String orderId, Long amount) { if (!payment.getAmount().equals(amount)) { throw new PaymentAmountMismatchException(payment.getAmount(), amount); } - if (payment.getStatus() != PaymentStatus.READY) { + if (!canConfirm(payment)) { throw new RoomescapeException(ErrorCode.PAYMENT_CONFIRMATION_NOT_ALLOWED, - "결제 대기 상태의 결제만 승인할 수 있습니다."); + "결제 대기 또는 확인 필요 상태의 결제만 승인할 수 있습니다."); } PaymentResult result; @@ -111,4 +111,8 @@ private Payment reuseOrCreatePayment(Payment payment, Long reservationId) { throw new RoomescapeException(ErrorCode.PAYMENT_RETRY_NOT_ALLOWED, "결제를 다시 시도할 수 없는 상태입니다."); } + + private boolean canConfirm(Payment payment) { + return payment.getStatus() == PaymentStatus.READY || payment.getStatus() == PaymentStatus.CHECK_REQUIRED; + } } diff --git a/src/test/java/roomescape/domain/PaymentTest.java b/src/test/java/roomescape/domain/PaymentTest.java index 740d683bad..41a82c9de2 100644 --- a/src/test/java/roomescape/domain/PaymentTest.java +++ b/src/test/java/roomescape/domain/PaymentTest.java @@ -50,4 +50,18 @@ class PaymentTest { assertThat(checkRequiredPayment.getStatus()).isEqualTo(PaymentStatus.CHECK_REQUIRED); assertThat(checkRequiredPayment.getFailureCode()).isEqualTo("PAYMENT_CONFIRMATION_UNKNOWN"); } + + @Test + void 결제_승인_결과_확인이_필요한_결제를_승인하면_실패_정보를_초기화한다() { + Payment payment = Payment.restore(1L, 1L, "payment_check_required_123456789", 20_000L, + null, PaymentStatus.CHECK_REQUIRED, + "PAYMENT_CONFIRMATION_UNKNOWN", "결제 승인 결과를 확인할 수 없습니다."); + + Payment confirmedPayment = payment.confirm("test_payment_key"); + + assertThat(confirmedPayment.getStatus()).isEqualTo(PaymentStatus.CONFIRMED); + assertThat(confirmedPayment.getPaymentKey()).isEqualTo("test_payment_key"); + assertThat(confirmedPayment.getFailureCode()).isNull(); + assertThat(confirmedPayment.getFailureMessage()).isNull(); + } } diff --git a/src/test/java/roomescape/service/PaymentServiceTest.java b/src/test/java/roomescape/service/PaymentServiceTest.java index c275e802a1..d07f91e068 100644 --- a/src/test/java/roomescape/service/PaymentServiceTest.java +++ b/src/test/java/roomescape/service/PaymentServiceTest.java @@ -145,6 +145,28 @@ class PaymentServiceTest { verify(reservationService).confirmPayment(1L); } + @Test + void 확인_필요_상태의_결제도_같은_주문으로_승인을_재시도할_수_있다() { + Payment payment = Payment.restore(1L, 1L, "payment_check_required_123456789", 20_000L, + null, PaymentStatus.CHECK_REQUIRED, + "PAYMENT_CONFIRMATION_UNKNOWN", "결제 승인 결과를 확인할 수 없습니다."); + PaymentConfirmation confirmation = new PaymentConfirmation("test_payment_key", payment.getOrderId(), 20_000L); + PaymentResult result = new PaymentResult("test_payment_key", payment.getOrderId(), PaymentStatus.CONFIRMED, 20_000L); + when(paymentRepository.findByOrderId(payment.getOrderId())).thenReturn(Optional.of(payment)); + when(paymentGateway.confirm(confirmation)).thenReturn(result); + + PaymentResult actual = paymentService.confirm("test_payment_key", payment.getOrderId(), 20_000L); + + assertThat(actual).isEqualTo(result); + verify(paymentGateway).confirm(confirmation); + ArgumentCaptor paymentCaptor = ArgumentCaptor.forClass(Payment.class); + verify(paymentRepository).update(paymentCaptor.capture()); + assertThat(paymentCaptor.getValue().getStatus()).isEqualTo(PaymentStatus.CONFIRMED); + assertThat(paymentCaptor.getValue().getPaymentKey()).isEqualTo("test_payment_key"); + assertThat(paymentCaptor.getValue().getFailureCode()).isNull(); + verify(reservationService).confirmPayment(1L); + } + @Test void 확정적인_승인_실패는_결제를_실패_상태로_저장한다() { Payment payment = readyPayment(); From 7ddfd90ad66c9e00c484e6da0ebafa27485660d1 Mon Sep 17 00:00:00 2001 From: tjdakf Date: Thu, 25 Jun 2026 22:49:50 +0900 Subject: [PATCH 09/11] =?UTF-8?q?feat:=20=EC=98=88=EC=95=BD=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EC=9D=91=EB=8B=B5=EC=97=90=20=EA=B2=B0=EC=A0=9C=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/response/BookingPaymentResponse.java | 28 +++++++++++++++++++ .../dto/response/BookingStatusResponse.java | 6 ++-- .../service/BookingLookupService.java | 16 +++++++++-- .../service/dto/BookingPaymentInfo.java | 28 +++++++++++++++++++ .../roomescape/service/dto/BookingStatus.java | 19 +++++++++++-- .../admin/AdminBookingControllerTest.java | 2 ++ .../user/BookingControllerTest.java | 11 +++++++- .../service/BookingLookupServiceTest.java | 16 +++++++++-- 8 files changed, 115 insertions(+), 11 deletions(-) create mode 100644 src/main/java/roomescape/controller/dto/response/BookingPaymentResponse.java create mode 100644 src/main/java/roomescape/service/dto/BookingPaymentInfo.java diff --git a/src/main/java/roomescape/controller/dto/response/BookingPaymentResponse.java b/src/main/java/roomescape/controller/dto/response/BookingPaymentResponse.java new file mode 100644 index 0000000000..992d2491b6 --- /dev/null +++ b/src/main/java/roomescape/controller/dto/response/BookingPaymentResponse.java @@ -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() + ); + } +} diff --git a/src/main/java/roomescape/controller/dto/response/BookingStatusResponse.java b/src/main/java/roomescape/controller/dto/response/BookingStatusResponse.java index 158dc5945f..18c38c75f8 100644 --- a/src/main/java/roomescape/controller/dto/response/BookingStatusResponse.java +++ b/src/main/java/roomescape/controller/dto/response/BookingStatusResponse.java @@ -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) { @@ -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()) ); } } diff --git a/src/main/java/roomescape/service/BookingLookupService.java b/src/main/java/roomescape/service/BookingLookupService.java index 24cba992c0..6920d6e11c 100644 --- a/src/main/java/roomescape/service/BookingLookupService.java +++ b/src/main/java/roomescape/service/BookingLookupService.java @@ -9,6 +9,7 @@ import org.springframework.transaction.annotation.Transactional; import roomescape.exception.ErrorCode; import roomescape.exception.RoomescapeException; +import roomescape.repository.PaymentRepository; import roomescape.service.dto.BookingStatus; @Service @@ -16,17 +17,23 @@ public class BookingLookupService { private final ReservationService reservationService; private final ReservationWaitingService reservationWaitingService; + private final PaymentRepository paymentRepository; public BookingLookupService(ReservationService reservationService, - ReservationWaitingService reservationWaitingService) { + ReservationWaitingService reservationWaitingService, + PaymentRepository paymentRepository) { this.reservationService = reservationService; this.reservationWaitingService = reservationWaitingService; + this.paymentRepository = paymentRepository; } public List findByName(String name) { return Stream.concat( reservationService.findByName(name).stream() - .map(BookingStatus::reservation), + .map(reservation -> BookingStatus.reservation( + reservation, + paymentRepository.findLatestByReservationId(reservation.getId()).orElse(null) + )), reservationWaitingService.findByName(name).stream() .map(BookingStatus::waiting)) .sorted(Comparator @@ -41,7 +48,10 @@ public List findByDateRange(LocalDate startDate, LocalDate endDat } return Stream.concat( reservationService.findByDateRange(startDate, endDate).stream() - .map(BookingStatus::reservation), + .map(reservation -> BookingStatus.reservation( + reservation, + paymentRepository.findLatestByReservationId(reservation.getId()).orElse(null) + )), reservationWaitingService.findByDateRange(startDate, endDate).stream() .map(BookingStatus::waiting)) .sorted(Comparator diff --git a/src/main/java/roomescape/service/dto/BookingPaymentInfo.java b/src/main/java/roomescape/service/dto/BookingPaymentInfo.java new file mode 100644 index 0000000000..535f621c78 --- /dev/null +++ b/src/main/java/roomescape/service/dto/BookingPaymentInfo.java @@ -0,0 +1,28 @@ +package roomescape.service.dto; + +import roomescape.domain.Payment; +import roomescape.domain.PaymentStatus; + +public record BookingPaymentInfo( + String orderId, + Long amount, + String paymentKey, + PaymentStatus status, + String failureCode, + String failureMessage +) { + + public static BookingPaymentInfo from(Payment payment) { + if (payment == null) { + return null; + } + return new BookingPaymentInfo( + payment.getOrderId(), + payment.getAmount(), + payment.getPaymentKey(), + payment.getStatus(), + payment.getFailureCode(), + payment.getFailureMessage() + ); + } +} diff --git a/src/main/java/roomescape/service/dto/BookingStatus.java b/src/main/java/roomescape/service/dto/BookingStatus.java index da8540063a..498e4d84e5 100644 --- a/src/main/java/roomescape/service/dto/BookingStatus.java +++ b/src/main/java/roomescape/service/dto/BookingStatus.java @@ -1,6 +1,7 @@ package roomescape.service.dto; import java.time.LocalDate; +import roomescape.domain.Payment; import roomescape.domain.Reservation; import roomescape.domain.ReservationSlot; import roomescape.domain.ReservationStatus; @@ -17,9 +18,19 @@ public record BookingStatus( Theme theme, BookingType bookingType, ReservationStatus reservationStatus, - Long turn + Long turn, + BookingPaymentInfo payment ) { + public BookingStatus(Long id, String name, LocalDate date, ReservationTime time, Theme theme, + BookingType bookingType, ReservationStatus reservationStatus, Long turn) { + this(id, name, date, time, theme, bookingType, reservationStatus, turn, null); + } + public static BookingStatus reservation(Reservation reservation) { + return reservation(reservation, null); + } + + public static BookingStatus reservation(Reservation reservation, Payment payment) { ReservationSlot slot = reservation.getSlot(); return new BookingStatus( reservation.getId(), @@ -29,7 +40,8 @@ public static BookingStatus reservation(Reservation reservation) { slot.getTheme(), BookingType.RESERVATION, reservation.getStatus(), - null + null, + BookingPaymentInfo.from(payment) ); } @@ -44,7 +56,8 @@ public static BookingStatus waiting(WaitingWithTurn waitingWithTurn) { slot.getTheme(), BookingType.WAITING, null, - waitingWithTurn.turn() + waitingWithTurn.turn(), + null ); } } diff --git a/src/test/java/roomescape/controller/admin/AdminBookingControllerTest.java b/src/test/java/roomescape/controller/admin/AdminBookingControllerTest.java index d29353a454..189bd7a43b 100644 --- a/src/test/java/roomescape/controller/admin/AdminBookingControllerTest.java +++ b/src/test/java/roomescape/controller/admin/AdminBookingControllerTest.java @@ -55,9 +55,11 @@ class AdminBookingControllerTest { .andExpect(jsonPath("$[0].bookingType").value("RESERVATION")) .andExpect(jsonPath("$[0].reservationStatus").value("CONFIRMED")) .andExpect(jsonPath("$[0].turn").value(nullValue())) + .andExpect(jsonPath("$[0].payment").value(nullValue())) .andExpect(jsonPath("$[1].id").value(2)) .andExpect(jsonPath("$[1].bookingType").value("WAITING")) .andExpect(jsonPath("$[1].reservationStatus").value(nullValue())) + .andExpect(jsonPath("$[1].payment").value(nullValue())) .andExpect(jsonPath("$[1].turn").value(1)); verify(bookingLookupService, times(1)).findByDateRange(startDate, endDate); diff --git a/src/test/java/roomescape/controller/user/BookingControllerTest.java b/src/test/java/roomescape/controller/user/BookingControllerTest.java index 0ba649d140..389d654245 100644 --- a/src/test/java/roomescape/controller/user/BookingControllerTest.java +++ b/src/test/java/roomescape/controller/user/BookingControllerTest.java @@ -20,8 +20,10 @@ import org.springframework.test.web.servlet.MockMvc; import roomescape.domain.ReservationTime; import roomescape.domain.ReservationStatus; +import roomescape.domain.PaymentStatus; import roomescape.domain.Theme; import roomescape.service.BookingLookupService; +import roomescape.service.dto.BookingPaymentInfo; import roomescape.service.dto.BookingStatus; import roomescape.service.dto.BookingType; @@ -51,9 +53,14 @@ class BookingControllerTest { .andExpect(jsonPath("$[0].bookingType").value("RESERVATION")) .andExpect(jsonPath("$[0].reservationStatus").value("CONFIRMED")) .andExpect(jsonPath("$[0].turn").value(nullValue())) + .andExpect(jsonPath("$[0].payment.orderId").value("payment_confirmed_123456789012345")) + .andExpect(jsonPath("$[0].payment.amount").value(20000)) + .andExpect(jsonPath("$[0].payment.paymentKey").value("test_payment_key")) + .andExpect(jsonPath("$[0].payment.status").value("CONFIRMED")) .andExpect(jsonPath("$[1].id").value(2)) .andExpect(jsonPath("$[1].bookingType").value("WAITING")) .andExpect(jsonPath("$[1].reservationStatus").value(nullValue())) + .andExpect(jsonPath("$[1].payment").value(nullValue())) .andExpect(jsonPath("$[1].turn").value(1)); verify(bookingLookupService, times(1)).findByName("브라운"); @@ -74,7 +81,9 @@ private BookingStatus reservationBooking() { ReservationTime time = new ReservationTime(1L, LocalTime.of(10, 0)); Theme theme = new Theme(1L, "테마", "설명", "썸네일"); return new BookingStatus(1L, "브라운", LocalDate.of(2099, 1, 1), time, theme, - BookingType.RESERVATION, ReservationStatus.CONFIRMED, null); + BookingType.RESERVATION, ReservationStatus.CONFIRMED, null, + new BookingPaymentInfo("payment_confirmed_123456789012345", 20_000L, + "test_payment_key", PaymentStatus.CONFIRMED, null, null)); } private BookingStatus waitingBooking() { diff --git a/src/test/java/roomescape/service/BookingLookupServiceTest.java b/src/test/java/roomescape/service/BookingLookupServiceTest.java index 7086b4778c..6ae7e69deb 100644 --- a/src/test/java/roomescape/service/BookingLookupServiceTest.java +++ b/src/test/java/roomescape/service/BookingLookupServiceTest.java @@ -10,6 +10,8 @@ import java.time.LocalTime; import java.util.List; import org.junit.jupiter.api.Test; +import roomescape.domain.Payment; +import roomescape.domain.PaymentStatus; import roomescape.domain.Reservation; import roomescape.domain.ReservationSlot; import roomescape.domain.ReservationStatus; @@ -20,6 +22,7 @@ import roomescape.domain.WaitingWithTurn; import roomescape.exception.ErrorCode; import roomescape.exception.RoomescapeException; +import roomescape.repository.PaymentRepository; import roomescape.service.dto.BookingStatus; import roomescape.service.dto.BookingType; @@ -27,9 +30,11 @@ class BookingLookupServiceTest { private final ReservationService reservationService = mock(); private final ReservationWaitingService reservationWaitingService = mock(); + private final PaymentRepository paymentRepository = mock(); private final BookingLookupService service = new BookingLookupService( reservationService, - reservationWaitingService); + reservationWaitingService, + paymentRepository); private final LocalDate date = LocalDate.now().plusDays(1); private final ReservationTime time = new ReservationTime(1L, LocalTime.parse("10:00")); @@ -45,6 +50,10 @@ class BookingLookupServiceTest { when(reservationService.findByName(name)).thenReturn(List.of(reservation)); when(reservationWaitingService.findByName(name)).thenReturn(List.of(waiting)); + when(paymentRepository.findLatestByReservationId(1L)).thenReturn(java.util.Optional.of( + Payment.restore(1L, 1L, "payment_ready_123456789012345678901", 20_000L, null, + PaymentStatus.READY, null, null) + )); List result = service.findByName(name); @@ -55,7 +64,10 @@ class BookingLookupServiceTest { .containsExactly(BookingType.WAITING, BookingType.RESERVATION), () -> assertThat(result).extracting(BookingStatus::reservationStatus) .containsExactly(null, ReservationStatus.CONFIRMED), - () -> assertThat(result).extracting(BookingStatus::turn).containsExactly(1L, null)); + () -> assertThat(result).extracting(BookingStatus::turn).containsExactly(1L, null), + () -> assertThat(result.get(1).payment().orderId()).isEqualTo("payment_ready_123456789012345678901"), + () -> assertThat(result.get(1).payment().status()).isEqualTo(PaymentStatus.READY), + () -> assertThat(result.get(0).payment()).isNull()); } @Test From 18e467e4a49e5d87bc076644765b39fe06b77f9c Mon Sep 17 00:00:00 2001 From: tjdakf Date: Thu, 25 Jun 2026 22:52:27 +0900 Subject: [PATCH 10/11] =?UTF-8?q?feat:=20=EB=82=B4=20=EC=98=88=EC=95=BD=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=EC=97=90=20=EA=B2=B0=EC=A0=9C=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/static/css/app.css | 6 + .../resources/templates/my-reservation.html | 111 +++++++++++++++++- 2 files changed, 111 insertions(+), 6 deletions(-) diff --git a/src/main/resources/static/css/app.css b/src/main/resources/static/css/app.css index 68c628e1d7..9165d55c15 100644 --- a/src/main/resources/static/css/app.css +++ b/src/main/resources/static/css/app.css @@ -897,6 +897,12 @@ tbody tr.edit-row:hover { background: var(--accent-dim); } +.status-badge.failed { + border-color: rgba(213,108,91,0.45); + color: var(--danger); + background: rgba(213,108,91,0.08); +} + .action-buttons { display: flex; justify-content: flex-end; diff --git a/src/main/resources/templates/my-reservation.html b/src/main/resources/templates/my-reservation.html index ccf626cd3f..17298a6fe4 100644 --- a/src/main/resources/templates/my-reservation.html +++ b/src/main/resources/templates/my-reservation.html @@ -40,13 +40,14 @@ 시간 테마 상태 + 결제 상태 대기 순번 관리 - 조회된 예약이 없습니다 + 조회된 예약이 없습니다 @@ -64,6 +65,7 @@ let editingReservationId = null; let editingTimeId = null; let editingTimes = null; + let detailBookingKey = null; window.onload = () => { nameInput.addEventListener('keydown', event => { @@ -94,7 +96,7 @@ } resultMeta.textContent = '예약·대기 목록을 불러오는 중입니다.'; - reservationBody.innerHTML = `조회 중입니다`; + reservationBody.innerHTML = `조회 중입니다`; fetch(`/bookings?name=${encodeURIComponent(name)}`) .then(handleResponse) @@ -103,6 +105,7 @@ editingReservationId = null; editingTimeId = null; editingTimes = null; + detailBookingKey = null; renderReservations(name, reservations); }) .catch(showError); @@ -113,7 +116,7 @@ const sortedRows = [...rows].sort(compareReservation); resultMeta.textContent = `${name}님의 예약·대기 ${rows.length}건`; if (sortedRows.length === 0) { - reservationBody.innerHTML = `조회된 예약이 없습니다`; + reservationBody.innerHTML = `조회된 예약이 없습니다`; return; } @@ -130,6 +133,7 @@ ${formatTime(reservation.time?.startAt)} ${escapeHtml(reservation.theme?.name || '')} ${renderStatusBadge(reservation)} + ${renderPaymentStatusBadge(reservation)} ${waiting ? `${reservation.turn}번째` : '-'}
@@ -140,6 +144,9 @@ if (editing) { row += renderEditRow(reservation); } + if (isDetailOpen(reservation)) { + row += renderDetailRow(reservation); + } return row; }).join(''); } @@ -147,7 +154,7 @@ function renderEditRow(reservation) { return ` - +
현재 예약: ${reservation.date} ${formatTime(reservation.time?.startAt)} · ${escapeHtml(reservation.theme?.name || '')} @@ -174,6 +181,40 @@ `; } + function renderDetailRow(reservation) { + const payment = reservation.payment; + return ` + + +
+
+ 상세 정보: ${reservation.date} ${formatTime(reservation.time?.startAt)} · ${escapeHtml(reservation.theme?.name || '')} +
+
+
+ 예약 정보 +
${escapeHtml(reservation.name || '')}
+
${escapeHtml(reservation.theme?.name || '')}
+
${reservation.date} ${formatTime(reservation.time?.startAt)}
+
+
+ 결제 정보 + ${payment ? ` +
상태: ${getPaymentStatusLabel(payment.status)}
+
주문번호: ${escapeHtml(payment.orderId || '-')}
+
승인키: ${escapeHtml(payment.paymentKey || '-')}
+
금액: ${formatAmount(payment.amount)}
+ ${payment.failureCode ? `
실패 코드: ${escapeHtml(payment.failureCode)}
` : ''} + ${payment.failureMessage ? `
사유: ${escapeHtml(payment.failureMessage)}
` : ''} + ` : '
결제 정보가 없습니다.
'} +
+
+
+ + + `; + } + function renderEditTimes(reservationId) { if (editingTimes === null) { return '
날짜를 기준으로 시간을 불러오는 중입니다
'; @@ -360,6 +401,7 @@ time: reservation.time?.startAt, theme: reservation.theme?.name, status: getStatusLabel(reservation), + payment: getPaymentStatusLabel(reservation.payment?.status), turn: reservation.turn ?? 0 }[key] || ''; } @@ -394,17 +436,49 @@ return value ? value.slice(0, 5) : ''; } + function formatAmount(value) { + if (value === null || value === undefined) { + return '-'; + } + return `${Number(value).toLocaleString('ko-KR')}원`; + } + + function getBookingKey(reservation) { + return `${reservation.bookingType}-${reservation.id}`; + } + + function isDetailOpen(reservation) { + return detailBookingKey === getBookingKey(reservation); + } + + function toggleDetail(id, status) { + const reservation = findReservation(id, status); + if (!reservation) { + showToast('예약 정보를 찾을 수 없습니다', 'error'); + return; + } + const key = getBookingKey(reservation); + detailBookingKey = detailBookingKey === key ? null : key; + renderReservations(nameInput.value.trim(), reservations); + } + function renderActions(reservation, waiting, pendingPayment) { if (waiting) { return ``; } + const detailButton = ``; if (pendingPayment) { + const retryButton = reservation.payment?.status === 'CHECK_REQUIRED' + ? '' + : ``; return ` - + ${detailButton} + ${retryButton} `; } return ` + ${detailButton} 결제 취소 기능 준비 중 `; @@ -432,6 +506,21 @@ return '예약 확정'; } + function renderPaymentStatusBadge(reservation) { + if (reservation.bookingType === 'WAITING') { + return '-'; + } + const status = reservation.payment?.status; + const label = getPaymentStatusLabel(status); + if (status === 'CONFIRMED') { + return `${label}`; + } + if (status === 'FAILED' || status === 'CANCELED') { + return `${label}`; + } + return `${label}`; + } + function getStatusLabel(reservation) { if (reservation.bookingType === 'WAITING') { return '대기'; @@ -439,6 +528,16 @@ return reservation.reservationStatus === 'PENDING' ? '결제 대기' : '예약 확정'; } + function getPaymentStatusLabel(status) { + return { + READY: '결제 대기', + CONFIRMED: '결제 완료', + FAILED: '결제 실패', + CANCELED: '결제 취소', + CHECK_REQUIRED: '확인 필요' + }[status] || '-'; + } + function escapeHtml(value) { return String(value) .replaceAll('&', '&') @@ -476,7 +575,7 @@ function showError(error) { resultMeta.textContent = '예약·대기 목록을 불러오지 못했습니다.'; - reservationBody.innerHTML = `조회된 예약이 없습니다`; + reservationBody.innerHTML = `조회된 예약이 없습니다`; showToast(error.message, 'error'); } From 6b521f679746bbdd17036e97436186b30a35bd68 Mon Sep 17 00:00:00 2001 From: tjdakf Date: Thu, 25 Jun 2026 23:00:40 +0900 Subject: [PATCH 11/11] =?UTF-8?q?style:=20=EB=82=B4=20=EC=98=88=EC=95=BD?= =?UTF-8?q?=20=EA=B2=B0=EC=A0=9C=20=EC=83=81=EC=84=B8=20=ED=99=94=EB=A9=B4?= =?UTF-8?q?=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/static/css/app.css | 99 ++++++++++++++++++- .../resources/templates/my-reservation.html | 73 ++++++++++---- 2 files changed, 152 insertions(+), 20 deletions(-) diff --git a/src/main/resources/static/css/app.css b/src/main/resources/static/css/app.css index 9165d55c15..63804679ce 100644 --- a/src/main/resources/static/css/app.css +++ b/src/main/resources/static/css/app.css @@ -86,6 +86,10 @@ main { padding: 3rem 2rem 6rem; } +body.my-page main { + max-width: 1120px; +} + /* 스텝 인디케이터 */ .steps { display: flex; @@ -791,7 +795,7 @@ button:disabled { .table-wrap { border: 1px solid var(--border); border-radius: 8px; - overflow: hidden; + overflow-x: auto; background: var(--surface); } @@ -836,6 +840,26 @@ tbody tr.edit-row:hover { background: rgba(200,169,110,0.04); } +body.my-page th, +body.my-page td { + white-space: nowrap; +} + +body.my-page td:nth-child(4) { + min-width: 140px; + white-space: normal; +} + +body.my-page tr.edit-row td, +body.my-page tr.detail-row td { + white-space: normal; +} + +tbody tr.detail-row, +tbody tr.detail-row:hover { + background: rgba(200,169,110,0.035); +} + .sort-indicator { display: inline-block; min-width: 1.2rem; @@ -946,6 +970,70 @@ tbody tr.edit-row:hover { min-height: 42px; } +.booking-detail-panel { + border: 1px solid var(--border); + border-radius: 10px; + background: var(--surface-2); + padding: 1.1rem 1.2rem; +} + +.booking-detail-title { + display: flex; + justify-content: space-between; + gap: 1rem; + color: var(--text-primary); + font-weight: 500; + margin-bottom: 1rem; +} + +.booking-detail-title span { + color: var(--accent); + font-size: 0.82rem; + font-weight: 400; +} + +.booking-detail-grid { + display: grid; + grid-template-columns: minmax(220px, 0.75fr) minmax(320px, 1.25fr); + gap: 1.25rem; +} + +.booking-detail-section h3 { + color: var(--text-muted); + font-size: 0.68rem; + font-weight: 500; + letter-spacing: 0.1em; + text-transform: uppercase; + margin-bottom: 0.65rem; +} + +.booking-detail-section dl { + display: grid; + gap: 0.55rem; +} + +.booking-detail-section dl > div { + display: grid; + grid-template-columns: 72px minmax(0, 1fr); + gap: 0.9rem; + align-items: start; +} + +.booking-detail-section dt { + color: var(--text-secondary); + font-size: 0.78rem; +} + +.booking-detail-section dd { + color: var(--text-primary); + font-size: 0.86rem; +} + +.booking-detail-section .code-value { + overflow-wrap: anywhere; + word-break: break-word; +} + .edit-actions { display: flex; justify-content: flex-end; @@ -1268,6 +1356,15 @@ body.payment-result-fail { .edit-grid { grid-template-columns: 1fr; } + .booking-detail-grid, + .booking-detail-title { + grid-template-columns: 1fr; + flex-direction: column; + } + .booking-detail-section dl > div { + grid-template-columns: 1fr; + gap: 0.2rem; + } .edit-actions { justify-content: stretch; } diff --git a/src/main/resources/templates/my-reservation.html b/src/main/resources/templates/my-reservation.html index 17298a6fe4..6e9d517227 100644 --- a/src/main/resources/templates/my-reservation.html +++ b/src/main/resources/templates/my-reservation.html @@ -184,28 +184,64 @@ function renderDetailRow(reservation) { const payment = reservation.payment; return ` - + -
-
- 상세 정보: ${reservation.date} ${formatTime(reservation.time?.startAt)} · ${escapeHtml(reservation.theme?.name || '')} +
+
+ ${escapeHtml(reservation.theme?.name || '')} + ${reservation.date} ${formatTime(reservation.time?.startAt)}
-
-
- 예약 정보 -
${escapeHtml(reservation.name || '')}
-
${escapeHtml(reservation.theme?.name || '')}
-
${reservation.date} ${formatTime(reservation.time?.startAt)}
+
+
+

예약 정보

+
+
+
예약자
+
${escapeHtml(reservation.name || '')}
+
+
+
테마
+
${escapeHtml(reservation.theme?.name || '')}
+
+
+
예약 일시
+
${reservation.date} ${formatTime(reservation.time?.startAt)}
+
+
-
- 결제 정보 +
+

결제 정보

${payment ? ` -
상태: ${getPaymentStatusLabel(payment.status)}
-
주문번호: ${escapeHtml(payment.orderId || '-')}
-
승인키: ${escapeHtml(payment.paymentKey || '-')}
-
금액: ${formatAmount(payment.amount)}
- ${payment.failureCode ? `
실패 코드: ${escapeHtml(payment.failureCode)}
` : ''} - ${payment.failureMessage ? `
사유: ${escapeHtml(payment.failureMessage)}
` : ''} +
+
+
상태
+
${getPaymentStatusLabel(payment.status)}
+
+
+
주문번호
+
${escapeHtml(payment.orderId || '-')}
+
+
+
승인키
+
${escapeHtml(payment.paymentKey || '-')}
+
+
+
금액
+
${formatAmount(payment.amount)}
+
+ ${payment.failureCode ? ` +
+
실패 코드
+
${escapeHtml(payment.failureCode)}
+
+ ` : ''} + ${payment.failureMessage ? ` +
+
사유
+
${escapeHtml(payment.failureMessage)}
+
+ ` : ''} +
` : '
결제 정보가 없습니다.
'}
@@ -480,7 +516,6 @@ return ` ${detailButton} - 결제 취소 기능 준비 중 `; }