토스페이먼츠 시리즈 (5) _ 카드 결제취소

2022. 4. 16. 13:00·프로젝트/토스페이먼츠 PG 연동 시리즈

이제 카드 결제는 완료하였다. 성공한 완료된 카드 결제들에 대해 이제 카드 결제취소를 구현해보자.

 

결제 취소를 위해서는 결제 승인 시 토스페이먼츠에게서 발급 받은 {paymentKey}와 최소 이유인 {cancelReason}이 필요하다. 이때 paymentKey는 요청 시 pathVariable로 추가하면 되고, cancelReason은 requestParameter로 추가해주면 된다.

 

물론 이 요청을 보낼 때도 이전에 결제 승인 요청 때 보낸것과 마찬가지로 HTTP 헤더에 시크릿키를 이용한 Basic Authorize를 설정해서 보내야 한다.


결제 취소 구현

Payment Controller

@PostMapping("/cancel")
@ApiOperation(value = "결제 취소", notes = "완료 된 결제 건에 대해서 결제취소를 요청합니다.")
public SingleResult<String> requestPaymentCancel(
    @ApiParam(value = "토스 측 주문 고유 번호", required = true) @RequestParam String paymentKey,
    @ApiParam(value = "결제 취소 사유", required = true) @RequestParam String cancelReason
) {
    try {
        return responseService.getSingleResult(paymentService.requestPaymentCancel(paymentKey, cancelReason));
    } catch (Exception e) {
        e.printStackTrace();
        throw new BussinessException(e.getMessage());
    }
}
  • 취소 요청을 받을 API를 생성해준다.
  • 현재는 테스트용이므로 String 문자열로 반환하도록 했다.

Payment Service

@Transactional
public String requestPaymentCancel(String paymentKey, String cancelReason) {
    RestTemplate rest = new RestTemplate();

    URI uri = URI.create(tossOriginUrl + paymentKey + "/cancel");

    HttpHeaders headers = new HttpHeaders();
    byte[] secretKeyByte = (testSecretApiKey + ":").getBytes(StandardCharsets.UTF_8);
    headers.setBasicAuth(new String(Base64.getEncoder().encode(secretKeyByte)));
    headers.setContentType(MediaType.APPLICATION_JSON);
    headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON));

    JSONObject param = new JSONObject();
    param.put("cancelReason", cancelReason);

    return rest.postForObject(
            uri,
            new HttpEntity<>(param, headers),
            String.class
    );
}
  • 요청을 보낼 URL / HTTP header / 전달할 파리미터 / 파라미터를 전달 받는 방식 등을 세팅해주고 요청을 보낸다.
  • 시크릿 키도 Basic64 방식으로 인코딩하여 헤더에 넣어주었다.
  • 파라미터는 JSONObject 방식으로 키:쌍 형태를 만들어서 보내주었다.

테스트

정상적으로 응답이오고 transactionKey도 들어있는것을 확인할 수 있다.

 

고민

이렇게 취소하는 것들을 결제 객체에 포함해야할지, 새로운 객체를 만들어서 결제에 OneToOne으로 갖게해야할지, 그게 아니라 회원과 OneToMany관계를 갖게해야될지 고민이 있었다.

 

하지만 결제 객체에 가지지 않을수도 있는 취소 데이터를 포함시켜서 NULL값이 있게 하는것은 좋지 않아보였고, 결제에 OneToOne관계를 갖게 하는건 결제를 조회할 때마다 fetchJoin을 써서 하던가 아니면 1+N쿼리 문제를 가지고 가야 하는 문제가 있었다.

 

따라서 최종적으론 회원과 결제취소를 OneToMany관계를 갖게 하도록 결정하였고 결제 취소 테이블을 새롭게 만들기로 하였다. 그럼 이제 취소 내역을 DB에 저장할 수 있도록 변경하고, 응답 결과도 이전에 만들어놓은 DTO를 이용해서 깔끔하게 보이도록 변경해보자.

결제 취소 구현 2

CancelPayment Controller

@Api(tags = "13. 결제 취소")
@RequestMapping("/v1/api/cancelPayment")
@RestController
@RequiredArgsConstructor
public class CancelPaymentController {

	private final ResponseService responseService;
	private final CancelPaymentService cancelPaymentService;
	private final int FAIL = -1;

	@PostMapping
	@ApiOperation(value = "결제 취소", notes = "완료 된 결제 건에 대해서 결제취소를 요청합니다.")
	public CommonResult requestPaymentCancel(
			@ApiParam(value = "토스 측 주문 고유 번호", required = true) @RequestParam String paymentKey,
			@ApiParam(value = "결제 취소 사유", required = true) @RequestParam String cancelReason
	) {
		boolean result = cancelPaymentService.requestPaymentCancel(paymentKey, cancelReason);
		if (result) {
			return responseService.getSuccessResult();
		} else return responseService.getFailResult(
				FAIL,
				ExMessage.PAYMENT_CANCEL_ERROR_FAIL.getMessage()
		);
	}
}
  • 일단 결제 객체와 결제 취소내역 객체를 구분하였으므로 Controller도 구분하였다.

CancelPayment Entity

@Entity
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class CancelPayment {
	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	@Column(name = "seq", nullable = false)
	private Long seq;

	@Column(nullable = false)
	private String orderId;

	@Column(nullable = false)
	private String paymentKey;

	// 생략.. 취소 관련 데이터

	@Setter
	@ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.PERSIST)
	private Member customer;
}
  • 결제취소를 담을 객체를 생성해주었다.

PaymentResHandleDto (추가)

// 생략.. 기존과 동일
public CancelPayment toCancelPayment() {
    return CancelPayment.builder()
            .orderId(orderId)
            .orderName(orderName)
            .paymentKey(paymentKey)
            .requestedAt(requestedAt)
            .approvedAt(approvedAt)
            .cardCompany(card.getCompany())
            .cardNumber(card.getNumber())
            .receiptUrl(card.getReceiptUrl())
            .cancelAmount(cancels[0].getCancelAmount())
            .cancelDate(cancels[0].getCanceledAt())
            .cancelReason(cancels[0].getCancelReason())
            .build();
}
  • 결제취소시 반환받은 값을 PaymentResHandleDto 객체로 변환하고, 이를 다시 CancelPayment로 변환하기위해 메소드를 추가해주었다.

CancelPayment Repository

@Repository
public interface CancelPaymentRepository extends JpaRepository<CancelPayment, Long> {
	Optional<CancelPayment> findByPaymentKey(String orderId);
}

CancelPayment Service

@Slf4j
@Service
@RequiredArgsConstructor
public class CancelPaymentService {

	private final PaymentRepository paymentRepository;

	@Value("${payments.toss.test_client_api_key}")
	private String testClientApiKey;

	@Value("${payments.toss.test_secret_api_key}")
	private String testSecretApiKey;

	@Value("${payments.toss.origin_url}")
	private String tossOriginUrl;

	@Transactional
	public boolean requestPaymentCancel(String paymentKey, String cancelReason) {
		RestTemplate rest = new RestTemplate();

		URI uri = URI.create(tossOriginUrl + paymentKey + "/cancel");

		HttpHeaders headers = new HttpHeaders();
		byte[] secretKeyByte = (testSecretApiKey + ":").getBytes(StandardCharsets.UTF_8);
		headers.setBasicAuth(new String(Base64.getEncoder().encode(secretKeyByte)));
		headers.setContentType(MediaType.APPLICATION_JSON);
		headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON));

		JSONObject param = new JSONObject();
		param.put("cancelReason", cancelReason);

		PaymentResHandleDto paymentCancelResDto;

		try {
			paymentCancelResDto = rest.postForObject(
					uri,
					new HttpEntity<>(param, headers),
					PaymentResHandleDto.class
			);
		} catch (Exception e) {
			throw new BussinessException(e.getMessage().split(": ")[1]);
		}

		if (paymentCancelResDto == null) return false;

		Long cancelAmount = paymentCancelResDto.getCancels()[0].getCancelAmount();
		try {
			paymentRepository
					.findByPaymentKey(paymentKey)
					.filter(P -> P.getAmount().equals(cancelAmount))
					.orElseThrow(() -> new BussinessException(ExMessage.PAYMENT_ERROR_ORDER_NOTFOUND))
					.getCustomer()
					.addCancelPayment(paymentCancelResDto.toCancelPayment());
			return true;
		} catch (Exception e) {
			throw new BussinessException(ExMessage.DB_ERROR_SAVE);
		}
	}
}
  • 새롭게 결제취소를 요청하고, 성공적이라면 --> 결제내역을 갖고 있는 고객에게 취소내역을 추가해주었다.
    CascadeType이 PERSIST 이므로 자동적으로 엔티티매니저에 추가가 되어 트랜잭션 종료시 같이 flush되어 DB에 적용이 될 것이다.
  • 요청은 이전 [최종결제승인요청]과 비슷하다.

❗️참고사항❗️

PaymentResHandleDto 이 객체는 결제요청 / 결제취소 시 반환받는 문자열을 처리하는 DTO였다. 이때 card정보는 단일객체값으로 전달되었으므로 변수를 통해 바로 받으면 됐다.

하지만, 결제취소 관련 정보를 갖는 cancels 필드는 Array(배열)이다.
따라서 "PaymentResHandleCancelDto[] cancels; // : 결제 취소 이력 관련 객체"
이런식으로 배열로 받아줘야 한다.

참고로 필자는 부분취소 개념은 없으므로 취소이력이 여러개 쌓일 일이 없어서 인덱스0으로 항상 접근하도록 해놓았다. 이부분은 상황에 맞게 수정하도록 하자.

 

결과

요청 및 응답 확인

DB 취소 내역 저장 확인

[
  {
    "seq": 1,
    "approved_at": "2022-03-18T22:52:16+09:00",
    "cancel_amount": 3000,
    "cancel_date": "2022-03-20T21:20:22+09:00",
    "cancel_reason": "단순 변심",
    "card_company": "국민",
    "card_number": "5570************",
    "order_id": "36a7a916-5aa3-41d0-8321-888cf542fc10",
    "order_name": --상품명-
    "payment_key": --페이먼트키--
    "receipt_url": "https://dashboard.tosspayments.com/sales-slip?transactionId=1XKHbawlO%2B9xTnsg7kf0Sfe2k4Y9Nm0LoJyxdBU9H7OtF8yB5gnXKTrhRssJxhTo&ref=PX",
    "requested_at": "2022-03-18T22:51:57+09:00",
    "customer_seq": 7
  }
]

 

 

이렇게 결제취소까지 완료하였다. 다양한 추가적인 API들이 존재하지만 일단 토스페이먼츠를 이용한 결제신청/결제취소는 여기까지로 마무리하고 다음 포스트에서는 금융결제원을 통한 계좌번호 검증을 구현하겠다.

 

 


Reference

https://docs.tosspayments.com/guides/apis/cancel-payment

 

 

 

반응형
저작자표시 비영리 변경금지 (새창열림)

'프로젝트 > 토스페이먼츠 PG 연동 시리즈' 카테고리의 다른 글

토스페이먼츠 시리즈 (7) _ 가상계좌 결제 1  (0) 2022.04.24
토스페이먼츠 시리즈 (6) _ 금융결제원 계좌실명조회  (10) 2022.04.20
토스페이먼츠 시리즈 (4) _ 카드 결제 3  (3) 2022.04.12
토스페이먼츠 시리즈 (3) _ 카드 결제 2  (12) 2022.04.08
토스페이먼츠 시리즈 (2) _ 카드 결제 1  (25) 2022.04.04
'프로젝트/토스페이먼츠 PG 연동 시리즈' 카테고리의 다른 글
  • 토스페이먼츠 시리즈 (7) _ 가상계좌 결제 1
  • 토스페이먼츠 시리즈 (6) _ 금융결제원 계좌실명조회
  • 토스페이먼츠 시리즈 (4) _ 카드 결제 3
  • 토스페이먼츠 시리즈 (3) _ 카드 결제 2
구름뭉치
구름뭉치
구름의 개발일기장
  • 구름뭉치
    구름 개발일기장
    구름뭉치
  • 전체
    오늘
    어제
    • ALL (283)
      • 프로젝트 (23)
        • 토스페이먼츠 PG 연동 시리즈 (12)
        • JWT 방식 인증&인가 시리즈 (6)
        • 스우미 웹 애플리케이션 프로젝트 (1)
        • 스프링부트 기본 보일러 플레이트 구축 시리즈 (2)
        • 마이크로서비스 아키텍쳐 시리즈 (1)
      • 스프링 (43)
        • 스프링부트 API 설계 정리 (8)
        • 스프링부트 RestAPI 프로젝트 (18)
        • 스프링부트 WebSocket 적용기 (3)
        • 스프링 JPA 정리 시리즈 (5)
        • 스프링 MVC (5)
        • 스프링 배치 (2)
        • 토비의 스프링 정리 (2)
      • 기술 학습 (28)
        • 아파치 카프카 (9)
        • 클린 코드 (4)
        • 디자인 패턴의 아름다움 (2)
        • 모던 자바 인 액션 (7)
        • JVM 스레드 딥다이브 (6)
      • Web (25)
        • 정리글 (20)
        • GraphQL 정리글 (2)
        • Jenkins 정리글 (3)
      • 취업 (6)
      • CS (77)
        • 네트워크 전공 수업 정리 (11)
        • OSI 7계층 정리 (12)
        • 운영체제 정리 (19)
        • 데이터베이스 정리 (5)
        • MySql 정리 (17)
        • GoF의 Design Pattern 정리 (12)
      • 알고리즘 (70)
        • 백준 (56)
        • 프로그래머스 (12)
        • 알고리즘 정리본 (1)
      • 기초 지식 정리 (2)
      • 일상 (8)
  • 블로그 메뉴

    • 홈
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    크로아티아
    마우스 패드
    마우스
    레이저
    류블라냐
    동유럽
    키보드 손목 받침대
    mx master s3 for mac
    부다페스트
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.6
구름뭉치
토스페이먼츠 시리즈 (5) _ 카드 결제취소
상단으로

티스토리툴바