Skip to content

Commit

Permalink
feat: 무료 주문 생성하기 API 구현 (#520)
Browse files Browse the repository at this point in the history
* feat: 무료 주문 생성 API 구현

* feat: 오타 및 DTO 이름 수정

* feat: 무료 주문 생성 정적 팩토리 메서드 추가

* feat: 무료 여부 확인하는 메서드 추가

* refactor: 0원에 해당하는 금액 상수 추가

* feat: 결제정보 조회 시 유료 결제만 가능하도록 수정

* docs: 주문 취소 후 연관 엔티티 수정 작업 투두 추가

* feat: 무료 주문 취소 불가하도록 수정

* feat: 무료 주문 생성 검증 로직 추가

* test: 취소 테스트 nested로 묶기

* test: 무료주문 생성 테스트 추가

* feat: 기존 주문 요청 DTO를 사용하도록 변경

* feat: 무료주문 생성시 외부에서 금액정보 입력받도록 수정

* test: 생성자 시그니처 수정

* test: 무료주문 생성 검증기 테스트 추가

* docs: 오타 수정
  • Loading branch information
uwoobeat authored Jul 26, 2024
1 parent b15079a commit 7622747
Show file tree
Hide file tree
Showing 11 changed files with 424 additions and 92 deletions.
2 changes: 2 additions & 0 deletions src/main/java/com/gdschongik/gdsc/domain/common/vo/Money.java
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ private Money(BigDecimal amount) {
this.amount = amount;
}

public static final Money ZERO = Money.from(BigDecimal.ZERO);

public static Money from(BigDecimal amount) {
validateAmountNotNull(amount);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import java.math.BigDecimal;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
Expand Down Expand Up @@ -46,7 +45,7 @@ public static Coupon createCoupon(String name, Money discountAmount) {
// 검증 로직

private static void validateDiscountAmountPositive(Money discountAmount) {
if (!discountAmount.isGreaterThan(Money.from(BigDecimal.ZERO))) {
if (!discountAmount.isGreaterThan(Money.ZERO)) {
throw new CustomException(COUPON_DISCOUNT_AMOUNT_NOT_POSITIVE);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,15 +37,15 @@ public ResponseEntity<Page<OrderAdminResponse>> getOrders(
}

@Operation(
summary = "완료된 주문 결제정보 조회하기",
description = "주문 결제정보를 조회합니다. 토스페이먼츠 API의 결제 정보인 Payment 객체를 반환합니다. 완료된 주문에 대해서만 조회 가능합니다.")
summary = "완료된 유료 주문 결제정보 조회하기",
description = "주문 결제정보를 조회합니다. 토스페이먼츠 API의 결제 정보인 Payment 객체를 반환합니다. 완료된 유료 주문만 조회할 수 있습니다")
@GetMapping("/{orderId}")
public ResponseEntity<PaymentResponse> getCompletedOrderPayment(@PathVariable Long orderId) {
var response = orderService.getCompletedOrderPayment(orderId);
var response = orderService.getCompletedPaidOrderPayment(orderId);
return ResponseEntity.ok(response);
}

@Operation(summary = "주문 결제 취소하기", description = "주문 상태를 취소로 변경하고 결제를 취소합니다.")
@Operation(summary = "주문 결제 취소하기", description = "주문 상태를 취소로 변경하고 결제를 취소합니다. 회비납입상태를 대기로 변경하고, 준회원으로 강등합니다.")
@PostMapping("/{orderId}/cancel")
public ResponseEntity<Void> cancelOrder(
@PathVariable Long orderId, @Valid @RequestBody OrderCancelRequest request) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,17 @@ public ResponseEntity<Void> createPendingOrder(@Valid @RequestBody OrderCreateRe
return ResponseEntity.ok().build();
}

@Operation(summary = "주문 완료하기", description = "주문을 완료합니다. 요청된 결제는 승인됩니다.")
@Operation(summary = "주문 완료", description = "임시 주문을 완료합니다. 요청된 결제는 승인됩니다.")
@PostMapping("/complete")
public ResponseEntity<Void> completeOrder(@Valid @RequestBody OrderCompleteRequest request) {
orderService.completeOrder(request);
return ResponseEntity.ok().build();
}

@Operation(summary = "무료 주문 생성", description = "무료 주문을 생성합니다. 무료 주문은 완료된 상태로 생성됩니다.")
@PostMapping("/free")
public ResponseEntity<Void> createFreeOrder(@Valid @RequestBody OrderCreateRequest request) {
orderService.createFreeOrder(request);
return ResponseEntity.ok().build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -107,11 +107,12 @@ public Page<OrderAdminResponse> searchOrders(OrderQueryOption queryOption, Pagea
}

@Transactional(readOnly = true)
public PaymentResponse getCompletedOrderPayment(Long orderId) {
public PaymentResponse getCompletedPaidOrderPayment(Long orderId) {
Order order = orderRepository
.findById(orderId)
.filter(Order::isCompleted)
.orElseThrow(() -> new CustomException(ORDER_COMPLETED_NOT_FOUND));
.filter(o -> !o.isFree())
.orElseThrow(() -> new CustomException(ORDER_COMPLETED_PAID_NOT_FOUND));

return paymentClient.getPayment(order.getPaymentKey());
}
Expand Down Expand Up @@ -141,4 +142,29 @@ private ZonedDateTime getCanceledAt(PaymentResponse response) {
private Optional<ZonedDateTime> findLatestCancelDate(List<PaymentResponse.CancelDto> cancels) {
return cancels.stream().map(PaymentResponse.CancelDto::canceledAt).max(ZonedDateTime::compareTo);
}

@Transactional
public void createFreeOrder(OrderCreateRequest request) {
Membership membership = membershipRepository
.findById(request.membershipId())
.orElseThrow(() -> new CustomException(MEMBERSHIP_NOT_FOUND));

Optional<IssuedCoupon> issuedCoupon =
Optional.ofNullable(request.issuedCouponId()).map(this::getIssuedCoupon);

MoneyInfo moneyInfo = MoneyInfo.of(
Money.from(request.totalAmount()),
Money.from(request.discountAmount()),
Money.from(request.finalPaymentAmount()));

Member currentMember = memberUtil.getCurrentMember();

orderValidator.validateFreeOrderCreate(membership, issuedCoupon, currentMember);

Order order = Order.createFree(request.orderNanoId(), membership, issuedCoupon.orElse(null), moneyInfo);

orderRepository.save(order);

log.info("[OrderService] 무료 주문 생성: orderId={}", order.getId());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -58,4 +58,8 @@ private static void validateFinalPaymentAmount(Money totalAmount, Money discount
throw new CustomException(ErrorCode.ORDER_FINAL_PAYMENT_AMOUNT_MISMATCH);
}
}

public boolean isFree() {
return finalPaymentAmount.equals(Money.ZERO);
}
}
29 changes: 29 additions & 0 deletions src/main/java/com/gdschongik/gdsc/domain/order/domain/Order.java
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,26 @@ public static Order createPending(
.build();
}

public static Order createFree(
String nanoId, Membership membership, @Nullable IssuedCoupon issuedCoupon, MoneyInfo moneyInfo) {
validateFreeOrder(moneyInfo);
return Order.builder()
.status(OrderStatus.COMPLETED)
.nanoId(nanoId)
.memberId(membership.getMember().getId())
.membershipId(membership.getId())
.recruitmentRoundId(membership.getRecruitmentRound().getId())
.issuedCouponId(issuedCoupon != null ? issuedCoupon.getId() : null)
.moneyInfo(moneyInfo)
.build();
}

private static void validateFreeOrder(MoneyInfo moneyInfo) {
if (!moneyInfo.isFree()) {
throw new CustomException(ORDER_FREE_FINAL_PAYMENT_NOT_ZERO);
}
}

// 데이터 변경 로직

/**
Expand All @@ -119,6 +139,7 @@ public void complete(String paymentKey, ZonedDateTime approvedAt) {
* 상태 변경 및 취소 시각을 저장하며, 예외를 발생시키지 않도록 외부 취소 요청 전에 validateCancelable을 호출합니다.
*/
public void cancel(ZonedDateTime canceledAt) {
// TODO: 취소 이벤트 발행을 통해 멤버십 및 멤버 상태에 대한 변경 로직 추가
validateCancelable();
this.status = OrderStatus.CANCELED;
this.canceledAt = canceledAt;
Expand All @@ -128,11 +149,19 @@ public void validateCancelable() {
if (status != OrderStatus.COMPLETED) {
throw new CustomException(ORDER_CANCEL_NOT_COMPLETED);
}

if (isFree()) {
throw new CustomException(ORDER_CANCEL_FREE_ORDER);
}
}

// 데이터 조회 로직

public boolean isCompleted() {
return status == OrderStatus.COMPLETED;
}

public boolean isFree() {
return moneyInfo.isFree();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
import com.gdschongik.gdsc.global.annotation.DomainService;
import com.gdschongik.gdsc.global.exception.CustomException;
import jakarta.annotation.Nullable;
import java.math.BigDecimal;
import java.util.Optional;

@DomainService
Expand Down Expand Up @@ -68,7 +67,7 @@ private void validateIssuedCouponOwnership(IssuedCoupon issuedCoupon, Member cur
}

private void validateDiscountAmountZero(Money discountAmount) {
if (!discountAmount.equals(Money.from(BigDecimal.ZERO))) {
if (!discountAmount.equals(Money.ZERO)) {
throw new CustomException(ORDER_DISCOUNT_AMOUNT_NOT_ZERO);
}
}
Expand Down Expand Up @@ -99,4 +98,35 @@ public void validateCompleteOrder(
throw new CustomException(ORDER_COMPLETE_AMOUNT_MISMATCH);
}
}

public void validateFreeOrderCreate(
Membership membership, Optional<IssuedCoupon> optionalIssuedCoupon, Member currentMember) {
// TODO: 공통 로직으로 추출

// 멤버십 관련 검증

if (!membership.getMember().getId().equals(currentMember.getId())) {
throw new CustomException(ORDER_MEMBERSHIP_MEMBER_MISMATCH);
}

if (membership.getRegularRequirement().isPaymentSatisfied()) {
throw new CustomException(ORDER_MEMBERSHIP_ALREADY_PAID);
}

// 리쿠르팅 관련 검증

RecruitmentRound recruitmentRound = membership.getRecruitmentRound();

if (!recruitmentRound.isOpen()) {
throw new CustomException(ORDER_RECRUITMENT_PERIOD_INVALID);
}

// 발급쿠폰 관련 검증

if (optionalIssuedCoupon.isPresent()) {
var issuedCoupon = optionalIssuedCoupon.get();
validateIssuedCouponOwnership(issuedCoupon, currentMember);
issuedCoupon.validateUsable();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -117,10 +117,12 @@ public enum ErrorCode {
ORDER_ALREADY_COMPLETED(HttpStatus.CONFLICT, "이미 완료된 주문입니다."),
ORDER_COMPLETE_AMOUNT_MISMATCH(HttpStatus.CONFLICT, "주문 최종결제금액이 주문완료요청의 결제금액과 일치하지 않습니다."),
ORDER_COMPLETE_MEMBER_MISMATCH(HttpStatus.CONFLICT, "주문자와 현재 로그인한 멤버가 일치하지 않습니다."),
ORDER_COMPLETED_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 주문이거나, 완료되지 않은 주문입니다."),
ORDER_COMPLETED_PAID_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 주문이거나, 완료되지 않은 유료 주문입니다."),
ORDER_CANCEL_NOT_COMPLETED(HttpStatus.CONFLICT, "완료되지 않은 주문은 취소할 수 없습니다."),
ORDER_CANCEL_FREE_ORDER(HttpStatus.CONFLICT, "무료 주문은 취소할 수 없습니다."),
ORDER_CANCEL_RESPONSE_NOT_FOUND(
HttpStatus.INTERNAL_SERVER_ERROR, "주문 결제가 취소되었지만, 응답에 취소 정보가 존재하지 않습니다. 관리자에게 문의 바랍니다."),
ORDER_FREE_FINAL_PAYMENT_NOT_ZERO(HttpStatus.CONFLICT, "무료 주문의 최종결제금액은 0원이어야 합니다."),

// Order - MoneyInfo
ORDER_FINAL_PAYMENT_AMOUNT_MISMATCH(HttpStatus.CONFLICT, "주문 최종결제금액은 주문총액에서 할인금액을 뺀 값이어야 합니다."),
Expand Down
Loading

0 comments on commit 7622747

Please sign in to comment.