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 @@ -66,6 +66,38 @@ long countActivePendingByMemberIdAndProductCode(
@Param("now") LocalDateTime now
);

@Query("""
SELECT COALESCE(SUM(oi.quantity), 0)
FROM Order o
JOIN o.orderItems oi
WHERE o.memberId = :memberId
AND o.productCode = :productCode
AND o.status = com.comatching.item.domain.order.enums.OrderStatus.APPROVED
AND oi.itemType = :itemType
""")
long sumApprovedQuantityByMemberIdAndProductCodeAndItemType(
@Param("memberId") Long memberId,
@Param("productCode") String productCode,
@Param("itemType") ItemType itemType
);

@Query("""
SELECT COALESCE(SUM(oi.quantity), 0)
FROM Order o
JOIN o.orderItems oi
WHERE o.memberId = :memberId
AND o.productCode = :productCode
AND o.status = com.comatching.item.domain.order.enums.OrderStatus.PENDING
AND o.expiresAt > :now
AND oi.itemType = :itemType
""")
long sumActivePendingQuantityByMemberIdAndProductCodeAndItemType(
@Param("memberId") Long memberId,
@Param("productCode") String productCode,
@Param("itemType") ItemType itemType,
@Param("now") LocalDateTime now
);

@Query("""
SELECT COALESCE(SUM(oi.quantity), 0)
FROM Order o
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,18 +78,14 @@ public void requestPurchase(Long memberId, Long productId, int quantity) {
throw new BusinessException(ItemErrorCode.PRODUCT_NOT_AVAILABLE);
}

if (DISCOUNT_MATCHING_TICKET_CODE.equals(resolveProductCode(product)) && quantity != 1) {
throw new BusinessException(PaymentErrorCode.INVALID_ORDER_QUANTITY);
}

LocalDateTime now = LocalDateTime.now();
boolean hasPendingRequest = orderRepository.existsActivePendingOrder(memberId, now);
if (hasPendingRequest) {
throw new BusinessException(PaymentErrorCode.PENDING_REQUEST_ALREADY_EXISTS);
}

validatePurchaseLimit(memberId, product, now, quantity);
validatePurchaseCountLimit(memberId, product, now);
validatePurchaseCountLimit(memberId, product, now, quantity);

OrdererInfoDto ordererInfo = userOrderClient.getOrdererInfo(memberId);
String realName = normalizeRequiredText(ordererInfo.realName(), PaymentErrorCode.REAL_NAME_REQUIRED);
Expand Down Expand Up @@ -121,8 +117,8 @@ public void requestPurchase(Long memberId, Long productId, int quantity) {

private ProductResponse toMemberProductResponse(Long memberId, Product product, LocalDateTime now) {
String productCode = resolveProductCode(product);
long usedPurchaseCount = orderRepository.countApprovedByMemberIdAndProductCode(memberId, productCode);
long activePendingOrderCount = orderRepository.countActivePendingByMemberIdAndProductCode(memberId, productCode, now);
long usedPurchaseCount = usedPurchaseCount(memberId, productCode);
long activePendingOrderCount = activePendingPurchaseCount(memberId, productCode, now);

Long remainingPurchaseCount = remainingCount(product.getPurchaseLimitPerMember(), usedPurchaseCount, activePendingOrderCount);
PurchaseBlockReason blockReason = purchaseBlockReason(
Expand All @@ -141,20 +137,44 @@ private ProductResponse toMemberProductResponse(Long memberId, Product product,
);
}

private void validatePurchaseCountLimit(Long memberId, Product product, LocalDateTime now) {
private void validatePurchaseCountLimit(Long memberId, Product product, LocalDateTime now, int quantity) {
if (product.isFirstPurchaseOnly() && orderRepository.existsApprovedOrActivePendingOrder(memberId, now)) {
throw new BusinessException(PaymentErrorCode.FIRST_PURCHASE_ONLY);
}

String productCode = resolveProductCode(product);
long usedPurchaseCount = orderRepository.countApprovedByMemberIdAndProductCode(memberId, productCode);
long activePendingOrderCount = orderRepository.countActivePendingByMemberIdAndProductCode(memberId, productCode, now);
long usedPurchaseCount = usedPurchaseCount(memberId, productCode);
long activePendingOrderCount = activePendingPurchaseCount(memberId, productCode, now);
Long remainingPurchaseCount = remainingCount(product.getPurchaseLimitPerMember(), usedPurchaseCount, activePendingOrderCount);
if (remainingPurchaseCount != null && remainingPurchaseCount <= 0) {
int requestedPurchaseCount = DISCOUNT_MATCHING_TICKET_CODE.equals(productCode) ? quantity : 1;
if (remainingPurchaseCount != null && remainingPurchaseCount < requestedPurchaseCount) {
throw new BusinessException(PaymentErrorCode.PRODUCT_PURCHASE_LIMIT_EXCEEDED);
}
}

private long usedPurchaseCount(Long memberId, String productCode) {
if (DISCOUNT_MATCHING_TICKET_CODE.equals(productCode)) {
return orderRepository.sumApprovedQuantityByMemberIdAndProductCodeAndItemType(
memberId,
productCode,
ItemType.MATCHING_TICKET
);
}
return orderRepository.countApprovedByMemberIdAndProductCode(memberId, productCode);
}

private long activePendingPurchaseCount(Long memberId, String productCode, LocalDateTime now) {
if (DISCOUNT_MATCHING_TICKET_CODE.equals(productCode)) {
return orderRepository.sumActivePendingQuantityByMemberIdAndProductCodeAndItemType(
memberId,
productCode,
ItemType.MATCHING_TICKET,
now
);
}
return orderRepository.countActivePendingByMemberIdAndProductCode(memberId, productCode, now);
}

private PurchaseBlockReason purchaseBlockReason(
Long memberId,
Product product,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ public void run(String... args) throws Exception {
1,
true,
1,
true
false
);
addReward(firstPurchaseBundle, ItemType.MATCHING_TICKET, 3);
addReward(firstPurchaseBundle, ItemType.OPTION_TICKET, 6);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,27 +73,25 @@ class ShopServiceImplTest {
private static final int DEFAULT_QUANTITY = 1;

@ParameterizedTest
@ValueSource(longs = {0, 1, 2})
@DisplayName("할인 뽑기권은 승인 3회 미만이면 800원에 1개 주문을 생성한다")
void shouldAllowDiscountTicketBeforeThreeApprovedPurchases(long approvedCount) {
@ValueSource(ints = {1, 2, 3})
@DisplayName("할인 뽑기권은 누적 한도 안에서 요청 수량만큼 주문을 생성한다")
void shouldAllowDiscountTicketUpToThreePerMember(int quantity) {
Product product = discountMatchingTicket();
given(productRepository.findById(3L)).willReturn(Optional.of(product));
given(orderRepository.countApprovedByMemberIdAndProductCode(100L, product.getCode()))
.willReturn(approvedCount);
given(userOrderClient.getOrdererInfo(100L)).willReturn(new OrdererInfoDto(100L, "홍길동", "길동이"));
given(paymentOrderProperties.expireMinutes()).willReturn(43200L);

shopService.requestPurchase(100L, 3L, 1);
shopService.requestPurchase(100L, 3L, quantity);

ArgumentCaptor<Order> captor = ArgumentCaptor.forClass(Order.class);
then(orderRepository).should().save(captor.capture());
Order order = captor.getValue();
assertThat(order.getProductCode()).isEqualTo("DISCOUNT_MATCHING_TICKET_1");
assertThat(order.getExpectedPrice()).isEqualTo(800);
assertThat(order.getRequestedPrice()).isEqualTo(800);
assertThat(order.getExpectedPrice()).isEqualTo(800 * quantity);
assertThat(order.getRequestedPrice()).isEqualTo(800 * quantity);
assertThat(order.getOrderItems()).singleElement().satisfies(item -> {
assertThat(item.getItemType()).isEqualTo(ItemType.MATCHING_TICKET);
assertThat(item.getQuantity()).isEqualTo(1);
assertThat(item.getQuantity()).isEqualTo(quantity);
});
then(orderOutboxService).should().enqueueOrderCreated(order);
}
Expand All @@ -103,7 +101,8 @@ void shouldAllowDiscountTicketBeforeThreeApprovedPurchases(long approvedCount) {
void shouldRejectDiscountTicketAfterThreeApprovedPurchases() {
Product product = discountMatchingTicket();
given(productRepository.findById(3L)).willReturn(Optional.of(product));
given(orderRepository.countApprovedByMemberIdAndProductCode(100L, product.getCode())).willReturn(3L);
given(orderRepository.sumApprovedQuantityByMemberIdAndProductCodeAndItemType(
100L, product.getCode(), ItemType.MATCHING_TICKET)).willReturn(3L);

assertThatThrownBy(() -> shopService.requestPurchase(100L, 3L, 1))
.isInstanceOf(BusinessException.class)
Expand All @@ -119,9 +118,11 @@ void shouldRejectDiscountTicketAfterThreeApprovedPurchases() {
void shouldReturnDiscountTicketRemainingCount(long approvedCount, long pendingCount, long remainingCount) {
Product product = discountMatchingTicket();
given(productRepository.findActiveProductsWithRewards(true)).willReturn(List.of(product));
given(orderRepository.countApprovedByMemberIdAndProductCode(100L, product.getCode()))
given(orderRepository.sumApprovedQuantityByMemberIdAndProductCodeAndItemType(
100L, product.getCode(), ItemType.MATCHING_TICKET))
.willReturn(approvedCount);
given(orderRepository.countActivePendingByMemberIdAndProductCode(eq(100L), eq(product.getCode()), any()))
given(orderRepository.sumActivePendingQuantityByMemberIdAndProductCodeAndItemType(
eq(100L), eq(product.getCode()), eq(ItemType.MATCHING_TICKET), any()))
.willReturn(pendingCount);

assertThat(shopService.getActiveProducts(100L, true)).singleElement().satisfies(response -> {
Expand All @@ -137,8 +138,10 @@ void shouldReturnDiscountTicketRemainingCount(long approvedCount, long pendingCo
void shouldRejectDiscountTicketWithTwoApprovedAndOnePendingPurchase() {
Product product = discountMatchingTicket();
given(productRepository.findActiveProductsWithRewards(true)).willReturn(List.of(product));
given(orderRepository.countApprovedByMemberIdAndProductCode(100L, product.getCode())).willReturn(2L);
given(orderRepository.countActivePendingByMemberIdAndProductCode(eq(100L), eq(product.getCode()), any()))
given(orderRepository.sumApprovedQuantityByMemberIdAndProductCodeAndItemType(
100L, product.getCode(), ItemType.MATCHING_TICKET)).willReturn(2L);
given(orderRepository.sumActivePendingQuantityByMemberIdAndProductCodeAndItemType(
eq(100L), eq(product.getCode()), eq(ItemType.MATCHING_TICKET), any()))
.willReturn(1L);
given(productRepository.findById(3L)).willReturn(Optional.of(product));
given(orderRepository.existsActivePendingOrder(eq(100L), any())).willReturn(true);
Expand All @@ -155,17 +158,44 @@ void shouldRejectDiscountTicketWithTwoApprovedAndOnePendingPurchase() {
then(orderOutboxService).shouldHaveNoInteractions();
}

@ParameterizedTest
@ValueSource(ints = {2, 3})
@DisplayName("할인 뽑기권 quantity 2 또는 3 직접 요청은 서버에서 거부한다")
void shouldRejectMultipleDiscountTicketsInOneRequest(int quantity) {
given(productRepository.findById(3L)).willReturn(Optional.of(discountMatchingTicket()));
@Test
@DisplayName("할인 뽑기권 승인 0개와 유효 대기 3개가 있으면 잔여 0개이며 추가 요청을 거부한다")
void shouldRejectDiscountTicketWithNoApprovedAndThreePendingTickets() {
Product product = discountMatchingTicket();
given(productRepository.findActiveProductsWithRewards(true)).willReturn(List.of(product));
given(orderRepository.sumApprovedQuantityByMemberIdAndProductCodeAndItemType(
100L, product.getCode(), ItemType.MATCHING_TICKET)).willReturn(0L);
given(orderRepository.sumActivePendingQuantityByMemberIdAndProductCodeAndItemType(
eq(100L), eq(product.getCode()), eq(ItemType.MATCHING_TICKET), any()))
.willReturn(3L);
given(productRepository.findById(3L)).willReturn(Optional.of(product));
given(orderRepository.existsActivePendingOrder(eq(100L), any())).willReturn(true);

assertThatThrownBy(() -> shopService.requestPurchase(100L, 3L, quantity))
assertThat(shopService.getActiveProducts(100L, true)).singleElement().satisfies(response -> {
assertThat(response.remainingPurchaseCount()).isZero();
assertThat(response.purchaseCountPurchasable()).isFalse();
});
assertThatThrownBy(() -> shopService.requestPurchase(100L, 3L, 1))
.isInstanceOf(BusinessException.class)
.extracting(exception -> ((BusinessException)exception).getErrorCode())
.isEqualTo(PaymentErrorCode.INVALID_ORDER_QUANTITY);
then(orderRepository).shouldHaveNoInteractions();
.isEqualTo(PaymentErrorCode.PENDING_REQUEST_ALREADY_EXISTS);
then(orderRepository).should(never()).save(any());
then(orderOutboxService).shouldHaveNoInteractions();
}

@Test
@DisplayName("할인 뽑기권은 기존 구매 수량과 요청 수량의 합이 3개를 넘으면 거부한다")
void shouldRejectDiscountTicketsExceedingThreePerMember() {
Product product = discountMatchingTicket();
given(productRepository.findById(3L)).willReturn(Optional.of(product));
given(orderRepository.sumApprovedQuantityByMemberIdAndProductCodeAndItemType(
100L, product.getCode(), ItemType.MATCHING_TICKET)).willReturn(1L);

assertThatThrownBy(() -> shopService.requestPurchase(100L, 3L, 3))
.isInstanceOf(BusinessException.class)
.extracting(exception -> ((BusinessException)exception).getErrorCode())
.isEqualTo(PaymentErrorCode.PRODUCT_PURCHASE_LIMIT_EXCEEDED);
then(orderRepository).should(never()).save(any());
then(userOrderClient).shouldHaveNoInteractions();
then(orderOutboxService).shouldHaveNoInteractions();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ void shouldCreateConfirmedDefaultProducts() throws Exception {
assertThat(firstPurchaseBundle.getName()).isEqualTo("첫 구매 특가 번들");
assertThat(firstPurchaseBundle.getPrice()).isEqualTo(3000);
assertThat(firstPurchaseBundle.getPurchaseLimitPerMember()).isEqualTo(1);
assertThat(firstPurchaseBundle.isFirstPurchaseOnly()).isTrue();
assertThat(firstPurchaseBundle.isFirstPurchaseOnly()).isFalse();
assertThat(rewardQuantity(firstPurchaseBundle, ItemType.MATCHING_TICKET)).isEqualTo(3);
assertThat(rewardQuantity(firstPurchaseBundle, ItemType.OPTION_TICKET)).isEqualTo(6);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,17 +88,20 @@ void shouldReturnDiscountMatchingTicket() throws Exception {
}

@Test
@DisplayName("POST /api/v1/shop/purchase/{id}는 할인 상품 quantity=2를 거부한다")
void shouldRejectMultipleDiscountTickets() throws Exception {
given(productRepository.findById(3L)).willReturn(Optional.of(discountMatchingTicket()));
@DisplayName("POST /api/v1/shop/purchase/{id}는 할인 상품 quantity=3을 허용한다")
void shouldAllowThreeDiscountTickets() throws Exception {
Product product = discountMatchingTicket();
given(productRepository.findById(3L)).willReturn(Optional.of(product));
given(userOrderClient.getOrdererInfo(100L)).willReturn(new com.comatching.common.dto.member.OrdererInfoDto(
100L, "홍길동", "길동이"));
given(paymentOrderProperties.expireMinutes()).willReturn(43200L);

mockMvc.perform(post("/api/v1/shop/purchase/3")
.param("quantity", "2")
.param("quantity", "3")
.header("X-Member-Id", "100"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("PAY-007"));
.andExpect(status().isOk());

then(orderRepository).shouldHaveNoInteractions();
then(orderRepository).should().save(any());
}

@Test
Expand All @@ -107,16 +110,17 @@ void shouldRejectFourthDiscountTicketPurchase() throws Exception {
Product product = discountMatchingTicket();
given(productRepository.findById(3L)).willReturn(Optional.of(product));
given(orderRepository.existsActivePendingOrder(eq(100L), any())).willReturn(false);
given(orderRepository.countApprovedByMemberIdAndProductCode(100L, product.getCode())).willReturn(3L);
given(orderRepository.sumApprovedQuantityByMemberIdAndProductCodeAndItemType(
100L, product.getCode(), ItemType.MATCHING_TICKET)).willReturn(3L);

mockMvc.perform(post("/api/v1/shop/purchase/3")
.param("quantity", "1")
.header("X-Member-Id", "100"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("PAY-012"));

then(orderRepository).should().countActivePendingByMemberIdAndProductCode(
eq(100L), eq(product.getCode()), any());
then(orderRepository).should().sumActivePendingQuantityByMemberIdAndProductCodeAndItemType(
eq(100L), eq(product.getCode()), eq(ItemType.MATCHING_TICKET), any());
then(orderRepository).should(never()).save(any());
}

Expand Down
Loading