Skip to content
Merged
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
Expand Up @@ -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;
Expand Down Expand Up @@ -47,15 +47,15 @@ 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 {
return webClient.post()
.uri("/confirm")
.bodyValue(request)
.retrieve()
.bodyToMono(PaymentConfirmResponse.class)
.bodyToMono(TossPaymentConfirmResponse.class)
.block();

} catch (WebClientResponseException e) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
64 changes: 64 additions & 0 deletions src/main/java/in/koreatech/payment/controller/PaymentsApi.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -70,6 +74,66 @@ ResponseEntity<TemporaryPaymentResponse> 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 = """
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,8 @@ public ResponseEntity<PaymentConfirmResponse> 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);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,24 +1,56 @@
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)
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<InnerCartItemResponse> 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,
Expand All @@ -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<InnerMenuOptionResponse> options
) {
public static InnerCartItemResponse from(TemporaryMenuItems temporaryMenuItems) {
List<InnerMenuOptionResponse> 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> 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()
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<PaymentCancel> cancelPayment(String accessToken, String paymentKey, String cancelReason);
}
18 changes: 10 additions & 8 deletions src/main/java/in/koreatech/payment/service/TossService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand All @@ -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());
Expand All @@ -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
Expand Down
Loading