diff --git a/build.gradle b/build.gradle index 82badb15..9783e62f 100644 --- a/build.gradle +++ b/build.gradle @@ -50,6 +50,7 @@ dependencies { // Test testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'com.squareup.okhttp3:mockwebserver:4.11.0' // Querydsl implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' @@ -92,6 +93,9 @@ dependencies { // S3 implementation platform("io.awspring.cloud:spring-cloud-aws-dependencies:3.0.2") implementation 'io.awspring.cloud:spring-cloud-aws-starter-s3' + + // webflux + implementation 'org.springframework.boot:spring-boot-starter-webflux' } tasks.named('test') { diff --git a/src/main/java/com/moabam/api/application/bug/BugMapper.java b/src/main/java/com/moabam/api/application/bug/BugMapper.java index 0d439458..9f217971 100644 --- a/src/main/java/com/moabam/api/application/bug/BugMapper.java +++ b/src/main/java/com/moabam/api/application/bug/BugMapper.java @@ -28,4 +28,13 @@ public static BugHistory toUseBugHistory(Long memberId, BugType bugType, int qua .quantity(quantity) .build(); } + + public static BugHistory toChargeBugHistory(Long memberId, int quantity) { + return BugHistory.builder() + .memberId(memberId) + .bugType(BugType.GOLDEN) + .actionType(BugActionType.CHARGE) + .quantity(quantity) + .build(); + } } diff --git a/src/main/java/com/moabam/api/application/bug/BugService.java b/src/main/java/com/moabam/api/application/bug/BugService.java index 46908ae4..18d2a7a2 100644 --- a/src/main/java/com/moabam/api/application/bug/BugService.java +++ b/src/main/java/com/moabam/api/application/bug/BugService.java @@ -13,8 +13,9 @@ import com.moabam.api.application.member.MemberService; import com.moabam.api.application.payment.PaymentMapper; import com.moabam.api.application.product.ProductMapper; +import com.moabam.api.domain.bug.Bug; +import com.moabam.api.domain.bug.repository.BugHistoryRepository; import com.moabam.api.domain.coupon.Coupon; -import com.moabam.api.domain.member.Member; import com.moabam.api.domain.payment.Payment; import com.moabam.api.domain.payment.repository.PaymentRepository; import com.moabam.api.domain.product.Product; @@ -34,13 +35,14 @@ public class BugService { private final MemberService memberService; private final CouponService couponService; + private final BugHistoryRepository bugHistoryRepository; private final ProductRepository productRepository; private final PaymentRepository paymentRepository; public BugResponse getBug(Long memberId) { - Member member = memberService.getById(memberId); + Bug bug = memberService.getById(memberId).getBug(); - return BugMapper.toBugResponse(member.getBug()); + return BugMapper.toBugResponse(bug); } public ProductsResponse getBugProducts() { @@ -63,6 +65,14 @@ public PurchaseProductResponse purchaseBugProduct(Long memberId, Long productId, return ProductMapper.toPurchaseProductResponse(payment); } + @Transactional + public void charge(Long memberId, Product bugProduct) { + Bug bug = memberService.getById(memberId).getBug(); + + bug.charge(bugProduct.getQuantity()); + bugHistoryRepository.save(BugMapper.toChargeBugHistory(memberId, bugProduct.getQuantity())); + } + private Product getById(Long productId) { return productRepository.findById(productId) .orElseThrow(() -> new NotFoundException(PRODUCT_NOT_FOUND)); diff --git a/src/main/java/com/moabam/api/application/coupon/CouponService.java b/src/main/java/com/moabam/api/application/coupon/CouponService.java index 43962390..ec96458a 100644 --- a/src/main/java/com/moabam/api/application/coupon/CouponService.java +++ b/src/main/java/com/moabam/api/application/coupon/CouponService.java @@ -10,6 +10,7 @@ import com.moabam.api.domain.coupon.CouponWallet; import com.moabam.api.domain.coupon.repository.CouponRepository; import com.moabam.api.domain.coupon.repository.CouponSearchRepository; +import com.moabam.api.domain.coupon.repository.CouponWalletRepository; import com.moabam.api.domain.coupon.repository.CouponWalletSearchRepository; import com.moabam.api.domain.member.Role; import com.moabam.api.dto.coupon.CouponResponse; @@ -32,9 +33,9 @@ public class CouponService { private final ClockHolder clockHolder; private final CouponManageService couponManageService; - private final CouponRepository couponRepository; private final CouponSearchRepository couponSearchRepository; + private final CouponWalletRepository couponWalletRepository; private final CouponWalletSearchRepository couponWalletSearchRepository; @Transactional @@ -57,6 +58,12 @@ public void delete(AuthMember admin, Long couponId) { couponManageService.deleteCouponManage(coupon.getName()); } + @Transactional + public void use(Long memberId, Long couponWalletId) { + Coupon coupon = getByWalletIdAndMemberId(couponWalletId, memberId); + couponRepository.delete(coupon); + } + public CouponResponse getById(Long couponId) { Coupon coupon = couponRepository.findById(couponId) .orElseThrow(() -> new NotFoundException(ErrorMessage.NOT_FOUND_COUPON)); diff --git a/src/main/java/com/moabam/api/application/payment/PaymentMapper.java b/src/main/java/com/moabam/api/application/payment/PaymentMapper.java index c5bf6c56..3495adad 100644 --- a/src/main/java/com/moabam/api/application/payment/PaymentMapper.java +++ b/src/main/java/com/moabam/api/application/payment/PaymentMapper.java @@ -19,7 +19,7 @@ public static Payment toPayment(Long memberId, Product product) { .memberId(memberId) .product(product) .order(order) - .amount(product.getPrice()) + .totalAmount(product.getPrice()) .build(); } } diff --git a/src/main/java/com/moabam/api/application/payment/PaymentService.java b/src/main/java/com/moabam/api/application/payment/PaymentService.java index 7d4b762f..0055813b 100644 --- a/src/main/java/com/moabam/api/application/payment/PaymentService.java +++ b/src/main/java/com/moabam/api/application/payment/PaymentService.java @@ -5,9 +5,17 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import com.moabam.api.application.bug.BugService; +import com.moabam.api.application.coupon.CouponService; import com.moabam.api.domain.payment.Payment; import com.moabam.api.domain.payment.repository.PaymentRepository; +import com.moabam.api.domain.payment.repository.PaymentSearchRepository; +import com.moabam.api.dto.payment.ConfirmPaymentRequest; +import com.moabam.api.dto.payment.ConfirmTossPaymentResponse; import com.moabam.api.dto.payment.PaymentRequest; +import com.moabam.api.infrastructure.payment.TossPaymentMapper; +import com.moabam.api.infrastructure.payment.TossPaymentService; +import com.moabam.global.error.exception.MoabamException; import com.moabam.global.error.exception.NotFoundException; import lombok.RequiredArgsConstructor; @@ -17,7 +25,11 @@ @RequiredArgsConstructor public class PaymentService { + private final BugService bugService; + private final CouponService couponService; + private final TossPaymentService tossPaymentService; private final PaymentRepository paymentRepository; + private final PaymentSearchRepository paymentSearchRepository; @Transactional public void request(Long memberId, Long paymentId, PaymentRequest request) { @@ -26,8 +38,33 @@ public void request(Long memberId, Long paymentId, PaymentRequest request) { payment.request(request.orderId()); } + @Transactional + public void confirm(Long memberId, ConfirmPaymentRequest request) { + Payment payment = getByOrderId(request.orderId()); + payment.validateInfo(memberId, request.amount()); + + try { + ConfirmTossPaymentResponse response = tossPaymentService.confirm( + TossPaymentMapper.toConfirmRequest(request.paymentKey(), request.orderId(), request.amount()) + ); + payment.confirm(response.paymentKey(), response.approvedAt()); + + if (payment.isCouponApplied()) { + couponService.use(memberId, payment.getCouponWalletId()); + } + bugService.charge(memberId, payment.getProduct()); + } catch (MoabamException exception) { + payment.fail(request.paymentKey()); + } + } + private Payment getById(Long paymentId) { return paymentRepository.findById(paymentId) .orElseThrow(() -> new NotFoundException(PAYMENT_NOT_FOUND)); } + + private Payment getByOrderId(String orderId) { + return paymentSearchRepository.findByOrderId(orderId) + .orElseThrow(() -> new NotFoundException(PAYMENT_NOT_FOUND)); + } } diff --git a/src/main/java/com/moabam/api/application/product/ProductMapper.java b/src/main/java/com/moabam/api/application/product/ProductMapper.java index 32369eb2..b847d9c1 100644 --- a/src/main/java/com/moabam/api/application/product/ProductMapper.java +++ b/src/main/java/com/moabam/api/application/product/ProductMapper.java @@ -35,7 +35,7 @@ public static PurchaseProductResponse toPurchaseProductResponse(Payment payment) return PurchaseProductResponse.builder() .paymentId(payment.getId()) .orderName(payment.getOrder().getName()) - .price(payment.getAmount()) + .price(payment.getTotalAmount()) .build(); } } diff --git a/src/main/java/com/moabam/api/application/room/CertificationService.java b/src/main/java/com/moabam/api/application/room/CertificationService.java index 60d4b56d..4d320147 100644 --- a/src/main/java/com/moabam/api/application/room/CertificationService.java +++ b/src/main/java/com/moabam/api/application/room/CertificationService.java @@ -1,9 +1,6 @@ package com.moabam.api.application.room; -import static com.moabam.global.error.model.ErrorMessage.DUPLICATED_DAILY_MEMBER_CERTIFICATION; -import static com.moabam.global.error.model.ErrorMessage.INVALID_CERTIFY_TIME; -import static com.moabam.global.error.model.ErrorMessage.PARTICIPANT_NOT_FOUND; -import static com.moabam.global.error.model.ErrorMessage.ROUTINE_NOT_FOUND; +import static com.moabam.global.error.model.ErrorMessage.*; import java.time.LocalDate; import java.time.LocalDateTime; @@ -81,7 +78,7 @@ public void certifyRoom(Long memberId, Long roomId, List imageUrls) { return; } - member.getBug().increaseBug(bugType, roomLevel); + member.getBug().increase(bugType, roomLevel); } public boolean existsMemberCertification(Long memberId, Long roomId, LocalDate date) { @@ -175,6 +172,6 @@ private void provideBugToCompletedMembers(BugType bugType, List completedMember.getBug().increaseBug(bugType, expAppliedRoomLevel)); + .forEach(completedMember -> completedMember.getBug().increase(bugType, expAppliedRoomLevel)); } } diff --git a/src/main/java/com/moabam/api/domain/bug/Bug.java b/src/main/java/com/moabam/api/domain/bug/Bug.java index 246844cf..ae07f4d6 100644 --- a/src/main/java/com/moabam/api/domain/bug/Bug.java +++ b/src/main/java/com/moabam/api/domain/bug/Bug.java @@ -48,7 +48,7 @@ private int validateBugCount(int bug) { public void use(BugType bugType, int price) { int currentBug = getBug(bugType); validateEnoughBug(currentBug, price); - decreaseBug(bugType, price); + decrease(bugType, price); } private int getBug(BugType bugType) { @@ -65,7 +65,7 @@ private void validateEnoughBug(int currentBug, int price) { } } - private void decreaseBug(BugType bugType, int bug) { + private void decrease(BugType bugType, int bug) { switch (bugType) { case MORNING -> this.morningBug -= bug; case NIGHT -> this.nightBug -= bug; @@ -73,11 +73,15 @@ private void decreaseBug(BugType bugType, int bug) { } } - public void increaseBug(BugType bugType, int bug) { + public void increase(BugType bugType, int bug) { switch (bugType) { case MORNING -> this.morningBug += bug; case NIGHT -> this.nightBug += bug; case GOLDEN -> this.goldenBug += bug; } } + + public void charge(int quantity) { + this.goldenBug += quantity; + } } diff --git a/src/main/java/com/moabam/api/domain/bug/repository/BugHistorySearchRepository.java b/src/main/java/com/moabam/api/domain/bug/repository/BugHistorySearchRepository.java index 26a8f9ff..82a9ae61 100644 --- a/src/main/java/com/moabam/api/domain/bug/repository/BugHistorySearchRepository.java +++ b/src/main/java/com/moabam/api/domain/bug/repository/BugHistorySearchRepository.java @@ -22,8 +22,7 @@ public class BugHistorySearchRepository { private final JPAQueryFactory jpaQueryFactory; public List find(Long memberId, BugActionType actionType, LocalDateTime dateTime) { - return jpaQueryFactory - .selectFrom(bugHistory) + return jpaQueryFactory.selectFrom(bugHistory) .where( DynamicQuery.generateEq(memberId, bugHistory.memberId::eq), DynamicQuery.generateEq(actionType, bugHistory.actionType::eq), diff --git a/src/main/java/com/moabam/api/domain/item/repository/InventorySearchRepository.java b/src/main/java/com/moabam/api/domain/item/repository/InventorySearchRepository.java index 431cf898..0141d2e4 100644 --- a/src/main/java/com/moabam/api/domain/item/repository/InventorySearchRepository.java +++ b/src/main/java/com/moabam/api/domain/item/repository/InventorySearchRepository.java @@ -23,23 +23,23 @@ public class InventorySearchRepository { private final JPAQueryFactory jpaQueryFactory; public Optional findOne(Long memberId, Long itemId) { - return Optional.ofNullable( - jpaQueryFactory.selectFrom(inventory) - .where( - DynamicQuery.generateEq(memberId, inventory.memberId::eq), - DynamicQuery.generateEq(itemId, inventory.item.id::eq)) - .fetchOne() + return Optional.ofNullable(jpaQueryFactory + .selectFrom(inventory) + .where( + DynamicQuery.generateEq(memberId, inventory.memberId::eq), + DynamicQuery.generateEq(itemId, inventory.item.id::eq)) + .fetchOne() ); } public Optional findDefault(Long memberId, ItemType type) { - return Optional.ofNullable( - jpaQueryFactory.selectFrom(inventory) - .where( - DynamicQuery.generateEq(memberId, inventory.memberId::eq), - DynamicQuery.generateEq(type, inventory.item.type::eq), - inventory.isDefault.isTrue()) - .fetchOne() + return Optional.ofNullable(jpaQueryFactory + .selectFrom(inventory) + .where( + DynamicQuery.generateEq(memberId, inventory.memberId::eq), + DynamicQuery.generateEq(type, inventory.item.type::eq), + inventory.isDefault.isTrue()) + .fetchOne() ); } diff --git a/src/main/java/com/moabam/api/domain/payment/Payment.java b/src/main/java/com/moabam/api/domain/payment/Payment.java index cf55b607..9c1f4dea 100644 --- a/src/main/java/com/moabam/api/domain/payment/Payment.java +++ b/src/main/java/com/moabam/api/domain/payment/Payment.java @@ -1,7 +1,6 @@ package com.moabam.api.domain.payment; import static com.moabam.global.error.model.ErrorMessage.*; -import static java.lang.Math.*; import static java.util.Objects.*; import java.time.LocalDateTime; @@ -53,18 +52,17 @@ public class Payment { @JoinColumn(name = "product_id", updatable = false, nullable = false) private Product product; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "coupon_id") - private Coupon coupon; - @Column(name = "coupon_wallet_id") private Long couponWalletId; @Embedded private Order order; - @Column(name = "amount", nullable = false) - private int amount; + @Column(name = "total_amount", nullable = false) + private int totalAmount; + + @Column(name = "discount_amount", nullable = false) + private int discountAmount; @Column(name = "payment_key") private String paymentKey; @@ -84,11 +82,14 @@ public class Payment { private LocalDateTime approvedAt; @Builder - public Payment(Long memberId, Product product, Order order, int amount, PaymentStatus status) { + public Payment(Long memberId, Product product, Long couponWalletId, Order order, int totalAmount, + int discountAmount, PaymentStatus status) { this.memberId = requireNonNull(memberId); this.product = requireNonNull(product); + this.couponWalletId = couponWalletId; this.order = requireNonNull(order); - this.amount = validateAmount(amount); + this.totalAmount = validateAmount(totalAmount); + this.discountAmount = validateAmount(discountAmount); this.status = requireNonNullElse(status, PaymentStatus.READY); } @@ -100,20 +101,46 @@ private int validateAmount(int amount) { return amount; } + public void validateInfo(Long memberId, int amount) { + validateByMember(memberId); + validateByTotalAmount(amount); + } + public void validateByMember(Long memberId) { if (!this.memberId.equals(memberId)) { throw new BadRequestException(INVALID_MEMBER_PAYMENT); } } + private void validateByTotalAmount(int amount) { + if (this.totalAmount != amount) { + throw new BadRequestException(INVALID_PAYMENT_INFO); + } + } + + public boolean isCouponApplied() { + return !isNull(this.couponWalletId); + } + public void applyCoupon(Coupon coupon, Long couponWalletId) { - this.coupon = coupon; this.couponWalletId = couponWalletId; - this.amount = max(MIN_AMOUNT, this.amount - coupon.getPoint()); + this.discountAmount = coupon.getPoint(); + this.totalAmount = Math.max(MIN_AMOUNT, this.totalAmount - coupon.getPoint()); } public void request(String orderId) { this.order.updateId(orderId); this.requestedAt = LocalDateTime.now(); } + + public void confirm(String paymentKey, LocalDateTime approvedAt) { + this.paymentKey = paymentKey; + this.approvedAt = approvedAt; + this.status = PaymentStatus.DONE; + } + + public void fail(String paymentKey) { + this.paymentKey = paymentKey; + this.status = PaymentStatus.ABORTED; + } } diff --git a/src/main/java/com/moabam/api/domain/payment/repository/PaymentSearchRepository.java b/src/main/java/com/moabam/api/domain/payment/repository/PaymentSearchRepository.java new file mode 100644 index 00000000..dafb6925 --- /dev/null +++ b/src/main/java/com/moabam/api/domain/payment/repository/PaymentSearchRepository.java @@ -0,0 +1,27 @@ +package com.moabam.api.domain.payment.repository; + +import static com.moabam.api.domain.payment.QPayment.*; + +import java.util.Optional; + +import org.springframework.stereotype.Repository; + +import com.moabam.api.domain.payment.Payment; +import com.querydsl.jpa.impl.JPAQueryFactory; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class PaymentSearchRepository { + + private final JPAQueryFactory jpaQueryFactory; + + public Optional findByOrderId(String orderId) { + return Optional.ofNullable(jpaQueryFactory + .selectFrom(payment) + .where(payment.order.id.eq(orderId)) + .fetchOne() + ); + } +} diff --git a/src/main/java/com/moabam/api/dto/payment/ConfirmPaymentRequest.java b/src/main/java/com/moabam/api/dto/payment/ConfirmPaymentRequest.java new file mode 100644 index 00000000..ee5a4d03 --- /dev/null +++ b/src/main/java/com/moabam/api/dto/payment/ConfirmPaymentRequest.java @@ -0,0 +1,15 @@ +package com.moabam.api.dto.payment; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Builder; + +@Builder +public record ConfirmPaymentRequest( + @NotBlank String paymentKey, + @NotBlank String orderId, + @NotNull @Min(0) int amount +) { + +} diff --git a/src/main/java/com/moabam/api/dto/payment/ConfirmTossPaymentRequest.java b/src/main/java/com/moabam/api/dto/payment/ConfirmTossPaymentRequest.java new file mode 100644 index 00000000..3527f148 --- /dev/null +++ b/src/main/java/com/moabam/api/dto/payment/ConfirmTossPaymentRequest.java @@ -0,0 +1,12 @@ +package com.moabam.api.dto.payment; + +import lombok.Builder; + +@Builder +public record ConfirmTossPaymentRequest( + String paymentKey, + String orderId, + int amount +) { + +} diff --git a/src/main/java/com/moabam/api/dto/payment/ConfirmTossPaymentResponse.java b/src/main/java/com/moabam/api/dto/payment/ConfirmTossPaymentResponse.java new file mode 100644 index 00000000..ee32917a --- /dev/null +++ b/src/main/java/com/moabam/api/dto/payment/ConfirmTossPaymentResponse.java @@ -0,0 +1,21 @@ +package com.moabam.api.dto.payment; + +import java.time.LocalDateTime; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.moabam.api.domain.payment.PaymentStatus; + +import lombok.Builder; + +@Builder +@JsonIgnoreProperties(ignoreUnknown = true) +public record ConfirmTossPaymentResponse( + String paymentKey, + String orderId, + String orderName, + PaymentStatus status, + int totalAmount, + LocalDateTime approvedAt +) { + +} diff --git a/src/main/java/com/moabam/api/dto/product/PurchaseProductRequest.java b/src/main/java/com/moabam/api/dto/product/PurchaseProductRequest.java index a14a00c7..6e7eda86 100644 --- a/src/main/java/com/moabam/api/dto/product/PurchaseProductRequest.java +++ b/src/main/java/com/moabam/api/dto/product/PurchaseProductRequest.java @@ -1,6 +1,6 @@ package com.moabam.api.dto.product; -import javax.annotation.Nullable; +import jakarta.annotation.Nullable; public record PurchaseProductRequest( @Nullable Long couponWalletId diff --git a/src/main/java/com/moabam/api/infrastructure/payment/TossPaymentMapper.java b/src/main/java/com/moabam/api/infrastructure/payment/TossPaymentMapper.java new file mode 100644 index 00000000..146036eb --- /dev/null +++ b/src/main/java/com/moabam/api/infrastructure/payment/TossPaymentMapper.java @@ -0,0 +1,18 @@ +package com.moabam.api.infrastructure.payment; + +import com.moabam.api.dto.payment.ConfirmTossPaymentRequest; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class TossPaymentMapper { + + public static ConfirmTossPaymentRequest toConfirmRequest(String paymentKey, String orderId, int amount) { + return ConfirmTossPaymentRequest.builder() + .paymentKey(paymentKey) + .orderId(orderId) + .amount(amount) + .build(); + } +} diff --git a/src/main/java/com/moabam/api/infrastructure/payment/TossPaymentService.java b/src/main/java/com/moabam/api/infrastructure/payment/TossPaymentService.java new file mode 100644 index 00000000..2389746e --- /dev/null +++ b/src/main/java/com/moabam/api/infrastructure/payment/TossPaymentService.java @@ -0,0 +1,50 @@ +package com.moabam.api.infrastructure.payment; + +import java.util.Base64; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.BodyInserters; +import org.springframework.web.reactive.function.client.WebClient; + +import com.moabam.api.dto.payment.ConfirmTossPaymentRequest; +import com.moabam.api.dto.payment.ConfirmTossPaymentResponse; +import com.moabam.global.config.TossPaymentConfig; +import com.moabam.global.error.exception.MoabamException; +import com.moabam.global.error.model.ErrorResponse; + +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import reactor.core.publisher.Mono; + +@Service +@RequiredArgsConstructor +public class TossPaymentService { + + private final TossPaymentConfig config; + private WebClient webClient; + + @PostConstruct + public void init() { + this.webClient = WebClient.builder() + .baseUrl(config.baseUrl()) + .defaultHeaders(headers -> { + headers.add(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE); + headers.setBearerAuth(Base64.getEncoder().encodeToString(config.secretKey().getBytes())); + }) + .build(); + } + + public ConfirmTossPaymentResponse confirm(ConfirmTossPaymentRequest request) { + return webClient.post() + .uri("/v1/payments/confirm") + .body(BodyInserters.fromValue(request)) + .retrieve() + .onStatus(HttpStatusCode::isError, response -> response.bodyToMono(ErrorResponse.class) + .flatMap(error -> Mono.error(new MoabamException(error.message())))) + .bodyToMono(ConfirmTossPaymentResponse.class) + .block(); + } +} diff --git a/src/main/java/com/moabam/api/presentation/PaymentController.java b/src/main/java/com/moabam/api/presentation/PaymentController.java index 715fac64..e294f6fc 100644 --- a/src/main/java/com/moabam/api/presentation/PaymentController.java +++ b/src/main/java/com/moabam/api/presentation/PaymentController.java @@ -9,6 +9,7 @@ import org.springframework.web.bind.annotation.RestController; import com.moabam.api.application.payment.PaymentService; +import com.moabam.api.dto.payment.ConfirmPaymentRequest; import com.moabam.api.dto.payment.PaymentRequest; import com.moabam.global.auth.annotation.Auth; import com.moabam.global.auth.model.AuthMember; @@ -25,9 +26,14 @@ public class PaymentController { @PostMapping("/{paymentId}") @ResponseStatus(HttpStatus.OK) - public void requestPayment(@Auth AuthMember member, - @PathVariable Long paymentId, + public void request(@Auth AuthMember member, @PathVariable Long paymentId, @Valid @RequestBody PaymentRequest request) { paymentService.request(member.id(), paymentId, request); } + + @PostMapping("/confirm") + @ResponseStatus(HttpStatus.OK) + public void confirm(@Auth AuthMember member, @Valid @RequestBody ConfirmPaymentRequest request) { + paymentService.confirm(member.id(), request); + } } diff --git a/src/main/java/com/moabam/global/config/TossPaymentConfig.java b/src/main/java/com/moabam/global/config/TossPaymentConfig.java new file mode 100644 index 00000000..e3f2bcca --- /dev/null +++ b/src/main/java/com/moabam/global/config/TossPaymentConfig.java @@ -0,0 +1,11 @@ +package com.moabam.global.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "payment.toss") +public record TossPaymentConfig( + String baseUrl, + String secretKey +) { + +} diff --git a/src/main/resources/config b/src/main/resources/config index 2e460460..ea25d857 160000 --- a/src/main/resources/config +++ b/src/main/resources/config @@ -1 +1 @@ -Subproject commit 2e460460a0048796a3ef6fef17697935027a8708 +Subproject commit ea25d85744c2e6fcedbdb66b34c08837d382814d diff --git a/src/main/resources/static/docs/coupon.html b/src/main/resources/static/docs/coupon.html index 60f27e8c..4b9cc040 100644 --- a/src/main/resources/static/docs/coupon.html +++ b/src/main/resources/static/docs/coupon.html @@ -461,7 +461,7 @@

요청

POST /admins/coupons HTTP/1.1
 Content-Type: application/json;charset=UTF-8
-Content-Length: 183
+Content-Length: 175
 Host: localhost:8080
 
 {
@@ -540,7 +540,7 @@ 

응답

Access-Control-Allow-Credentials: true Access-Control-Max-Age: 3600 Content-Type: application/json -Content-Length: 215 +Content-Length: 205 { "id" : 22, @@ -571,7 +571,7 @@

요청

POST /coupons/search HTTP/1.1
 Content-Type: application/json;charset=UTF-8
-Content-Length: 44
+Content-Length: 41
 Host: localhost:8080
 
 {
@@ -590,7 +590,7 @@ 

응답

Access-Control-Allow-Credentials: true Access-Control-Max-Age: 3600 Content-Type: application/json -Content-Length: 216 +Content-Length: 206 [ { "id" : 23, @@ -637,7 +637,7 @@

응답

Access-Control-Allow-Credentials: true Access-Control-Max-Age: 3600 Content-Type: application/json -Content-Length: 66 +Content-Length: 64 { "message" : "쿠폰 발급 가능 기간이 아닙니다." @@ -672,7 +672,7 @@

응답

Access-Control-Allow-Credentials: true Access-Control-Max-Age: 3600 Content-Type: application/json -Content-Length: 537 +Content-Length: 507 [ { "id" : 17, @@ -724,7 +724,7 @@

쿠폰 사용 (진행 중)

diff --git a/src/main/resources/static/docs/index.html b/src/main/resources/static/docs/index.html index cb72d35f..62f80fd6 100644 --- a/src/main/resources/static/docs/index.html +++ b/src/main/resources/static/docs/index.html @@ -616,7 +616,7 @@

diff --git a/src/main/resources/static/docs/notification.html b/src/main/resources/static/docs/notification.html index a06f23b3..d56f528e 100644 --- a/src/main/resources/static/docs/notification.html +++ b/src/main/resources/static/docs/notification.html @@ -513,7 +513,7 @@

응답

diff --git a/src/test/java/com/moabam/api/application/payment/PaymentServiceTest.java b/src/test/java/com/moabam/api/application/payment/PaymentServiceTest.java index f5599a33..c56e95bf 100644 --- a/src/test/java/com/moabam/api/application/payment/PaymentServiceTest.java +++ b/src/test/java/com/moabam/api/application/payment/PaymentServiceTest.java @@ -1,6 +1,8 @@ package com.moabam.api.application.payment; +import static com.moabam.support.fixture.CouponFixture.*; import static com.moabam.support.fixture.PaymentFixture.*; +import static com.moabam.support.fixture.ProductFixture.*; import static org.assertj.core.api.Assertions.*; import static org.mockito.BDDMockito.*; @@ -14,9 +16,16 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import com.moabam.api.application.bug.BugService; +import com.moabam.api.application.coupon.CouponService; +import com.moabam.api.domain.payment.Payment; +import com.moabam.api.domain.payment.PaymentStatus; import com.moabam.api.domain.payment.repository.PaymentRepository; -import com.moabam.api.domain.product.repository.ProductRepository; +import com.moabam.api.domain.payment.repository.PaymentSearchRepository; +import com.moabam.api.dto.payment.ConfirmPaymentRequest; import com.moabam.api.dto.payment.PaymentRequest; +import com.moabam.api.infrastructure.payment.TossPaymentService; +import com.moabam.global.error.exception.MoabamException; import com.moabam.global.error.exception.NotFoundException; @ExtendWith(MockitoExtension.class) @@ -26,18 +35,27 @@ class PaymentServiceTest { PaymentService paymentService; @Mock - ProductRepository productRepository; + BugService bugService; + + @Mock + CouponService couponService; + + @Mock + TossPaymentService tossPaymentService; @Mock PaymentRepository paymentRepository; + @Mock + PaymentSearchRepository paymentSearchRepository; + @DisplayName("결제를 요청한다.") @Nested - class RequestPayment { + class Request { @DisplayName("해당 결제 정보가 존재하지 않으면 예외가 발생한다.") @Test - void payment_not_found_exception() { + void not_found_exception() { // given Long memberId = 1L; Long paymentId = 1L; @@ -50,4 +68,60 @@ void payment_not_found_exception() { .hasMessage("존재하지 않는 결제 정보입니다."); } } + + @DisplayName("결제를 승인한다.") + @Nested + class Confirm { + + @DisplayName("해당 결제 정보가 존재하지 않으면 예외가 발생한다.") + @Test + void not_found_exception() { + // given + Long memberId = 1L; + ConfirmPaymentRequest request = confirmPaymentRequest(); + given(paymentSearchRepository.findByOrderId(request.orderId())).willReturn(Optional.empty()); + + // when, then + assertThatThrownBy(() -> paymentService.confirm(memberId, request)) + .isInstanceOf(NotFoundException.class) + .hasMessage("존재하지 않는 결제 정보입니다."); + } + + @DisplayName("쿠폰을 적용한 경우 쿠폰을 차감한 후 벌레를 충전한다.") + @Test + void use_coupon_success() { + // given + Long memberId = 1L; + Long couponWalletId = 1L; + Payment payment = paymentWithCoupon(bugProduct(), discount1000Coupon(), couponWalletId); + ConfirmPaymentRequest request = confirmPaymentRequest(); + given(paymentSearchRepository.findByOrderId(request.orderId())).willReturn(Optional.of(payment)); + given(tossPaymentService.confirm(confirmTossPaymentRequest())).willReturn(confirmTossPaymentResponse()); + + // when + paymentService.confirm(memberId, request); + + // then + verify(couponService, times(1)).use(memberId, couponWalletId); + verify(bugService, times(1)).charge(memberId, payment.getProduct()); + } + + @DisplayName("실패한다.") + @Test + void fail() { + // given + Long memberId = 1L; + Long couponWalletId = 1L; + Payment payment = paymentWithCoupon(bugProduct(), discount1000Coupon(), couponWalletId); + ConfirmPaymentRequest request = confirmPaymentRequest(); + given(paymentSearchRepository.findByOrderId(request.orderId())).willReturn(Optional.of(payment)); + given(tossPaymentService.confirm(any())).willThrow(MoabamException.class); + + // when + paymentService.confirm(memberId, request); + + // then + assertThat(payment.getStatus()).isEqualTo(PaymentStatus.ABORTED); + } + } } diff --git a/src/test/java/com/moabam/api/domain/bug/BugTest.java b/src/test/java/com/moabam/api/domain/bug/BugTest.java index 7e1981f0..96609913 100644 --- a/src/test/java/com/moabam/api/domain/bug/BugTest.java +++ b/src/test/java/com/moabam/api/domain/bug/BugTest.java @@ -68,9 +68,9 @@ void increase_bug_success() { Bug bug = bug(); // when - bug.increaseBug(BugType.MORNING, 5); - bug.increaseBug(BugType.NIGHT, 5); - bug.increaseBug(BugType.GOLDEN, 5); + bug.increase(BugType.MORNING, 5); + bug.increase(BugType.NIGHT, 5); + bug.increase(BugType.GOLDEN, 5); // then assertThat(bug.getMorningBug()).isEqualTo(MORNING_BUG + 5); diff --git a/src/test/java/com/moabam/api/domain/payment/PaymentTest.java b/src/test/java/com/moabam/api/domain/payment/PaymentTest.java index 8a2dfffb..b9ca3d9c 100644 --- a/src/test/java/com/moabam/api/domain/payment/PaymentTest.java +++ b/src/test/java/com/moabam/api/domain/payment/PaymentTest.java @@ -21,7 +21,7 @@ void validate_amount_exception() { .memberId(1L) .product(bugProduct()) .order(order(bugProduct())) - .amount(-1000); + .totalAmount(-1000); assertThatThrownBy(paymentBuilder::build) .isInstanceOf(BadRequestException.class) @@ -44,8 +44,8 @@ void success() { payment.applyCoupon(coupon, couponWalletId); // then - assertThat(payment.getAmount()).isEqualTo(BUG_PRODUCT_PRICE - 1000); - assertThat(payment.getCoupon()).isEqualTo(coupon); + assertThat(payment.getTotalAmount()).isEqualTo(BUG_PRODUCT_PRICE - 1000); + assertThat(payment.getDiscountAmount()).isEqualTo(coupon.getPoint()); assertThat(payment.getCouponWalletId()).isEqualTo(couponWalletId); } @@ -61,7 +61,7 @@ void discount_amount_greater() { payment.applyCoupon(coupon, couponWalletId); // then - assertThat(payment.getAmount()).isZero(); + assertThat(payment.getTotalAmount()).isZero(); } } diff --git a/src/test/java/com/moabam/api/infrastructure/payment/TossPaymentServiceTest.java b/src/test/java/com/moabam/api/infrastructure/payment/TossPaymentServiceTest.java new file mode 100644 index 00000000..e9a57d0e --- /dev/null +++ b/src/test/java/com/moabam/api/infrastructure/payment/TossPaymentServiceTest.java @@ -0,0 +1,92 @@ +package com.moabam.api.infrastructure.payment; + +import static com.moabam.support.fixture.PaymentFixture.*; +import static org.assertj.core.api.Assertions.*; + +import java.io.IOException; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.moabam.api.dto.payment.ConfirmTossPaymentRequest; +import com.moabam.api.dto.payment.ConfirmTossPaymentResponse; +import com.moabam.global.config.TossPaymentConfig; +import com.moabam.global.error.exception.MoabamException; + +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; + +@SpringBootTest +@ActiveProfiles("test") +class TossPaymentServiceTest { + + @Autowired + TossPaymentConfig config; + + @Autowired + ObjectMapper objectMapper; + + TossPaymentService tossPaymentService; + MockWebServer mockWebServer; + + @BeforeEach + public void setup() { + mockWebServer = new MockWebServer(); + tossPaymentService = new TossPaymentService( + new TossPaymentConfig(mockWebServer.url("/").toString(), config.secretKey()) + ); + tossPaymentService.init(); + } + + @AfterEach + public void tearDown() throws IOException { + mockWebServer.shutdown(); + } + + @DisplayName("결제 승인을 요청한다.") + @Nested + class Confirm { + + @DisplayName("성공한다.") + @Test + void success() throws Exception { + // given + ConfirmTossPaymentRequest request = confirmTossPaymentRequest(); + ConfirmTossPaymentResponse expected = confirmTossPaymentResponse(); + mockWebServer.enqueue(new MockResponse() + .setResponseCode(200) + .setBody(objectMapper.writeValueAsString(expected)) + .addHeader("Content-Type", "application/json")); + + // when + ConfirmTossPaymentResponse actual = tossPaymentService.confirm(request); + + // then + assertThat(actual).isEqualTo(expected); + } + + @DisplayName("예외가 발생한다.") + @Test + void exception() { + // given + ConfirmTossPaymentRequest request = confirmTossPaymentRequest(); + String jsonString = "{\"code\":\"NOT_FOUND_PAYMENT\",\"message\":\"존재하지 않는 결제 입니다.\"}"; + mockWebServer.enqueue(new MockResponse() + .setResponseCode(404) + .setBody(jsonString) + .addHeader("Content-Type", "application/json")); + + // when, then + assertThatThrownBy(() -> tossPaymentService.confirm(request)) + .isInstanceOf(MoabamException.class) + .hasMessage("존재하지 않는 결제 입니다."); + } + } +} diff --git a/src/test/java/com/moabam/api/presentation/PaymentControllerTest.java b/src/test/java/com/moabam/api/presentation/PaymentControllerTest.java index 536b4ab4..ee2080b9 100644 --- a/src/test/java/com/moabam/api/presentation/PaymentControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/PaymentControllerTest.java @@ -1,8 +1,11 @@ package com.moabam.api.presentation; +import static com.moabam.global.auth.model.AuthorizationThreadLocal.*; +import static com.moabam.support.fixture.MemberFixture.*; import static com.moabam.support.fixture.PaymentFixture.*; import static com.moabam.support.fixture.ProductFixture.*; import static org.assertj.core.api.Assertions.*; +import static org.mockito.BDDMockito.*; import static org.springframework.http.MediaType.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.*; @@ -12,19 +15,25 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import org.junit.jupiter.params.provider.NullAndEmptySource; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.web.servlet.MockMvc; import org.springframework.transaction.annotation.Transactional; import com.fasterxml.jackson.databind.ObjectMapper; +import com.moabam.api.application.member.MemberService; import com.moabam.api.domain.payment.Payment; +import com.moabam.api.domain.payment.PaymentStatus; import com.moabam.api.domain.payment.repository.PaymentRepository; import com.moabam.api.domain.product.Product; import com.moabam.api.domain.product.repository.ProductRepository; +import com.moabam.api.dto.payment.ConfirmPaymentRequest; import com.moabam.api.dto.payment.PaymentRequest; +import com.moabam.api.infrastructure.payment.TossPaymentService; import com.moabam.support.annotation.WithMember; import com.moabam.support.common.WithoutFilterSupporter; @@ -39,6 +48,12 @@ class PaymentControllerTest extends WithoutFilterSupporter { @Autowired ObjectMapper objectMapper; + @MockBean + MemberService memberService; + + @MockBean + TossPaymentService tossPaymentService; + @Autowired PaymentRepository paymentRepository; @@ -86,4 +101,53 @@ void bad_request_body_exception(String orderId) throws Exception { .andDo(print()); } } + + @Nested + @DisplayName("결제를 승인한다.") + class Confirm { + + @DisplayName("성공한다.") + @WithMember + @Test + void success() throws Exception { + // given + Long memberId = getAuthMember().id(); + Product product = productRepository.save(bugProduct()); + Payment payment = paymentRepository.save(payment(product)); + payment.request(ORDER_ID); + ConfirmPaymentRequest request = confirmPaymentRequest(); + given(tossPaymentService.confirm(confirmTossPaymentRequest())).willReturn(confirmTossPaymentResponse()); + given(memberService.getById(memberId)).willReturn(member()); + + // expected + mockMvc.perform(post("/payments/confirm") + .contentType(APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andDo(print()); + Payment actual = paymentRepository.findById(payment.getId()).orElseThrow(); + assertThat(actual.getStatus()).isEqualTo(PaymentStatus.DONE); + } + + @DisplayName("결제 승인 요청 바디가 유효하지 않으면 예외가 발생한다.") + @WithMember + @ParameterizedTest + @CsvSource(value = { + ", random_order_id_123, 2000", + "payment_key_123, , 2000", + "payment_key_123, random_order_id_123, -1000", + }) + void bad_request_body_exception(String paymentKey, String orderId, int amount) throws Exception { + // given + ConfirmPaymentRequest request = new ConfirmPaymentRequest(paymentKey, orderId, amount); + + // expected + mockMvc.perform(post("/payments/confirm") + .contentType(APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("올바른 요청 정보가 아닙니다.")) + .andDo(print()); + } + } } diff --git a/src/test/java/com/moabam/support/fixture/PaymentFixture.java b/src/test/java/com/moabam/support/fixture/PaymentFixture.java index a47080ae..a46e4b7b 100644 --- a/src/test/java/com/moabam/support/fixture/PaymentFixture.java +++ b/src/test/java/com/moabam/support/fixture/PaymentFixture.java @@ -1,19 +1,41 @@ package com.moabam.support.fixture; +import static com.moabam.support.fixture.ProductFixture.*; + +import java.time.LocalDateTime; + +import com.moabam.api.domain.coupon.Coupon; import com.moabam.api.domain.payment.Order; import com.moabam.api.domain.payment.Payment; +import com.moabam.api.domain.payment.PaymentStatus; import com.moabam.api.domain.product.Product; +import com.moabam.api.dto.payment.ConfirmPaymentRequest; +import com.moabam.api.dto.payment.ConfirmTossPaymentRequest; +import com.moabam.api.dto.payment.ConfirmTossPaymentResponse; public final class PaymentFixture { + public static final String PAYMENT_KEY = "payment_key_123"; public static final String ORDER_ID = "random_order_id_123"; + public static final int AMOUNT = 3000; public static Payment payment(Product product) { return Payment.builder() .memberId(1L) .product(product) .order(order(product)) - .amount(product.getPrice()) + .totalAmount(product.getPrice()) + .build(); + } + + public static Payment paymentWithCoupon(Product product, Coupon coupon, Long couponWalletId) { + return Payment.builder() + .memberId(1L) + .product(product) + .couponWalletId(couponWalletId) + .order(order(product)) + .totalAmount(product.getPrice()) + .discountAmount(coupon.getPoint()) .build(); } @@ -22,4 +44,31 @@ public static Order order(Product product) { .name(product.getName()) .build(); } + + public static ConfirmPaymentRequest confirmPaymentRequest() { + return ConfirmPaymentRequest.builder() + .paymentKey(PAYMENT_KEY) + .orderId(ORDER_ID) + .amount(AMOUNT) + .build(); + } + + public static ConfirmTossPaymentRequest confirmTossPaymentRequest() { + return ConfirmTossPaymentRequest.builder() + .paymentKey(PAYMENT_KEY) + .orderId(ORDER_ID) + .amount(AMOUNT) + .build(); + } + + public static ConfirmTossPaymentResponse confirmTossPaymentResponse() { + return ConfirmTossPaymentResponse.builder() + .paymentKey(PAYMENT_KEY) + .orderId(ORDER_ID) + .orderName(BUG_PRODUCT_NAME) + .status(PaymentStatus.DONE) + .totalAmount(AMOUNT) + .approvedAt(LocalDateTime.of(2023, 1, 1, 1, 1)) + .build(); + } } diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 3497c521..32bd980a 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -62,3 +62,9 @@ token: secret-key: testestestestestestestestestesttestestestestestestestestestest allow: "" + +# Payment +payment: + toss: + base-url: "https://api.tosspayments.com" + secret-key: "test_sk_4yKeq5bgrpWk4XYdDoBxVGX0lzW6:"