diff --git a/src/main/java/in/koreatech/koin/domain/order/cart/model/Cart.java b/src/main/java/in/koreatech/koin/domain/order/cart/model/Cart.java index 789ff4a6e3..61b83aab05 100644 --- a/src/main/java/in/koreatech/koin/domain/order/cart/model/Cart.java +++ b/src/main/java/in/koreatech/koin/domain/order/cart/model/Cart.java @@ -127,6 +127,25 @@ public void validateOrderType(OrderType orderType) { } } + public void validateDeliveryOrder() { + if (cartMenuItems.isEmpty()) { + throw CustomException.of(ApiResponseCode.NOT_FOUND_CART_ITEM); + } + validateOrderType(OrderType.DELIVERY); + orderableShop.requireShopOpen(); + orderableShop.requireMinimumOrderAmount(calculateItemsAmount()); + cartMenuItems.forEach(item -> item.getOrderableShopMenu().validateSoldOut()); + } + + public void validateTakeoutOrder() { + if (cartMenuItems.isEmpty()) { + throw CustomException.of(ApiResponseCode.NOT_FOUND_CART_ITEM); + } + validateOrderType(OrderType.TAKE_OUT); + orderableShop.requireShopOpen(); + cartMenuItems.forEach(item -> item.getOrderableShopMenu().validateSoldOut()); + } + public Integer getItemTypeCount() { return this.cartMenuItems.size(); } diff --git a/src/main/java/in/koreatech/koin/domain/order/order/controller/OrderApi.java b/src/main/java/in/koreatech/koin/domain/order/order/controller/OrderApi.java index fb3abf3451..dfad1907a6 100644 --- a/src/main/java/in/koreatech/koin/domain/order/order/controller/OrderApi.java +++ b/src/main/java/in/koreatech/koin/domain/order/order/controller/OrderApi.java @@ -1,27 +1,99 @@ package in.koreatech.koin.domain.order.order.controller; import static in.koreatech.koin.domain.user.model.UserType.STUDENT; +import static in.koreatech.koin.global.code.ApiResponseCode.*; import java.util.List; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; +import in.koreatech.koin.domain.order.order.dto.request.DeliveryOrderRequest; import in.koreatech.koin.domain.order.order.dto.request.OrderSearchPeriodCriteria; import in.koreatech.koin.domain.order.order.dto.request.OrderStatusCriteria; import in.koreatech.koin.domain.order.order.dto.request.OrderTypeCriteria; +import in.koreatech.koin.domain.order.order.dto.request.TakeoutOrderRequest; import in.koreatech.koin.domain.order.order.dto.response.InprogressOrderResponse; +import in.koreatech.koin.domain.order.order.dto.response.OrderCreateResponse; import in.koreatech.koin.domain.order.order.dto.response.OrdersResponse; import in.koreatech.koin.global.auth.Auth; +import in.koreatech.koin.global.code.ApiResponseCodes; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; @Tag(name = "(Normal) Order: 주문", description = "주문을 관리한다.") @RequestMapping("/order") public interface OrderApi { + @ApiResponseCodes({ + OK, + INVALID_REQUEST_BODY, + NOT_READABLE_HTTP_MESSAGE, + INVALID_ADDRESS_FORMAT, + SHOP_NOT_DELIVERABLE, + SHOP_CLOSED, + ORDER_AMOUNT_BELOW_MINIMUM, + MENU_SOLD_OUT, + SHOP_BANK_ACCOUNT_UNAVAILABLE, + ORDER_PRICE_MISMATCH, + UNAUTHORIZED_USER, + WITHDRAWN_USER, + FORBIDDEN_USER_TYPE, + FORBIDDEN_STUDENT, + NOT_FOUND_USER, + NOT_FOUND_CART, + NOT_FOUND_CART_ITEM + }) + @Operation( + summary = "계좌이체 배달 주문을 생성한다.", + description = """ + 로그인한 학생의 장바구니로 계좌이체 배달 주문을 생성한다. + 메뉴 금액과 배달비를 검증하고 주문 생성 후 장바구니를 비운다. + 주문 정보, 입금할 금액과 상점 계좌 정보를 반환한다. + """ + ) + @PostMapping("/delivery") + ResponseEntity createDeliveryOrder( + @RequestBody @Valid DeliveryOrderRequest request, + @Auth(permit = {STUDENT}) Integer userId + ); + + @ApiResponseCodes({ + OK, + INVALID_REQUEST_BODY, + NOT_READABLE_HTTP_MESSAGE, + SHOP_NOT_TAKEOUT_AVAILABLE, + SHOP_CLOSED, + MENU_SOLD_OUT, + SHOP_BANK_ACCOUNT_UNAVAILABLE, + ORDER_PRICE_MISMATCH, + UNAUTHORIZED_USER, + WITHDRAWN_USER, + FORBIDDEN_USER_TYPE, + FORBIDDEN_STUDENT, + NOT_FOUND_USER, + NOT_FOUND_CART, + NOT_FOUND_CART_ITEM + }) + @Operation( + summary = "계좌이체 포장 주문을 생성한다.", + description = """ + 로그인한 학생의 장바구니로 계좌이체 포장 주문을 생성한다. + 메뉴 금액과 총 결제 금액을 검증하고 주문 생성 후 장바구니를 비운다. + 배달비는 부과하지 않으며 주문 정보, 입금할 금액과 상점 계좌 정보를 반환한다. + """ + ) + @PostMapping("/takeout") + ResponseEntity createTakeoutOrder( + @RequestBody @Valid TakeoutOrderRequest request, + @Auth(permit = {STUDENT}) Integer userId + ); + @Operation( summary = "주문 이력을 조회한다.", description = """ diff --git a/src/main/java/in/koreatech/koin/domain/order/order/controller/OrderController.java b/src/main/java/in/koreatech/koin/domain/order/order/controller/OrderController.java index 211946b218..b85784c39b 100644 --- a/src/main/java/in/koreatech/koin/domain/order/order/controller/OrderController.java +++ b/src/main/java/in/koreatech/koin/domain/order/order/controller/OrderController.java @@ -6,18 +6,24 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import in.koreatech.koin.domain.order.order.dto.request.DeliveryOrderRequest; import in.koreatech.koin.domain.order.order.dto.request.OrderSearchCondition; import in.koreatech.koin.domain.order.order.dto.request.OrderSearchPeriodCriteria; import in.koreatech.koin.domain.order.order.dto.request.OrderStatusCriteria; import in.koreatech.koin.domain.order.order.dto.request.OrderTypeCriteria; +import in.koreatech.koin.domain.order.order.dto.request.TakeoutOrderRequest; import in.koreatech.koin.domain.order.order.dto.response.InprogressOrderResponse; +import in.koreatech.koin.domain.order.order.dto.response.OrderCreateResponse; import in.koreatech.koin.domain.order.order.dto.response.OrdersResponse; import in.koreatech.koin.domain.order.order.service.OrderService; import in.koreatech.koin.global.auth.Auth; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @RestController @@ -27,6 +33,24 @@ public class OrderController implements OrderApi { private final OrderService orderService; + @PostMapping("/delivery") + public ResponseEntity createDeliveryOrder( + @RequestBody @Valid DeliveryOrderRequest request, + @Auth(permit = {STUDENT}) Integer userId + ) { + OrderCreateResponse response = orderService.createDeliveryOrder(userId, request); + return ResponseEntity.ok(response); + } + + @PostMapping("/takeout") + public ResponseEntity createTakeoutOrder( + @RequestBody @Valid TakeoutOrderRequest request, + @Auth(permit = {STUDENT}) Integer userId + ) { + OrderCreateResponse response = orderService.createTakeoutOrder(userId, request); + return ResponseEntity.ok(response); + } + @GetMapping public ResponseEntity getOrders( @RequestParam(name = "page", required = false, defaultValue = "1") Integer page, diff --git a/src/main/java/in/koreatech/koin/domain/order/order/dto/request/DeliveryOrderRequest.java b/src/main/java/in/koreatech/koin/domain/order/order/dto/request/DeliveryOrderRequest.java new file mode 100644 index 0000000000..031098d781 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/order/order/dto/request/DeliveryOrderRequest.java @@ -0,0 +1,74 @@ +package in.koreatech.koin.domain.order.order.dto.request; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +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.math.BigDecimal; + +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.order.delivery.model.AddressType; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.Size; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record DeliveryOrderRequest( + @Schema(description = "배달 주소", example = "충청남도 천안시 동남구 병천면 충절로 1600", requiredMode = REQUIRED) + @NotBlank(message = "배달 주소는 필수 입력사항입니다.") + @Size(max = 100, message = "배달 주소는 100자 이하여야 합니다.") + String address, + + @Schema(description = "배달 상세 주소", example = "은솔관 422호", requiredMode = NOT_REQUIRED) + String addressDetail, + + @Schema(description = "배달 주소 경도", example = "127.28372942", requiredMode = REQUIRED) + @NotNull(message = "배달 주소 경도는 필수 입력사항입니다.") + BigDecimal longitude, + + @Schema(description = "배달 주소 위도", example = "36.76125794", requiredMode = REQUIRED) + @NotNull(message = "배달 주소 위도는 필수 입력사항입니다.") + BigDecimal latitude, + + @Schema(description = "연락처", example = "01012345678", requiredMode = REQUIRED) + @NotBlank(message = "연락처는 필수 입력사항입니다.") + @Pattern(regexp = "^\\d{11}$", message = "전화번호 형식이 올바르지 않습니다. 11자리 숫자로 입력해 주세요.") + String phoneNumber, + + @Schema(description = "사장님에게", example = "리뷰 이벤트 감사합니다.", requiredMode = NOT_REQUIRED) + @Size(max = 50, message = "사장님 요청 사항은 50자 이하여야 합니다.") + String toOwner, + + @Schema(description = "라이더에게", example = "문 앞에 놔주세요.", requiredMode = NOT_REQUIRED) + @Size(max = 50, message = "라이더 요청 사항은 50자 이하여야 합니다.") + String toRider, + + @Schema(description = "메뉴 총 금액", example = "1234", requiredMode = REQUIRED) + @NotNull(message = "메뉴 총 금액은 필수 입력사항입니다.") + @PositiveOrZero(message = "금액은 0 이상이어야 합니다.") + Integer totalMenuPrice, + + @Schema(description = "배달 타입", example = "OFF_CAMPUS", requiredMode = REQUIRED) + @NotNull(message = "배달 타입은 필수 입력사항입니다.") + AddressType deliveryType, + + @Schema(description = "배달 팁", example = "1234", requiredMode = REQUIRED) + @NotNull(message = "배달 팁은 필수 입력사항입니다.") + @PositiveOrZero(message = "금액은 0 이상이어야 합니다.") + Integer deliveryTip, + + @Schema(description = "수저, 포크 수령 여부", example = "true", requiredMode = REQUIRED) + @NotNull(message = "수저, 포크 수령 여부는 필수 입력사항입니다.") + Boolean provideCutlery, + + @Schema(description = "결제 금액", example = "10000", requiredMode = REQUIRED) + @NotNull(message = "결제 금액은 필수 입력사항입니다.") + @PositiveOrZero(message = "금액은 0 이상이어야 합니다.") + Integer totalAmount +) { + +} diff --git a/src/main/java/in/koreatech/koin/domain/order/order/dto/request/TakeoutOrderRequest.java b/src/main/java/in/koreatech/koin/domain/order/order/dto/request/TakeoutOrderRequest.java new file mode 100644 index 0000000000..94f2255931 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/order/order/dto/request/TakeoutOrderRequest.java @@ -0,0 +1,42 @@ +package in.koreatech.koin.domain.order.order.dto.request; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.Size; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record TakeoutOrderRequest( + @Schema(description = "연락처", example = "01012345678", requiredMode = REQUIRED) + @NotBlank(message = "연락처는 필수 입력사항입니다.") + @Pattern(regexp = "^\\d{11}$", message = "전화번호 형식이 올바르지 않습니다. 11자리 숫자로 입력해 주세요.") + String phoneNumber, + + @Schema(description = "사장님에게", example = "리뷰 이벤트 감사합니다.", requiredMode = NOT_REQUIRED) + @Size(max = 50, message = "사장님 요청 사항은 50자 이하여야 합니다.") + String toOwner, + + @Schema(description = "수저, 포크 수령 여부", example = "true", requiredMode = REQUIRED) + @NotNull(message = "수저, 포크 수령 여부는 필수 입력사항입니다.") + Boolean provideCutlery, + + @Schema(description = "메뉴 총 금액", example = "1234", requiredMode = REQUIRED) + @NotNull(message = "메뉴 총 금액은 필수 입력사항입니다.") + @PositiveOrZero(message = "금액은 0 이상이어야 합니다.") + Integer totalMenuPrice, + + @Schema(description = "결제 금액", example = "10000", requiredMode = REQUIRED) + @NotNull(message = "결제 금액은 필수 입력사항입니다.") + @PositiveOrZero(message = "금액은 0 이상이어야 합니다.") + Integer totalAmount +) { + +} diff --git a/src/main/java/in/koreatech/koin/domain/order/order/dto/response/OrderCreateResponse.java b/src/main/java/in/koreatech/koin/domain/order/order/dto/response/OrderCreateResponse.java new file mode 100644 index 0000000000..b82e0f4a18 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/order/order/dto/response/OrderCreateResponse.java @@ -0,0 +1,51 @@ +package in.koreatech.koin.domain.order.order.dto.response; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.order.order.model.Order; +import in.koreatech.koin.domain.payment.dto.response.BankAccountResponse; +import in.koreatech.koin.domain.payment.model.entity.Payment; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record OrderCreateResponse( + @Schema(description = "주문 ID", example = "123", requiredMode = REQUIRED) + Integer orderId, + + @Schema(description = "주문 번호", example = "A1B2C3D4E5", requiredMode = REQUIRED) + String orderNumber, + + @Schema(description = "결제 ID", example = "456", requiredMode = REQUIRED) + Integer paymentId, + + @Schema(description = "주문 유형", example = "DELIVERY", requiredMode = REQUIRED) + String orderType, + + @Schema(description = "주문 상태", example = "CONFIRMING", requiredMode = REQUIRED) + String orderStatus, + + @Schema(description = "결제 방법", example = "계좌이체", requiredMode = REQUIRED) + String paymentMethod, + + @Schema(description = "총 결제 금액", example = "18000", requiredMode = REQUIRED) + Integer amount, + + @Schema(description = "상점 계좌 정보", requiredMode = REQUIRED) + BankAccountResponse bankAccount +) { + public static OrderCreateResponse of(Order order, Payment payment) { + return new OrderCreateResponse( + order.getId(), + order.getOrderNumber(), + payment.getId(), + order.getOrderType().name(), + order.getStatus().name(), + payment.getPaymentMethod().getDisplayName(), + payment.getAmount(), + BankAccountResponse.from(payment) + ); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/order/order/model/Order.java b/src/main/java/in/koreatech/koin/domain/order/order/model/Order.java index e824cc6b43..eb125e0108 100644 --- a/src/main/java/in/koreatech/koin/domain/order/order/model/Order.java +++ b/src/main/java/in/koreatech/koin/domain/order/order/model/Order.java @@ -8,6 +8,7 @@ import static java.lang.Boolean.FALSE; import static lombok.AccessLevel.PROTECTED; +import java.math.BigDecimal; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; @@ -16,6 +17,7 @@ import in.koreatech.koin.common.model.BaseEntity; import in.koreatech.koin.domain.order.shop.model.entity.shop.OrderableShop; +import in.koreatech.koin.domain.shop.model.shop.Shop; import in.koreatech.koin.domain.user.model.User; import in.koreatech.koin.global.code.ApiResponseCode; import in.koreatech.koin.global.exception.CustomException; @@ -48,9 +50,8 @@ public class Order extends BaseEntity { @Column(name = "id", nullable = false, updatable = false) Integer id; - @NotBlank @Size(min = 6, max = 64) - @Column(name = "pg_order_id", length = 64, nullable = false, updatable = false) + @Column(name = "pg_order_id", length = 64, updatable = false) private String pgOrderId; @NotBlank @@ -167,6 +168,86 @@ public Order( this.orderMenus = orderMenus; } + public static Order createDelivery( + User user, + OrderableShop orderableShop, + String orderNumber, + String phoneNumber, + String address, + String addressDetail, + BigDecimal longitude, + BigDecimal latitude, + String toOwner, + String toRider, + Boolean provideCutlery, + int totalProductPrice, + int deliveryFee, + int totalPrice + ) { + Shop shop = orderableShop.getShop(); + Order order = Order.builder() + .orderNumber(orderNumber) + .orderType(OrderType.DELIVERY) + .status(CONFIRMING) + .orderableShopAddress(shop.getAddress()) + .orderableShopAddressDetail(shop.getAddressDetail()) + .orderableShopName(shop.getName()) + .phoneNumber(phoneNumber) + .totalProductPrice(totalProductPrice) + .discountAmount(0) + .totalPrice(totalPrice) + .orderableShop(orderableShop) + .user(user) + .isDeleted(false) + .build(); + order.setOrderDelivery(OrderDelivery.builder() + .order(order) + .address(address) + .addressDetail(addressDetail) + .longitude(longitude) + .latitude(latitude) + .toOwner(toOwner) + .toRider(toRider) + .provideCutlery(provideCutlery) + .deliveryTip(deliveryFee) + .build()); + return order; + } + + public static Order createTakeout( + User user, + OrderableShop orderableShop, + String orderNumber, + String phoneNumber, + String toOwner, + Boolean provideCutlery, + int totalProductPrice, + int totalPrice + ) { + Shop shop = orderableShop.getShop(); + Order order = Order.builder() + .orderNumber(orderNumber) + .orderType(OrderType.TAKE_OUT) + .status(CONFIRMING) + .orderableShopAddress(shop.getAddress()) + .orderableShopAddressDetail(shop.getAddressDetail()) + .orderableShopName(shop.getName()) + .phoneNumber(phoneNumber) + .totalProductPrice(totalProductPrice) + .discountAmount(0) + .totalPrice(totalPrice) + .orderableShop(orderableShop) + .user(user) + .isDeleted(false) + .build(); + order.setOrderTakeout(OrderTakeout.builder() + .order(order) + .toOwner(toOwner) + .provideCutlery(provideCutlery) + .build()); + return order; + } + public void setOrderDelivery(OrderDelivery orderDelivery) { this.orderDelivery = orderDelivery; } @@ -254,10 +335,10 @@ public LocalDateTime getEstimatedAt() { return null; } - public void cancel(String cancelReason) { + public void cancel(String cancelReason, LocalDateTime canceledAt) { this.status = CANCELED; this.canceledReason = cancelReason; - this.canceledAt = LocalDateTime.now(); + this.canceledAt = canceledAt; } public void delivered() { diff --git a/src/main/java/in/koreatech/koin/domain/order/order/service/OrderService.java b/src/main/java/in/koreatech/koin/domain/order/order/service/OrderService.java index 0fd8fe3350..0bf8e7d35f 100644 --- a/src/main/java/in/koreatech/koin/domain/order/order/service/OrderService.java +++ b/src/main/java/in/koreatech/koin/domain/order/order/service/OrderService.java @@ -1,13 +1,23 @@ package in.koreatech.koin.domain.order.order.service; +import static in.koreatech.koin.domain.order.delivery.model.AddressType.OFF_CAMPUS; + +import java.time.Clock; +import java.time.LocalDateTime; import java.util.List; import org.springframework.data.domain.Page; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import in.koreatech.koin.domain.address.service.AddressValidator; +import in.koreatech.koin.domain.order.cart.model.Cart; +import in.koreatech.koin.domain.order.cart.repository.CartRepository; +import in.koreatech.koin.domain.order.order.dto.request.DeliveryOrderRequest; import in.koreatech.koin.domain.order.order.dto.request.OrderSearchCondition; +import in.koreatech.koin.domain.order.order.dto.request.TakeoutOrderRequest; import in.koreatech.koin.domain.order.order.dto.response.InprogressOrderResponse; +import in.koreatech.koin.domain.order.order.dto.response.OrderCreateResponse; import in.koreatech.koin.domain.order.order.dto.response.OrdersResponse; import in.koreatech.koin.domain.order.order.model.Order; import in.koreatech.koin.domain.order.order.model.OrderInfo; @@ -15,6 +25,9 @@ import in.koreatech.koin.domain.order.order.model.OrderStatus; import in.koreatech.koin.domain.order.order.repository.OrderRepository; import in.koreatech.koin.domain.order.order.repository.OrderSearchQueryRepository; +import in.koreatech.koin.domain.order.order.util.OrderPriceValidator; +import in.koreatech.koin.domain.order.shop.model.entity.shop.OrderableShop; +import in.koreatech.koin.domain.payment.mapper.OrderMenuItemsMapper; import in.koreatech.koin.domain.payment.model.entity.Payment; import in.koreatech.koin.domain.payment.repository.PaymentRepository; import in.koreatech.koin.domain.user.model.User; @@ -30,6 +43,84 @@ public class OrderService { private final UserRepository userRepository; private final PaymentRepository paymentRepository; private final OrderRepository orderRepository; + private final CartRepository cartRepository; + private final OrderMenuItemsMapper orderMenuItemsMapper; + private final OrderNumberGenerator orderNumberGenerator; + private final AddressValidator addressValidator; + private final Clock clock; + + @Transactional + public OrderCreateResponse createDeliveryOrder(Integer userId, DeliveryOrderRequest request) { + User user = userRepository.getById(userId); + if (request.deliveryType() == OFF_CAMPUS) { + addressValidator.validateAddress(request.address()); + } + + Cart cart = cartRepository.getCartByUserId(userId); + cart.validateDeliveryOrder(); + OrderableShop orderableShop = cart.getOrderableShop(); + orderableShop.getShop().validateBankAccount(); + + int totalProductPrice = cart.calculateItemsAmount(); + int deliveryFee = orderableShop.calculateDeliveryFee(totalProductPrice); + int totalPrice = totalProductPrice + deliveryFee; + OrderPriceValidator.validatePrice( + request.totalMenuPrice(), + request.deliveryTip(), + request.totalAmount(), + totalProductPrice, + deliveryFee, + totalPrice + ); + + Order order = Order.createDelivery( + user, + orderableShop, + orderNumberGenerator.generate(), + request.phoneNumber(), + request.address(), + request.addressDetail(), + request.longitude(), + request.latitude(), + request.toOwner(), + request.toRider(), + request.provideCutlery(), + totalProductPrice, + deliveryFee, + totalPrice + ); + return saveOrder(order, cart); + } + + @Transactional + public OrderCreateResponse createTakeoutOrder(Integer userId, TakeoutOrderRequest request) { + User user = userRepository.getById(userId); + Cart cart = cartRepository.getCartByUserId(userId); + cart.validateTakeoutOrder(); + OrderableShop orderableShop = cart.getOrderableShop(); + orderableShop.getShop().validateBankAccount(); + + int totalProductPrice = cart.calculateItemsAmount(); + int totalPrice = totalProductPrice; + OrderPriceValidator.validatePrice( + request.totalMenuPrice(), + request.totalAmount(), + totalProductPrice, + totalPrice + ); + + Order order = Order.createTakeout( + user, + orderableShop, + orderNumberGenerator.generate(), + request.phoneNumber(), + request.toOwner(), + request.provideCutlery(), + totalProductPrice, + totalPrice + ); + return saveOrder(order, cart); + } public OrdersResponse getOrders(Integer userId, OrderSearchCondition orderSearchCondition) { OrderSearchCriteria orderSearchCriteria = OrderSearchCriteria.from(orderSearchCondition); @@ -49,4 +140,16 @@ public List getInprogressOrders(Integer userId) { }) .toList(); } + + private OrderCreateResponse saveOrder(Order order, Cart cart) { + orderMenuItemsMapper.toOrderMenus(cart, order).forEach(order::addOrderMenu); + orderRepository.save(order); + + LocalDateTime now = LocalDateTime.now(clock); + Payment payment = Payment.createBankTransfer(order, now); + paymentRepository.save(payment); + cartRepository.delete(cart); + + return OrderCreateResponse.of(order, payment); + } } diff --git a/src/main/java/in/koreatech/koin/domain/order/order/util/OrderPriceValidator.java b/src/main/java/in/koreatech/koin/domain/order/order/util/OrderPriceValidator.java new file mode 100644 index 0000000000..42cd62b590 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/order/order/util/OrderPriceValidator.java @@ -0,0 +1,33 @@ +package in.koreatech.koin.domain.order.order.util; + +import static in.koreatech.koin.global.code.ApiResponseCode.ORDER_PRICE_MISMATCH; +import static lombok.AccessLevel.PRIVATE; + +import in.koreatech.koin.global.exception.CustomException; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = PRIVATE) +public class OrderPriceValidator { + + public static void validatePrice( + int requestedMenuPrice, + int requestedTotalAmount, + int totalProductPrice, + int totalPrice + ) { + validatePrice(requestedMenuPrice, 0, requestedTotalAmount, totalProductPrice, 0, totalPrice); + } + + public static void validatePrice( + int requestedMenuPrice, + int requestedDeliveryTip, + int requestedTotalAmount, + int totalProductPrice, + int deliveryFee, + int totalPrice + ) { + if (requestedMenuPrice != totalProductPrice || requestedDeliveryTip != deliveryFee || requestedTotalAmount != totalPrice) { + throw CustomException.of(ORDER_PRICE_MISMATCH); + } + } +} diff --git a/src/main/java/in/koreatech/koin/domain/payment/controller/PaymentApi.java b/src/main/java/in/koreatech/koin/domain/payment/controller/PaymentApi.java index 4c67ca3d9a..72660115c5 100644 --- a/src/main/java/in/koreatech/koin/domain/payment/controller/PaymentApi.java +++ b/src/main/java/in/koreatech/koin/domain/payment/controller/PaymentApi.java @@ -146,10 +146,11 @@ ResponseEntity cancelPayment( - `order_type`: 주문 방법 (DELIVERY | TAKE_OUT) - `easy_pay_company`: 간편결제사 - `requested_at`: 결제 요청 일시 (yyyy.MM.dd HH:mm) - - `approved_at`: 결제 승인 일시 (yyyy.MM.dd HH:mm) + - `approved_at`: 결제 승인 일시 (yyyy.MM.dd HH:mm). 직접 계좌이체 주문은 null - `payment_method`: 결제 수단 - `estimated_at`: 예상 시각 (HH:mm) - `order_status`: 주문 상태 + - `bank_account`: 은행명(bank)과 계좌번호(account_number) 스냅샷, 임시 예금주(account_holder) """ ) @GetMapping("/{paymentId}") diff --git a/src/main/java/in/koreatech/koin/domain/payment/dto/response/BankAccountResponse.java b/src/main/java/in/koreatech/koin/domain/payment/dto/response/BankAccountResponse.java new file mode 100644 index 0000000000..c39af3e045 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/payment/dto/response/BankAccountResponse.java @@ -0,0 +1,29 @@ +package in.koreatech.koin.domain.payment.dto.response; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.payment.model.entity.Payment; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record BankAccountResponse( + @Schema(description = "은행명", example = "국민은행", requiredMode = REQUIRED) + String bank, + + @Schema(description = "계좌번호", example = "123456-78-901234", requiredMode = REQUIRED) + String accountNumber, + + @Schema(description = "예금주", example = "홍길동", requiredMode = REQUIRED) + String accountHolder +) { + public static BankAccountResponse from(Payment payment) { + if (!payment.isDirectBankTransfer()) { + return null; + } + // TODO: 사장님 앱 작업 후 수정 + return new BankAccountResponse(payment.getBank(), payment.getAccountNumber(), "임금주명"); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/payment/dto/response/PaymentResponse.java b/src/main/java/in/koreatech/koin/domain/payment/dto/response/PaymentResponse.java index 90da0402cc..2ffb49c33e 100644 --- a/src/main/java/in/koreatech/koin/domain/payment/dto/response/PaymentResponse.java +++ b/src/main/java/in/koreatech/koin/domain/payment/dto/response/PaymentResponse.java @@ -82,7 +82,7 @@ public record PaymentResponse( @JsonFormat(pattern = "yyyy.MM.dd HH:mm") LocalDateTime requestedAt, - @Schema(description = "결제 승인 일시", example = "2025.06.21 21:00", requiredMode = REQUIRED) + @Schema(description = "결제 승인 일시. 직접 계좌이체 주문은 null", example = "2025.06.21 21:00", requiredMode = NOT_REQUIRED) @JsonFormat(pattern = "yyyy.MM.dd HH:mm") LocalDateTime approvedAt, @@ -94,7 +94,10 @@ public record PaymentResponse( LocalTime estimatedAt, @Schema(description = "주문 상태", example = "COOKING", requiredMode = REQUIRED) - String orderStatus + String orderStatus, + + @Schema(description = "직접 계좌이체 주문 계좌 정보. 은행명과 계좌번호는 스냅샷", requiredMode = NOT_REQUIRED) + BankAccountResponse bankAccount ) { @JsonNaming(value = SnakeCaseStrategy.class) @@ -208,7 +211,8 @@ public static PaymentResponse of( payment.getApprovedAt(), payment.getPaymentMethod().getDisplayName(), estimatedTime, - order.getStatus().name() + order.getStatus().name(), + BankAccountResponse.from(payment) ); } } diff --git a/src/main/java/in/koreatech/koin/domain/payment/model/entity/Payment.java b/src/main/java/in/koreatech/koin/domain/payment/model/entity/Payment.java index 10c5a6ad5c..23e47e5b3f 100644 --- a/src/main/java/in/koreatech/koin/domain/payment/model/entity/Payment.java +++ b/src/main/java/in/koreatech/koin/domain/payment/model/entity/Payment.java @@ -12,6 +12,7 @@ import in.koreatech.koin.common.model.BaseEntity; import in.koreatech.koin.domain.order.order.model.Order; +import in.koreatech.koin.domain.shop.model.shop.Shop; import in.koreatech.koin.global.exception.CustomException; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -21,7 +22,6 @@ import jakarta.persistence.JoinColumn; import jakarta.persistence.OneToOne; import jakarta.persistence.Table; -import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; import lombok.Builder; @@ -39,9 +39,8 @@ public class Payment extends BaseEntity { @GeneratedValue(strategy = IDENTITY) private Integer id; - @NotBlank @Size(max = 200) - @Column(name = "payment_key", length = 200, nullable = false, updatable = false) + @Column(name = "payment_key", length = 200, updatable = false) private String paymentKey; @NotNull @@ -69,14 +68,20 @@ public class Payment extends BaseEntity { @Column(name = "requested_at", nullable = false, updatable = false, columnDefinition = "TIMESTAMP") private LocalDateTime requestedAt; - @NotNull - @Column(name = "approved_at", nullable = false, updatable = false, columnDefinition = "TIMESTAMP") + @Column(name = "approved_at", updatable = false, columnDefinition = "TIMESTAMP") private LocalDateTime approvedAt; - @NotNull - @Column(name = "receipt", nullable = false, updatable = false, columnDefinition = "TEXT") + @Column(name = "receipt", updatable = false, columnDefinition = "TEXT") private String receipt; + @Size(max = 10) + @Column(name = "bank", length = 10, updatable = false) + private String bank; + + @Size(max = 20) + @Column(name = "account_number", length = 20, updatable = false) + private String accountNumber; + @NotNull @Column(name = "is_deleted", nullable = false) private Boolean isDeleted = FALSE; @@ -96,6 +101,8 @@ public Payment( LocalDateTime requestedAt, LocalDateTime approvedAt, String receipt, + String bank, + String accountNumber, Boolean isDeleted, Order order ) { @@ -108,18 +115,43 @@ public Payment( this.requestedAt = requestedAt; this.approvedAt = approvedAt; this.receipt = receipt; + this.bank = bank; + this.accountNumber = accountNumber; this.isDeleted = isDeleted; this.order = order; } + public static Payment createBankTransfer(Order order, LocalDateTime requestedAt) { + Shop shop = order.getOrderableShop().getShop(); + String description = order.getOrderMenus().get(0).getMenuName(); + if (order.getOrderMenus().size() > 1) { + description += " 외 " + (order.getOrderMenus().size() - 1) + "건"; + } + return Payment.builder() + .order(order) + .amount(order.getTotalPrice()) + .paymentStatus(PaymentStatus.WAITING_FOR_DEPOSIT) + .paymentMethod(PaymentMethod.ACCOUNT_TRANSFER) + .description(description) + .requestedAt(requestedAt) + .bank(shop.getBank()) + .accountNumber(shop.getAccountNumber()) + .isDeleted(false) + .build(); + } + + public boolean isDirectBankTransfer() { + return paymentMethod == PaymentMethod.ACCOUNT_TRANSFER && paymentKey == null; + } + public void validateUserIdMatches(Integer userId) { if (!userId.equals(this.order.getUser().getId())) { throw CustomException.of(PAYMENT_ACCESS_DENIED); } } - public void cancel(String reason) { + public void cancel(String reason, LocalDateTime canceledAt) { this.paymentStatus = CANCELED; - this.order.cancel(reason); + this.order.cancel(reason, canceledAt); } } diff --git a/src/main/java/in/koreatech/koin/domain/payment/service/PaymentCancelService.java b/src/main/java/in/koreatech/koin/domain/payment/service/PaymentCancelService.java index 8ab747bb85..66b521cebb 100644 --- a/src/main/java/in/koreatech/koin/domain/payment/service/PaymentCancelService.java +++ b/src/main/java/in/koreatech/koin/domain/payment/service/PaymentCancelService.java @@ -3,11 +3,15 @@ import static in.koreatech.koin.global.code.ApiResponseCode.PAYMENT_ALREADY_CANCELED; import static in.koreatech.koin.global.code.ApiResponseCode.PAYMENT_CANCEL_ERROR; +import java.time.Clock; +import java.time.LocalDateTime; import java.util.List; +import java.util.UUID; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import in.koreatech.koin.domain.order.order.model.OrderStatus; import in.koreatech.koin.domain.payment.dto.response.PaymentCancelResponse; import in.koreatech.koin.domain.payment.gateway.pg.PaymentGatewayService; import in.koreatech.koin.domain.payment.gateway.pg.dto.PaymentGatewayCancelResponse; @@ -31,6 +35,7 @@ public class PaymentCancelService { private final PaymentGatewayService paymentGatewayService; private final PaymentIdempotencyKeyService paymentIdempotencyKeyService; private final PaymentCancelMapper paymentCancelMapper; + private final Clock clock; @Transactional public PaymentCancelResponse cancelPayment(User user, Integer paymentId, PaymentCancelInfo paymentCancelInfo) { @@ -50,18 +55,40 @@ public PaymentCancelResponse cancelPaymentByOrderId(Integer orderId, String canc } private List cancel(Payment payment, String cancelReason) { + if (payment.isDirectBankTransfer()) { + return cancelBankTransferOrder(payment, cancelReason); + } String paymentIdempotencyKey = paymentIdempotencyKeyService.getOrCreate(payment.getOrder().getUser().getId()); PaymentGatewayCancelResponse pgResponse = paymentGatewayService.cancelPayment(payment.getPaymentKey(), cancelReason, paymentIdempotencyKey); validatePaymentIsCanceled(pgResponse.status()); - payment.cancel(cancelReason); + LocalDateTime now = LocalDateTime.now(clock); + payment.cancel(cancelReason, now); List paymentCancels = paymentCancelMapper.toEntity(payment, pgResponse); paymentCancelRepository.saveAll(paymentCancels); return paymentCancels; } + private List cancelBankTransferOrder(Payment payment, String cancelReason) { + payment.getOrder().requireStatusChangeableTo(OrderStatus.CANCELED); + LocalDateTime now = LocalDateTime.now(clock); + payment.cancel(cancelReason, now); + PaymentCancel paymentCancel = PaymentCancel.builder() + .transactionKey(UUID.randomUUID().toString()) + .cancelReason(cancelReason) + // 입금과 환불은 앱에서 확인하지 않으므로 자동 환불 금액은 0원이다. + .cancelAmount(0) + .canceledAt(now) + .isDeleted(false) + .payment(payment) + .build(); + List paymentCancels = List.of(paymentCancel); + paymentCancelRepository.saveAll(paymentCancels); + return paymentCancels; + } + private void validatePaymentStatusIsNotCanceled(Payment payment) { if (payment.getPaymentStatus().isCanceled()) { throw CustomException.of(PAYMENT_ALREADY_CANCELED); diff --git a/src/main/java/in/koreatech/koin/domain/shop/model/shop/Shop.java b/src/main/java/in/koreatech/koin/domain/shop/model/shop/Shop.java index 3b22431ce3..6fbdbb1902 100644 --- a/src/main/java/in/koreatech/koin/domain/shop/model/shop/Shop.java +++ b/src/main/java/in/koreatech/koin/domain/shop/model/shop/Shop.java @@ -14,6 +14,7 @@ import java.util.Set; import org.hibernate.annotations.Where; +import org.springframework.util.StringUtils; import in.koreatech.koin.common.model.BaseEntity; import in.koreatech.koin.domain.order.shop.model.domain.ShopBaseDeliveryTips; @@ -24,6 +25,8 @@ import in.koreatech.koin.domain.shop.model.menu.Menu; import in.koreatech.koin.domain.shop.model.menu.MenuCategory; import in.koreatech.koin.domain.shop.model.review.ShopReview; +import in.koreatech.koin.global.code.ApiResponseCode; +import in.koreatech.koin.global.exception.CustomException; import jakarta.persistence.Column; import jakarta.persistence.Embedded; import jakarta.persistence.Entity; @@ -336,6 +339,13 @@ public String getFullAddress() { return String.join(" ", this.address, this.addressDetail); } + // TODO: 예금주명 추가되면 검증 로직 추가 필요 + public void validateBankAccount() { + if (!payBank || !StringUtils.hasText(bank) || !StringUtils.hasText(accountNumber)) { + throw CustomException.of(ApiResponseCode.SHOP_BANK_ACCOUNT_UNAVAILABLE); + } + } + public boolean isOwner(Integer ownerId) { return this.owner != null && this.owner.getId().equals(ownerId); } diff --git a/src/main/java/in/koreatech/koin/global/code/ApiResponseCode.java b/src/main/java/in/koreatech/koin/global/code/ApiResponseCode.java index add108fd9f..46a3a2d52c 100644 --- a/src/main/java/in/koreatech/koin/global/code/ApiResponseCode.java +++ b/src/main/java/in/koreatech/koin/global/code/ApiResponseCode.java @@ -78,6 +78,7 @@ public enum ApiResponseCode { ORDER_AMOUNT_BELOW_MINIMUM(HttpStatus.BAD_REQUEST, "최소 주문 금액을 충족하지 않습니다."), INVALID_SELF_CHAT(HttpStatus.BAD_REQUEST, "자신이 올린 게시글에 메시지를 보낼 수 없습니다."), INVALID_WEBSOCKET_USER_SESSION(HttpStatus.BAD_REQUEST, "웹소켓 사용자 세션 탐색 실패"), + SHOP_BANK_ACCOUNT_UNAVAILABLE(HttpStatus.BAD_REQUEST, "상점의 계좌이체 정보가 등록되지 않았습니다."), ORDER_PRICE_MISMATCH(HttpStatus.BAD_REQUEST, "클라이언트 요청 금액이 서버 계산 결과와 다릅니다."), MISMATCH_TEMPORARY_PAYMENT(HttpStatus.BAD_REQUEST, "요청한 정보가 임시 결제 정보와 일치하지 않습니다."), PAYMENT_ALREADY_CANCELED(HttpStatus.BAD_REQUEST, "이미 취소된 결제입니다."), diff --git a/src/main/resources/db/migration/V21__allow_null_pg_order_id.sql b/src/main/resources/db/migration/V21__allow_null_pg_order_id.sql new file mode 100644 index 0000000000..0279b8875f --- /dev/null +++ b/src/main/resources/db/migration/V21__allow_null_pg_order_id.sql @@ -0,0 +1,2 @@ +ALTER TABLE `order_v2` + MODIFY COLUMN `pg_order_id` VARCHAR(64) NULL COMMENT 'PG 주문 ID. 직접 계좌이체 주문은 NULL'; diff --git a/src/main/resources/db/migration/V22__allow_null_payment_key.sql b/src/main/resources/db/migration/V22__allow_null_payment_key.sql new file mode 100644 index 0000000000..734b6216d0 --- /dev/null +++ b/src/main/resources/db/migration/V22__allow_null_payment_key.sql @@ -0,0 +1,2 @@ +ALTER TABLE `payment_v2` + MODIFY COLUMN `payment_key` VARCHAR(200) NULL COMMENT 'PG 결제 키'; diff --git a/src/main/resources/db/migration/V23__allow_null_payment_approved_at.sql b/src/main/resources/db/migration/V23__allow_null_payment_approved_at.sql new file mode 100644 index 0000000000..2387b06fb7 --- /dev/null +++ b/src/main/resources/db/migration/V23__allow_null_payment_approved_at.sql @@ -0,0 +1,2 @@ +ALTER TABLE `payment_v2` + MODIFY COLUMN `approved_at` TIMESTAMP NULL DEFAULT NULL COMMENT '결제 승인 일시'; diff --git a/src/main/resources/db/migration/V24__allow_null_payment_receipt.sql b/src/main/resources/db/migration/V24__allow_null_payment_receipt.sql new file mode 100644 index 0000000000..4ddf29fa60 --- /dev/null +++ b/src/main/resources/db/migration/V24__allow_null_payment_receipt.sql @@ -0,0 +1,2 @@ +ALTER TABLE `payment_v2` + MODIFY COLUMN `receipt` TEXT NULL COMMENT 'PG 영수증'; diff --git a/src/main/resources/db/migration/V25__add_payment_bank_snapshot.sql b/src/main/resources/db/migration/V25__add_payment_bank_snapshot.sql new file mode 100644 index 0000000000..76bc4e9086 --- /dev/null +++ b/src/main/resources/db/migration/V25__add_payment_bank_snapshot.sql @@ -0,0 +1,2 @@ +ALTER TABLE `payment_v2` + ADD COLUMN `bank` VARCHAR(10) NULL COMMENT '주문 계좌 은행명 스냅샷'; diff --git a/src/main/resources/db/migration/V26__add_payment_account_number_snapshot.sql b/src/main/resources/db/migration/V26__add_payment_account_number_snapshot.sql new file mode 100644 index 0000000000..de38f7ef7b --- /dev/null +++ b/src/main/resources/db/migration/V26__add_payment_account_number_snapshot.sql @@ -0,0 +1,2 @@ +ALTER TABLE `payment_v2` + ADD COLUMN `account_number` VARCHAR(20) NULL COMMENT '주문 계좌번호 스냅샷'; diff --git a/src/test/java/in/koreatech/koin/unit/domain/order/service/OwnerOrderServiceTest.java b/src/test/java/in/koreatech/koin/unit/domain/order/service/OwnerOrderServiceTest.java index 27e55fe8c5..71508508d7 100644 --- a/src/test/java/in/koreatech/koin/unit/domain/order/service/OwnerOrderServiceTest.java +++ b/src/test/java/in/koreatech/koin/unit/domain/order/service/OwnerOrderServiceTest.java @@ -362,7 +362,7 @@ class GetOrder { @DisplayName("반려된 주문은 반려 일시와 사유를 반환한다") void 반려된_주문은_반려_일시와_사유를_반환한다() { Order order = OrderFixture.배달_주문(1, "A1B2C3D4E5", OrderStatus.CONFIRMING, orderableShop, customer); - order.cancel("재료 소진"); + order.cancel("재료 소진", LocalDateTime.of(2026, 10, 27, 12, 0)); when(orderableShopRepository.getById(ORDERABLE_SHOP_ID)).thenReturn(orderableShop); when(orderRepository.findByIdAndOrderableShopId(1, ORDERABLE_SHOP_ID)).thenReturn(Optional.of(order));