diff --git a/src/main/java/in/koreatech/payment/client/TossPaymentClient.java b/src/main/java/in/koreatech/payment/client/TossPaymentClient.java index 97afdb5..17268f3 100644 --- a/src/main/java/in/koreatech/payment/client/TossPaymentClient.java +++ b/src/main/java/in/koreatech/payment/client/TossPaymentClient.java @@ -17,7 +17,7 @@ import in.koreatech.payment.client.dto.request.PaymentCancelRequest; import in.koreatech.payment.client.dto.request.PaymentConfirmRequest; import in.koreatech.payment.client.dto.response.PaymentCancelResponse; -import in.koreatech.payment.client.dto.response.PaymentConfirmResponse; +import in.koreatech.payment.client.dto.response.TossPaymentConfirmResponse; import in.koreatech.payment.client.exception.TossPaymentErrorCode; import in.koreatech.payment.client.exception.TossPaymentErrorResponse; import in.koreatech.payment.client.exception.TossPaymentException; @@ -47,7 +47,7 @@ public TossPaymentClient( .build(); } - public PaymentConfirmResponse requestConfirm(String paymentKey, String orderId, Integer amount) { + public TossPaymentConfirmResponse requestConfirm(String paymentKey, String orderId, Integer amount) { PaymentConfirmRequest request = new PaymentConfirmRequest(paymentKey, orderId, amount); try { @@ -55,7 +55,7 @@ public PaymentConfirmResponse requestConfirm(String paymentKey, String orderId, .uri("/confirm") .bodyValue(request) .retrieve() - .bodyToMono(PaymentConfirmResponse.class) + .bodyToMono(TossPaymentConfirmResponse.class) .block(); } catch (WebClientResponseException e) { diff --git a/src/main/java/in/koreatech/payment/client/dto/response/PaymentConfirmResponse.java b/src/main/java/in/koreatech/payment/client/dto/response/TossPaymentConfirmResponse.java similarity index 96% rename from src/main/java/in/koreatech/payment/client/dto/response/PaymentConfirmResponse.java rename to src/main/java/in/koreatech/payment/client/dto/response/TossPaymentConfirmResponse.java index 2034452..cbcab26 100644 --- a/src/main/java/in/koreatech/payment/client/dto/response/PaymentConfirmResponse.java +++ b/src/main/java/in/koreatech/payment/client/dto/response/TossPaymentConfirmResponse.java @@ -8,7 +8,7 @@ import in.koreatech.koin.domain.order.model.PaymentMethod; import in.koreatech.koin.domain.order.model.PaymentStatus; -public record PaymentConfirmResponse( +public record TossPaymentConfirmResponse( String paymentKey, Integer totalAmount, String status, diff --git a/src/main/java/in/koreatech/payment/controller/PaymentsApi.java b/src/main/java/in/koreatech/payment/controller/PaymentsApi.java index e1fdbd3..af6516f 100644 --- a/src/main/java/in/koreatech/payment/controller/PaymentsApi.java +++ b/src/main/java/in/koreatech/payment/controller/PaymentsApi.java @@ -16,6 +16,10 @@ import in.koreatech.payment.dto.response.TemporaryPaymentResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; @@ -70,6 +74,66 @@ ResponseEntity createTemporaryTakeoutPayment( @AccessToken final String accessToken ); + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "결제 승인 성공", + content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "배달", value = """ + { + "id": 1, + "delivery_address": "충청남도 천안시 동남구 병천면 충절로 1600 은솔관 422호", + "shop_address": "충청남도 천안시 동남구 병천면 충절로 1600 은솔관 422호", + "to_owner": "리뷰 이벤트 감사합니다.", + "to_rider": "문 앞에 놔주세요.", + "amount": 1000, + "shop_name": "굿모닝 살로만 치킨", + "menus": [ + { + "name": "허니콤보", + "quantity": 1, + "options": [ + { + "option_group_name": "소스 추가", + "option_name": "레드디핑 소스" + } + ] + } + ], + "order_type": "DELIVERY", + "requested_at": "2025-07-02T13:07:07.359Z", + "approved_at": "2025-07-02T13:07:07.360Z", + "payment_method": "카드" + } + """), + @ExampleObject(name = "포장", value = """ + { + "id": 1, + "delivery_address": null, + "shop_address": "충청남도 천안시 동남구 병천면 충절로 1600 은솔관 422호", + "to_owner": "리뷰 이벤트 감사합니다.", + "to_rider": null, + "amount": 1000, + "shop_name": "굿모닝 살로만 치킨", + "menus": [ + { + "name": "허니콤보", + "quantity": 1, + "options": [ + { + "option_group_name": "소스 추가", + "option_name": "레드디핑 소스" + } + ] + } + ], + "order_type": "TAKE_OUT", + "requested_at": "2025-07-02T13:07:07.359Z", + "approved_at": "2025-07-02T13:07:07.360Z", + "payment_method": "카드" + } + """) + }) + )} + ) @Operation( summary = "결제 승인을 한다.", description = """ diff --git a/src/main/java/in/koreatech/payment/controller/PaymentsController.java b/src/main/java/in/koreatech/payment/controller/PaymentsController.java index b06e0ca..e39fbb2 100644 --- a/src/main/java/in/koreatech/payment/controller/PaymentsController.java +++ b/src/main/java/in/koreatech/payment/controller/PaymentsController.java @@ -55,9 +55,8 @@ public ResponseEntity confirmPayment( @RequestBody @Valid final PaymentConfirmRequest request, @AccessToken final String accessToken ) { - Payment payment = paymentService.confirmPayment(accessToken, request.paymentKey(), request.orderId(), + PaymentConfirmResponse response = paymentService.confirmPayment(accessToken, request.paymentKey(), request.orderId(), request.amount()); - PaymentConfirmResponse response = PaymentConfirmResponse.from(payment); return ResponseEntity.ok(response); } diff --git a/src/main/java/in/koreatech/payment/dto/response/PaymentConfirmResponse.java b/src/main/java/in/koreatech/payment/dto/response/PaymentConfirmResponse.java index 2fdb76a..ead6d8d 100644 --- a/src/main/java/in/koreatech/payment/dto/response/PaymentConfirmResponse.java +++ b/src/main/java/in/koreatech/payment/dto/response/PaymentConfirmResponse.java @@ -1,14 +1,25 @@ package in.koreatech.payment.dto.response; import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import static in.koreatech.koin.domain.order.model.OrderType.DELIVERY; +import static in.koreatech.koin.domain.order.model.OrderType.TAKE_OUT; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import java.time.LocalDateTime; +import java.util.List; import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.databind.annotation.JsonNaming; +import in.koreatech.koin.domain.order.model.Order; +import in.koreatech.koin.domain.order.model.OrderDelivery; +import in.koreatech.koin.domain.order.model.OrderTakeout; import in.koreatech.koin.domain.order.model.Payment; +import in.koreatech.koin.domain.order.shop.model.entity.shop.OrderableShop; +import in.koreatech.koin.domain.shop.model.shop.Shop; +import in.koreatech.payment.model.domain.TemporaryMenuItems; +import in.koreatech.payment.model.domain.TemporaryMenuOption; import io.swagger.v3.oas.annotations.media.Schema; @JsonNaming(value = SnakeCaseStrategy.class) @@ -16,9 +27,30 @@ public record PaymentConfirmResponse( @Schema(description = "결제 고유 id", example = "1", requiredMode = REQUIRED) Integer id, + @Schema(description = "배달 주소", example = "충청남도 천안시 동남구 병천면 충절로 1600 은솔관 422호", requiredMode = NOT_REQUIRED) + String deliveryAddress, + + @Schema(description = "가게 주소", example = "충청남도 천안시 동남구 병천면 충절로 1600 은솔관 422호", requiredMode = NOT_REQUIRED) + String shopAddress, + + @Schema(description = "사장님에게", example = "리뷰 이벤트 감사합니다.", requiredMode = REQUIRED) + String toOwner, + + @Schema(description = "라이더에게", example = "문 앞에 놔주세요.", requiredMode = NOT_REQUIRED) + String toRider, + @Schema(description = "결제 금액", example = "1000", requiredMode = REQUIRED) Integer amount, + @Schema(description = "상점 이름", example = "굿모닝 살로만 치킨", requiredMode = REQUIRED) + String shopName, + + @Schema(description = "주문 메뉴 목록", requiredMode = REQUIRED) + List menus, + + @Schema(description = "주문 방법", example = "DELIVERY", requiredMode = REQUIRED) + String orderType, + @Schema(description = "결제 요청 일시", example = "2025.06.21 21:00", requiredMode = REQUIRED) @JsonFormat(pattern = "yyyy.MM.dd HH:mm") LocalDateTime requestedAt, @@ -30,13 +62,83 @@ public record PaymentConfirmResponse( @Schema(description = "결제 수단", example = "카드", requiredMode = REQUIRED) String paymentMethod ) { - public static PaymentConfirmResponse from(Payment payment) { + + @JsonNaming(value = SnakeCaseStrategy.class) + public record InnerCartItemResponse( + @Schema(description = "메뉴 이름", example = "허니콤보", requiredMode = REQUIRED) + String name, + + @Schema(description = "수량", example = "1", requiredMode = REQUIRED) + Integer quantity, + + @Schema(description = "선택한 옵션 목록", requiredMode = NOT_REQUIRED) + List options + ) { + public static InnerCartItemResponse from(TemporaryMenuItems temporaryMenuItems) { + List optionResponses = temporaryMenuItems.options().stream() + .map(InnerMenuOptionResponse::from) + .toList(); + + return new InnerCartItemResponse( + temporaryMenuItems.name(), + temporaryMenuItems.quantity(), + optionResponses + ); + } + } + + @JsonNaming(value = SnakeCaseStrategy.class) + public record InnerMenuOptionResponse( + @Schema(description = "옵션 그룹 이름", example = "소스 추가", requiredMode = NOT_REQUIRED) + String optionGroupName, + @Schema(description = "옵션 이름", example = "레드디핑 소스", requiredMode = REQUIRED) + String optionName + ) { + public static InnerMenuOptionResponse from(TemporaryMenuOption temporaryMenuOption) { + return new InnerMenuOptionResponse( + temporaryMenuOption.optionGroupName(), + temporaryMenuOption.optionName() + ); + } + } + + public static PaymentConfirmResponse of( + Payment payment, + Order order, + List temporaryMenuItems + ) { + OrderableShop orderableShop = order.getOrderableShop(); + Shop shop = orderableShop.getShop(); + + String deliveryAddress = null; + String toOwner = null; + String toRider = null; + + if (order.getOrderType() == DELIVERY) { + OrderDelivery delivery = order.getOrderDelivery(); + deliveryAddress = delivery.getAddress(); + toOwner = delivery.getToOwner(); + toRider = delivery.getToRider(); + } else if (order.getOrderType() == TAKE_OUT) { + OrderTakeout takeout = order.getOrderTakeout(); + toOwner = takeout.getToOwner(); + } + return new PaymentConfirmResponse( - payment.getId(), - payment.getAmount(), - payment.getRequestedAt(), - payment.getApprovedAt(), - payment.getPaymentMethod().getDisplayName() + payment.getId(), + deliveryAddress, + shop.getAddress(), + toOwner, + toRider, + payment.getAmount(), + shop.getName(), + temporaryMenuItems.stream() + .map(InnerCartItemResponse::from) + .toList(), + order.getOrderType().name(), + payment.getRequestedAt(), + payment.getApprovedAt(), + payment.getPaymentMethod().getDisplayName() ); } } diff --git a/src/main/java/in/koreatech/payment/service/PaymentService.java b/src/main/java/in/koreatech/payment/service/PaymentService.java index e175716..7ab482e 100644 --- a/src/main/java/in/koreatech/payment/service/PaymentService.java +++ b/src/main/java/in/koreatech/payment/service/PaymentService.java @@ -2,14 +2,14 @@ import java.util.List; -import in.koreatech.koin.domain.order.model.Payment; import in.koreatech.koin.domain.order.model.PaymentCancel; import in.koreatech.payment.dto.request.TemporaryDeliveryPaymentSaveRequest; import in.koreatech.payment.dto.request.TemporaryTakeoutPaymentSaveRequest; +import in.koreatech.payment.dto.response.PaymentConfirmResponse; public interface PaymentService { String createTemporaryDeliveryPayment(String accessToken, TemporaryDeliveryPaymentSaveRequest request); String createTemporaryTakeoutPayment(String accessToken, TemporaryTakeoutPaymentSaveRequest request); - Payment confirmPayment(String accessToken, String paymentKey, String orderId, Integer amount); + PaymentConfirmResponse confirmPayment(String accessToken, String paymentKey, String orderId, Integer amount); List cancelPayment(String accessToken, String paymentKey, String cancelReason); } diff --git a/src/main/java/in/koreatech/payment/service/TossService.java b/src/main/java/in/koreatech/payment/service/TossService.java index 5ff4790..173d9c4 100644 --- a/src/main/java/in/koreatech/payment/service/TossService.java +++ b/src/main/java/in/koreatech/payment/service/TossService.java @@ -23,10 +23,11 @@ import in.koreatech.koin.domain.user.repository.UserRepository; import in.koreatech.payment.client.TossPaymentClient; import in.koreatech.payment.client.dto.response.PaymentCancelResponse; -import in.koreatech.payment.client.dto.response.PaymentConfirmResponse; +import in.koreatech.payment.client.dto.response.TossPaymentConfirmResponse; import in.koreatech.payment.common.auth.JwtTokenResolver; import in.koreatech.payment.dto.request.TemporaryDeliveryPaymentSaveRequest; import in.koreatech.payment.dto.request.TemporaryTakeoutPaymentSaveRequest; +import in.koreatech.payment.dto.response.PaymentConfirmResponse; import in.koreatech.payment.exception.OrderPriceMismatchException; import in.koreatech.payment.exception.PaymentAlreadyCanceledException; import in.koreatech.payment.exception.PaymentCancelException; @@ -113,7 +114,7 @@ public String createTemporaryTakeoutPayment(String accessToken, TemporaryTakeout if (!request.totalMenuPrice().equals(totalProductPrice) || !request.totalAmount().equals(finalAmount) ) { - throw OrderPriceMismatchException.withDetail("totalProductPrice : " + totalProductPrice + "totalAmount : " + totalProductPrice + "finalAmount : " + finalAmount); + throw OrderPriceMismatchException.withDetail("totalProductPrice : " + totalProductPrice + "finalAmount : " + finalAmount); } String orderId = orderIdGenerator.generateOrderId(); @@ -134,16 +135,16 @@ public String createTemporaryTakeoutPayment(String accessToken, TemporaryTakeout } @Transactional(transactionManager = "koinTransactionManager") - public Payment confirmPayment(String accessToken, String paymentKey, String orderId, Integer amount) { + public PaymentConfirmResponse confirmPayment(String accessToken, String paymentKey, String orderId, Integer amount) { Integer userId = jwtTokenResolver.getUserId(accessToken); User user = userRepository.getById(userId); TemporaryPayment temporaryPayment = temporaryPaymentRedisRepository.getById(orderId); temporaryPayment.validateMatches(orderId, user.getId(), amount); - PaymentConfirmResponse response = tossPaymentClient.requestConfirm(paymentKey, orderId, amount); - PaymentStatus paymentStatus = PaymentStatus.valueOf(response.status()); + TossPaymentConfirmResponse tossPaymentResponse = tossPaymentClient.requestConfirm(paymentKey, orderId, amount); + PaymentStatus paymentStatus = PaymentStatus.valueOf(tossPaymentResponse.status()); if (!paymentStatus.isDone()) { - throw PaymentConfirmException.withDetail("paymentStatus : " + response.status()); + throw PaymentConfirmException.withDetail("paymentStatus : " + tossPaymentResponse.status()); } OrderableShop orderableShop = orderableShopRepository.getById(temporaryPayment.getOrderableShopId()); @@ -155,11 +156,12 @@ public Payment confirmPayment(String accessToken, String paymentKey, String orde .toList(); orderMenuRepository.saveAll(orderMenus); - Payment payment = response.toEntity(order); + Payment payment = tossPaymentResponse.toEntity(order); paymentRepository.save(payment); + final PaymentConfirmResponse response = PaymentConfirmResponse.of(payment, order, temporaryPayment.getTemporaryMenuItems()); temporaryPaymentRedisRepository.deleteById(orderId); cartRepository.deleteByUserId(user.getId()); - return payment; + return response; } @Transactional