Skip to content

Commit 48bfb90

Browse files
committed
feature: 주문 결제 방식 확장 및 프로세서 추가
- `OrderCommand`에 결제 방식(`paymentType`), 카드 세부정보 추가 - `PaymentProcessor` 인터페이스 및 구현체(PG/포인트) 추가 - 결제 방식에 따른 처리 로직 분리 및 통합 - 엔티티 및 테스트 코드 수정으로 결제 방식 검증 및 테스트 강화
1 parent d080da2 commit 48bfb90

11 files changed

Lines changed: 257 additions & 20 deletions

File tree

apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
package com.loopers.application.order;
22

3+
import com.loopers.domain.coupon.CouponDiscountResult;
4+
import com.loopers.domain.coupon.CouponService;
35
import com.loopers.domain.order.Order;
46
import com.loopers.domain.order.OrderCommand.Item;
57
import com.loopers.domain.order.OrderCommand.PlaceOrder;
68
import com.loopers.domain.order.OrderItem;
79
import com.loopers.domain.order.OrderService;
10+
import com.loopers.domain.payment.Payment;
11+
import com.loopers.domain.payment.PaymentProcessor;
812
import com.loopers.domain.point.PointService;
913
import com.loopers.domain.product.Product;
1014
import com.loopers.domain.product.ProductService;
@@ -28,6 +32,8 @@ public class OrderFacade {
2832
private final UserService userService;
2933
private final OrderService orderService;
3034
private final PointService pointService;
35+
private final CouponService couponService;
36+
private final List<PaymentProcessor> paymentProcessors;
3137

3238
@Transactional
3339
public OrderInfo placeOrder(PlaceOrder command) {
@@ -45,8 +51,32 @@ public OrderInfo placeOrder(PlaceOrder command) {
4551
Order order = orderService.createOrder(user.getId(), orderItems);
4652
long totalAmount = order.getTotalAmount().getValue();
4753

54+
long finalPaymentAmount = totalAmount;
55+
56+
if (command.couponId() != null) {
57+
CouponDiscountResult discountResult = couponService.useCouponAndCalculateDiscount(
58+
command.couponId(),
59+
totalAmount
60+
);
61+
62+
finalPaymentAmount -= discountResult.discountAmount();
63+
}
64+
4865
productService.deductStock(products, orderItems);
49-
pointService.deductPoint(user, totalAmount);
66+
67+
PaymentProcessor processor = paymentProcessors.stream()
68+
.filter(p -> p.supports(command.paymentType())) // command에 paymentType 필드 추가 필요
69+
.findFirst()
70+
.orElseThrow(() -> new CoreException(ErrorType.BAD_REQUEST, "지원하지 않는 결제 방식입니다."));
71+
72+
Map<String, Object> paymentDetails = command.getPaymentDetails();
73+
74+
Payment paymentResult = processor.process(
75+
order.getId(),
76+
user,
77+
finalPaymentAmount,
78+
paymentDetails
79+
);
5080

5181
return OrderInfo.from(order);
5282
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package com.loopers.application.payment;
2+
3+
import com.loopers.domain.money.Money;
4+
import com.loopers.domain.payment.CardType;
5+
import com.loopers.domain.payment.Payment;
6+
import com.loopers.domain.payment.PaymentExecutor;
7+
import com.loopers.domain.payment.PaymentProcessor;
8+
import com.loopers.domain.payment.PaymentService;
9+
import com.loopers.domain.payment.PaymentType;
10+
import com.loopers.domain.user.User;
11+
import com.loopers.support.error.CoreException;
12+
import com.loopers.support.error.ErrorType;
13+
import java.util.Map;
14+
import lombok.RequiredArgsConstructor;
15+
import org.springframework.stereotype.Component;
16+
17+
@Component
18+
@RequiredArgsConstructor
19+
public class PgPaymentProcessor implements PaymentProcessor {
20+
21+
private final PaymentService paymentService;
22+
private final PaymentExecutor paymentExecutor;
23+
24+
@Override
25+
public Payment process(Long orderId, User user, long finalAmount, Map<String, Object> paymentDetails) {
26+
27+
String cardTypeStr = (String) paymentDetails.get("cardType");
28+
String cardNo = (String) paymentDetails.get("cardNo");
29+
30+
if (cardTypeStr == null || cardNo == null) {
31+
throw new CoreException(ErrorType.BAD_REQUEST, "PG 결제에 필요한 카드 정보가 누락되었습니다.");
32+
}
33+
34+
CardType cardType;
35+
try {
36+
cardType = CardType.valueOf(cardTypeStr.toUpperCase());
37+
} catch (IllegalArgumentException e) {
38+
throw new CoreException(ErrorType.BAD_REQUEST, "지원하지 않는 카드 타입입니다: " + cardTypeStr);
39+
}
40+
41+
return paymentService.findValidPayment(orderId)
42+
.orElseGet(() -> {
43+
44+
Payment newPayment = paymentService.createPendingPayment(user.getId(),
45+
orderId,
46+
new Money(finalAmount),
47+
cardType,
48+
cardNo
49+
);
50+
51+
try {
52+
String pgTxnId = paymentExecutor.execute(newPayment);
53+
paymentService.registerPgToken(newPayment, pgTxnId);
54+
return newPayment;
55+
} catch (Exception e) {
56+
paymentService.failPayment(newPayment);
57+
throw e;
58+
}
59+
});
60+
}
61+
62+
@Override
63+
public boolean supports(PaymentType paymentType) {
64+
return paymentType == PaymentType.PG;
65+
}
66+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package com.loopers.application.payment;
2+
3+
import static com.loopers.domain.user.QUser.user;
4+
5+
import com.loopers.domain.money.Money;
6+
import com.loopers.domain.payment.Payment;
7+
import com.loopers.domain.payment.PaymentProcessor;
8+
import com.loopers.domain.payment.PaymentService;
9+
import com.loopers.domain.payment.PaymentType;
10+
import com.loopers.domain.point.PointService;
11+
import com.loopers.domain.user.User;
12+
import java.util.Map;
13+
import lombok.RequiredArgsConstructor;
14+
import org.springframework.stereotype.Component;
15+
16+
@Component
17+
@RequiredArgsConstructor
18+
public class PointPaymentProcessor implements PaymentProcessor {
19+
20+
private final PointService pointService;
21+
private final PaymentService paymentService;
22+
23+
@Override
24+
public Payment process(Long orderId, User user, long finalAmount, Map<String, Object> paymentDetails) {
25+
pointService.deductPoint(user, finalAmount);
26+
27+
return paymentService.createPointPaymentAndComplete(
28+
orderId,
29+
user.getId(),
30+
new Money(finalAmount));
31+
}
32+
33+
@Override
34+
public boolean supports(PaymentType paymentType) {
35+
return paymentType == PaymentType.POINT;
36+
}
37+
}

apps/commerce-api/src/main/java/com/loopers/domain/order/OrderCommand.java

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,26 @@
11
package com.loopers.domain.order;
22

3+
import com.loopers.domain.payment.PaymentType;
34
import java.util.List;
5+
import java.util.Map;
46

57
public class OrderCommand {
68

79
public record PlaceOrder(
810
Long userId,
911
Long couponId,
10-
List<Item> items
12+
List<Item> items,
13+
PaymentType paymentType,
14+
String cardType,
15+
String cardNo
1116
) {
17+
public Map<String, Object> getPaymentDetails() {
18+
return Map.of(
19+
"cardType", cardType,
20+
"cardNo", cardNo
21+
);
22+
}
23+
1224

1325
}
1426

apps/commerce-api/src/main/java/com/loopers/domain/payment/Payment.java

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,14 @@ public class Payment extends BaseEntity {
3333
private String transactionId;//멱등키
3434

3535
@Enumerated(EnumType.STRING)
36-
@Column(name = "card_type", nullable = false)
36+
@Column(name = "payment_type", nullable = false)
37+
private PaymentType paymentType;
38+
39+
@Enumerated(EnumType.STRING)
40+
@Column(name = "card_type", nullable = true)
3741
private CardType cardType;
3842

39-
@Column(name = "card_no", nullable = false)
43+
@Column(name = "card_no", nullable = true)
4044
private String cardNo;
4145

4246
@Embedded
@@ -50,31 +54,32 @@ public class Payment extends BaseEntity {
5054
@Column(name = "pg_txn_id")
5155
private String pgTxnId;
5256

53-
public Payment(Long orderId, Long userId, Money amount, CardType cardType, String cardNo) {
54-
validateConstructor(orderId, userId, amount, cardNo);
57+
public Payment(Long orderId, Long userId, Money amount, PaymentType paymentType, CardType cardType, String cardNo) {
58+
validateConstructor(orderId, userId, amount, paymentType);
5559

5660
this.orderId = orderId;
5761
this.userId = userId;
5862
this.amount = amount;
63+
this.paymentType = paymentType;
5964
this.cardType = cardType;
6065
this.cardNo = cardNo;
6166
this.status = PaymentStatus.READY;
6267

6368
this.transactionId = UUID.randomUUID().toString();
6469
}
6570

66-
private void validateConstructor(Long orderId, Long userId, Money amount, String cardNo) {
71+
private void validateConstructor(Long orderId, Long userId, Money amount, PaymentType paymentType) {
6772
if (orderId == null) {
6873
throw new CoreException(ErrorType.BAD_REQUEST, "주문 정보는 필수입니다.");
6974
}
7075
if (userId == null) {
7176
throw new CoreException(ErrorType.BAD_REQUEST, "사용자 정보는 필수입니다.");
7277
}
73-
if (amount == null) {
78+
if (amount == null || amount.getValue() <= 0) {
7479
throw new CoreException(ErrorType.BAD_REQUEST, "결제 금액은 필수입니다.");
7580
}
76-
if (!StringUtils.hasText(cardNo)) {
77-
throw new CoreException(ErrorType.BAD_REQUEST, "카드 번호는 필수입니다.");
81+
if (paymentType == null) {
82+
throw new CoreException(ErrorType.BAD_REQUEST, "결제 방식은 필수입니다.");
7883
}
7984
}
8085

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package com.loopers.domain.payment;
2+
3+
import com.loopers.domain.user.User;
4+
import java.util.Map;
5+
6+
public interface PaymentProcessor {
7+
8+
Payment process(Long orderId, User user, long finalAmount, Map<String, Object> paymentDetails);
9+
10+
boolean supports(PaymentType paymentType);
11+
}
12+
13+

apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentService.java

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,31 @@ public Payment createPendingPayment(Long userId, Long orderId, Money amount, Car
2626
orderId,
2727
userId,
2828
amount,
29+
PaymentType.PG,
2930
cardType,
3031
cardNo
3132
);
3233
return paymentRepository.save(payment);
3334
}
3435

36+
@Transactional
37+
public Payment createPointPaymentAndComplete(Long userId, Long orderId, Money amount) {
38+
39+
Payment payment = new Payment(
40+
orderId,
41+
userId,
42+
amount,
43+
PaymentType.POINT,
44+
null,
45+
null
46+
);
47+
paymentRepository.save(payment);
48+
49+
completePayment(payment);
50+
51+
return payment;
52+
}
53+
3554
@Transactional
3655
public void registerPgToken(Payment payment, String pgTxnId) {
3756
payment.setPgTxnId(pgTxnId);
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
package com.loopers.domain.payment;
2+
3+
public enum PaymentType {
4+
POINT, PG
5+
}

apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import com.loopers.application.order.OrderInfo;
44
import com.loopers.domain.order.OrderCommand;
5+
import com.loopers.domain.payment.PaymentType;
56
import jakarta.validation.constraints.NotEmpty;
67
import jakarta.validation.constraints.NotNull;
78
import jakarta.validation.constraints.Positive;
@@ -10,7 +11,12 @@
1011
public class OrderV1Dto {
1112
public record OrderRequest(
1213
@NotEmpty(message = "주문 상품은 필수입니다.")
13-
List<OrderItemRequest> items
14+
List<OrderItemRequest> items,
15+
Long couponId,
16+
@NotNull(message = "결제 방식은 필수입니다.")
17+
PaymentType paymentType,
18+
String cardType,
19+
String cardNo
1420
) {
1521

1622
public OrderCommand.PlaceOrder toCommand(Long userId) {
@@ -20,7 +26,11 @@ public OrderCommand.PlaceOrder toCommand(Long userId) {
2026

2127
return new OrderCommand.PlaceOrder(
2228
userId,
23-
commandItems
29+
couponId,
30+
commandItems,
31+
paymentType,
32+
cardType,
33+
cardNo
2434
);
2535
}
2636
}

apps/commerce-api/src/test/java/com/loopers/application/order/OrderConcurrencyTest.java

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import com.loopers.domain.money.Money;
66
import com.loopers.domain.order.OrderCommand.Item;
77
import com.loopers.domain.order.OrderCommand.PlaceOrder;
8+
import com.loopers.domain.payment.PaymentType;
89
import com.loopers.domain.point.Point;
910
import com.loopers.domain.product.Product;
1011
import com.loopers.domain.user.User;
@@ -46,6 +47,17 @@ void tearDown() {
4647
productJpaRepository.deleteAll();
4748
}
4849

50+
private PlaceOrder createPlaceOrderCommand(Long userId, List<Item> items) {
51+
return new PlaceOrder(
52+
userId,
53+
null,
54+
items,
55+
PaymentType.PG,
56+
"KB",
57+
"1234-5678-9012-3456"
58+
);
59+
}
60+
4961
@Nested
5062
@DisplayName("포인트 동시성 (비관적 락)")
5163
class PointConcurrency {
@@ -82,8 +94,10 @@ void point_deduction_concurrency_test() {
8294
.mapToObj(i -> CompletableFuture.runAsync(() -> {
8395
try {
8496
Product targetProduct = distinctProducts.get(i);
85-
PlaceOrder command = new PlaceOrder(savedUser.getId(),
86-
List.of(new Item(targetProduct.getId(), 1)));
97+
PlaceOrder command = createPlaceOrderCommand(
98+
savedUser.getId(),
99+
List.of(new Item(targetProduct.getId(), 1))
100+
);
87101

88102
orderFacade.placeOrder(command);
89103

@@ -141,8 +155,10 @@ void stock_deduction_concurrency_test() {
141155
List<CompletableFuture<Void>> futures = IntStream.range(0, threadCount)
142156
.mapToObj(i -> CompletableFuture.runAsync(() -> {
143157
try {
144-
PlaceOrder command = new PlaceOrder(multiUsers.get(i).getId(),
145-
List.of(new Item(savedProduct.getId(), 1)));
158+
PlaceOrder command = createPlaceOrderCommand(
159+
multiUsers.get(i).getId(),
160+
List.of(new Item(savedProduct.getId(), 1))
161+
);
146162

147163
orderFacade.placeOrder(command);
148164

0 commit comments

Comments
 (0)