diff --git a/src/main/java/kr/bb/payment/controller/clientcontroller/OrderClientController.java b/src/main/java/kr/bb/payment/controller/clientcontroller/OrderClientController.java index 9406b38..447ae7c 100644 --- a/src/main/java/kr/bb/payment/controller/clientcontroller/OrderClientController.java +++ b/src/main/java/kr/bb/payment/controller/clientcontroller/OrderClientController.java @@ -7,6 +7,7 @@ import bloomingblooms.response.CommonResponse; import java.time.LocalDateTime; import java.util.List; +import kr.bb.payment.dto.request.KakaopayCancelRequestDto; import kr.bb.payment.service.KakaopayService; import kr.bb.payment.service.PaymentService; import lombok.RequiredArgsConstructor; @@ -47,4 +48,10 @@ CommonResponse> getPaymentInfo(@RequestBody List or CommonResponse getPaymentDate(@RequestParam String orderGroupId){ return CommonResponse.success(paymentService.getPaymentDate(orderGroupId)); } + + @PostMapping(value = "/cancel") + CommonResponse cancel(@RequestBody KakaopayCancelRequestDto cancelRequestDto){ + kakaopayService.cancelPayment(cancelRequestDto); + return CommonResponse.success(null); + } } diff --git a/src/main/java/kr/bb/payment/dto/request/KakaopayCancelRequestDto.java b/src/main/java/kr/bb/payment/dto/request/KakaopayCancelRequestDto.java new file mode 100644 index 0000000..63418eb --- /dev/null +++ b/src/main/java/kr/bb/payment/dto/request/KakaopayCancelRequestDto.java @@ -0,0 +1,15 @@ +package kr.bb.payment.dto.request; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class KakaopayCancelRequestDto { + private String orderId; + private Long cancelAmount; +} diff --git a/src/main/java/kr/bb/payment/dto/response/ApprovedCancelAmount.java b/src/main/java/kr/bb/payment/dto/response/ApprovedCancelAmount.java new file mode 100644 index 0000000..87528cd --- /dev/null +++ b/src/main/java/kr/bb/payment/dto/response/ApprovedCancelAmount.java @@ -0,0 +1,17 @@ +package kr.bb.payment.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ApprovedCancelAmount { // 이번 요청으로 취소된 금액 + private Integer total; + private Integer tax_free; + private Integer vat; + private Integer point; +} diff --git a/src/main/java/kr/bb/payment/dto/response/CancelAvailableAmount.java b/src/main/java/kr/bb/payment/dto/response/CancelAvailableAmount.java new file mode 100644 index 0000000..fce208e --- /dev/null +++ b/src/main/java/kr/bb/payment/dto/response/CancelAvailableAmount.java @@ -0,0 +1,14 @@ +package kr.bb.payment.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CancelAvailableAmount { // 남은 취소 가능 금액 + private Integer total; +} diff --git a/src/main/java/kr/bb/payment/dto/response/CanceledAmount.java b/src/main/java/kr/bb/payment/dto/response/CanceledAmount.java new file mode 100644 index 0000000..eb443cc --- /dev/null +++ b/src/main/java/kr/bb/payment/dto/response/CanceledAmount.java @@ -0,0 +1,14 @@ +package kr.bb.payment.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CanceledAmount { // 누계 취소된 금액 + private Integer total; +} diff --git a/src/main/java/kr/bb/payment/dto/response/KakaopayCancelResponseDto.java b/src/main/java/kr/bb/payment/dto/response/KakaopayCancelResponseDto.java new file mode 100644 index 0000000..d4fb8a8 --- /dev/null +++ b/src/main/java/kr/bb/payment/dto/response/KakaopayCancelResponseDto.java @@ -0,0 +1,25 @@ +package kr.bb.payment.dto.response; + +import java.time.LocalDateTime; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class KakaopayCancelResponseDto { + private String cid; + private String status; + private String partner_order_id; + private String partner_user_id; + private ApprovedCancelAmount approved_cancel_amount; // 금번 취소 금액 + private CanceledAmount canceled_amount; // 누적 취소 금액 + private CancelAvailableAmount cancel_available_amount; + private LocalDateTime created_at; + private LocalDateTime canceled_at; +} diff --git a/src/main/java/kr/bb/payment/service/KakaopayService.java b/src/main/java/kr/bb/payment/service/KakaopayService.java index c83a8a1..e243fdf 100644 --- a/src/main/java/kr/bb/payment/service/KakaopayService.java +++ b/src/main/java/kr/bb/payment/service/KakaopayService.java @@ -11,7 +11,10 @@ import java.util.List; import java.util.Map; import javax.validation.constraints.NotNull; +import kr.bb.payment.dto.request.KakaopayCancelRequestDto; import kr.bb.payment.dto.response.KakaopayApproveResponseDto; +import kr.bb.payment.dto.response.KakaopayCancelResponseDto; +import kr.bb.payment.entity.Payment; import kr.bb.payment.feign.DeliveryServiceClient; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; @@ -119,6 +122,23 @@ public void renewSubscription(SubscriptionBatchDtoList subscriptionBatchDtoList) } + public void cancelPayment(KakaopayCancelRequestDto cancelRequestDto){ + Payment paymentEntity = paymentService.getPaymentEntity(cancelRequestDto.getOrderId()); + + MultiValueMap parameters = new LinkedMultiValueMap<>(); + + parameters.add("cid", paymentEntity.getPaymentCid()); + parameters.add("tid", paymentEntity.getPaymentTid()); + parameters.add("cancel_amount", String.valueOf(cancelRequestDto.getCancelAmount())); + parameters.add("cancel_tax_free_amount", String.valueOf(0L)); + + HttpEntity> requestEntity = new HttpEntity<>(parameters, this.getHeaders()); + + String url = "https://kapi.kakao.com/v1/payment/cancel"; + + restTemplate.postForObject(url, requestEntity, KakaopayCancelResponseDto.class); + } + @NotNull private HttpHeaders getHeaders() { HttpHeaders headers = new HttpHeaders(); diff --git a/src/main/java/kr/bb/payment/service/PaymentService.java b/src/main/java/kr/bb/payment/service/PaymentService.java index 4a4bf6c..9f248e0 100644 --- a/src/main/java/kr/bb/payment/service/PaymentService.java +++ b/src/main/java/kr/bb/payment/service/PaymentService.java @@ -104,4 +104,9 @@ public String getPaymentDate(String orderGroupId){ } return ""; } + + @Transactional(readOnly = true) + public Payment getPaymentEntity(String orderGroupId){ + return paymentRepository.findByOrderId(orderGroupId); + } } diff --git a/src/test/java/kr/bb/payment/service/KakaopayCancelTest.java b/src/test/java/kr/bb/payment/service/KakaopayCancelTest.java new file mode 100644 index 0000000..92a8357 --- /dev/null +++ b/src/test/java/kr/bb/payment/service/KakaopayCancelTest.java @@ -0,0 +1,94 @@ +package kr.bb.payment.service; + +import bloomingblooms.domain.notification.order.OrderType; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import java.time.LocalDateTime; +import kr.bb.payment.dto.request.KakaopayCancelRequestDto; +import kr.bb.payment.dto.response.ApprovedCancelAmount; +import kr.bb.payment.dto.response.CancelAvailableAmount; +import kr.bb.payment.dto.response.CanceledAmount; +import kr.bb.payment.dto.response.KakaopayCancelResponseDto; +import kr.bb.payment.entity.Payment; +import kr.bb.payment.entity.PaymentStatus; +import kr.bb.payment.repository.PaymentRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.test.web.client.match.MockRestRequestMatchers; +import org.springframework.test.web.client.response.MockRestResponseCreators; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.client.RestTemplate; + +@SpringBootTest +@Transactional +public class KakaopayCancelTest { + @Autowired private RestTemplate restTemplate; + @Autowired private KakaopayService kakaopayService; + @Autowired private PaymentRepository paymentRepository; + private MockRestServiceServer mockServer; + + @BeforeEach + void setUp() throws Exception { + mockServer = MockRestServiceServer.createServer(restTemplate); + + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.registerModule(new JavaTimeModule()); + String responseJson = objectMapper.writeValueAsString(kakaopayCancelResponseDto()); + + mockServer + .expect(MockRestRequestMatchers.requestTo("https://kapi.kakao.com/v1/payment/cancel")) + .andExpect(MockRestRequestMatchers.method(HttpMethod.POST)) + .andRespond(MockRestResponseCreators.withSuccess(responseJson, MediaType.APPLICATION_JSON)); + } + + @Test + @DisplayName("카카오 결제 취소 테스트") + void cancelPay() { + // given + KakaopayCancelRequestDto cancelRequestDto = + KakaopayCancelRequestDto.builder().cancelAmount(2000L).orderId("orderGroupId").build(); + + Payment payment = Payment.builder() + .orderId("orderGroupId") + .orderType(OrderType.DELIVERY) + .paymentActualAmount(10000L) + .paymentCid("TC0ONETIME") + .paymentStatus(PaymentStatus.PENDING) + .paymentTid("T59eb9072dff7a6a6515") + .paymentType("MONEY") + .userId(1L) + .build(); + paymentRepository.save(payment); + + // when + kakaopayService.cancelPayment(cancelRequestDto); + + mockServer.verify(); + } + + private KakaopayCancelResponseDto kakaopayCancelResponseDto() { + ApprovedCancelAmount approvedCancelAmount = + ApprovedCancelAmount.builder().total(10000).tax_free(0).vat(0).point(0).build(); + CanceledAmount canceledAmount = CanceledAmount.builder().total(10000).build(); + CancelAvailableAmount cancelAvailableAmount = + CancelAvailableAmount.builder().total(40000).build(); + + return KakaopayCancelResponseDto.builder() + .cid("cid 번호") + .status("주문취소") + .partner_order_id("orderGroupId") + .partner_user_id("userId") + .approved_cancel_amount(approvedCancelAmount) + .canceled_amount(canceledAmount) + .cancel_available_amount(cancelAvailableAmount) + .created_at(LocalDateTime.now().minusDays(10)) + .canceled_at(LocalDateTime.now()) + .build(); + } +} diff --git a/src/test/java/kr/bb/payment/service/KakaopayReadyTest.java b/src/test/java/kr/bb/payment/service/KakaopayReadyTest.java index 1f319d5..ed96155 100644 --- a/src/test/java/kr/bb/payment/service/KakaopayReadyTest.java +++ b/src/test/java/kr/bb/payment/service/KakaopayReadyTest.java @@ -2,19 +2,45 @@ import bloomingblooms.domain.payment.KakaopayReadyRequestDto; import bloomingblooms.domain.payment.KakaopayReadyResponseDto; -import org.junit.jupiter.api.Assertions; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import java.time.LocalDateTime; +import kr.bb.payment.dto.response.Amount; +import kr.bb.payment.dto.response.KakaopayApproveResponseDto; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.test.web.client.match.MockRestRequestMatchers; +import org.springframework.test.web.client.response.MockRestResponseCreators; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.client.RestTemplate; @SpringBootTest @Transactional public class KakaopayReadyTest { + @Autowired private RestTemplate restTemplate; @Autowired private KakaopayService kakaopayService; + private MockRestServiceServer mockServer; + @BeforeEach + void setUp() throws Exception { + mockServer = MockRestServiceServer.createServer(restTemplate); + + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.registerModule(new JavaTimeModule()); + String responseJson = objectMapper.writeValueAsString(kakaopayReadyResponseDto()); + + mockServer + .expect(MockRestRequestMatchers.requestTo("https://kapi.kakao.com/v1/payment/ready")) + .andExpect(MockRestRequestMatchers.method(HttpMethod.POST)) + .andRespond(MockRestResponseCreators.withSuccess(responseJson, MediaType.APPLICATION_JSON)); + } @DisplayName("단건결제 준비 - 픽업") @DirtiesContext @Test @@ -34,8 +60,7 @@ void kakaopayReadyForDeliveryAndPickupTest() { // then KakaopayReadyResponseDto responseDto = kakaopayService.kakaoPayReady(DTO_1); - Assertions.assertEquals(20, responseDto.getTid().length()); - Assertions.assertTrue(responseDto.getNextRedirectPcUrl().startsWith("https://")); + mockServer.verify(); } @DisplayName("단건결제 준비 - 배송&구독") @@ -57,7 +82,23 @@ void kakaopayReadyForSubscriptionTest() { // then KakaopayReadyResponseDto responseDto2 = kakaopayService.kakaoPayReady(DTO_2); - Assertions.assertEquals(20, responseDto2.getTid().length()); - Assertions.assertTrue(responseDto2.getNextRedirectPcUrl().startsWith("https://")); + mockServer.verify(); + } + + KakaopayApproveResponseDto kakaopayReadyResponseDto() { + return KakaopayApproveResponseDto.builder() + .aid("고유번호") + .tid("tid고유번호") + .cid("cid번호") + .sid("sid") + .partnerOrderId("1") + .partnerUserId("1") + .paymentMethodType("MONEY") + .itemName("상품명") + .quantity(1) + .createdAt(LocalDateTime.now()) + .approvedAt(LocalDateTime.now()) + .amount(new Amount(1000, 0, 0, 0, 0)) + .build(); } }