결제 바로가기 클릭
- 입찰 참여 페이지
- 모집 게시글 작성시 포인트 확인 후 글 작성
SDK를 추가한 뒤 클라이언트 키를 사용해 객체를 초기화
결제창 호출에 사용되는 메서드는 requestPayment()입니다.
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" class="no-js" lang="ko">
<head>
<meta charset="utf-8"/>
<meta http-equiv="x-ua-compatible" content="ie=edge"/>
<title>나의 현재 잔여포인트</title>
<meta name="description" content=""/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<link rel="shortcut icon" type="image/x-icon" th:href="@{assets/images/favicon.svg}"/>
<!-- Place favicon.ico in the root directory -->
<!-- Web Font -->
<link
th:href="@{https://fonts.googleapis.com/css2?family=Jost:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap}"
rel="stylesheet">
<link th:href="@{https://fonts.googleapis.com/css2?family=Lato&display=swap}" rel="stylesheet">
<link rel="stylesheet" th:href="@{https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.1/font/bootstrap-icons.css}">
<!-- ========================= CSS here ========================= -->
<link th:href="@{/awesome/css/bootstrap.min.css}" rel="stylesheet" type="text/css">
<link th:href="@{/awesome/css/LineIcons.2.0.css}" rel="stylesheet" type="text/css">
<link th:href="@{/awesome/css/animate.css}" rel="stylesheet" type="text/css">
<link th:href="@{/awesome/css/tiny-slider.css}" rel="stylesheet" type="text/css">
<link th:href="@{/awesome/css/glightbox.min.css}" rel="stylesheet" type="text/css">
<link th:href="@{/awesome/css/main.css}" rel="stylesheet" type="text/css">
<link th:href="@{/awesome/css/main-custom.css}" rel="stylesheet" type="text/css">
<link th:href="@{/awesome/css/apply-bidding-detail.css}" rel="stylesheet" type="text/css">
<script src="https://js.tosspayments.com/v1/payment"></script>
</head>
<body>
<!-- Start Header Area -->
<div th:replace="fragments/header :: header"/>
<!-- End Header Area -->
<!-- Start Breadcrumbs -->
<div class="breadcrumbs">
<div class="container">
<div class="row align-items-center">
<div class="col-lg-6 col-md-6 col-12">
<div class="breadcrumbs-content">
<h1 class="page-title">현재 잔여포인트</h1>
</div>
</div>
</div>
</div>
</div>
<!-- End Breadcrumbs -->
<!-- Start Contact Area -->
<section id="contact-us" class="contact-us section">
<div class="container">
<div class="contact-head wow fadeInUp" data-wow-delay=".4s">
<div class="">
<div class="">
<div class="single-head t-center">
<div class="contant-inner-title">
<h2>참여 정보</h2>
<span>
<span>OrderNumber:</span>
<span id="orderId">POST-C-ORDER-2023-02-12-07661e23-e4df-47b6-be86-3867a13fa7bc</span>
</span><br>
<span>
<span>참여게시물 타이틀:</span>
<span id="orderName">샐러드 재료 파프리카 모집합니다.</span>
</span><br>
<span>
<span>참여자:</span>
<span id="customerName">이기업</span>
</span>
</div>
<div class="contant-inner-title">
<h2>현재 잔여포인트 확인</h2>
<span>현재 잔여포인트:</span>
<span id="total-amount">0</span><br>
<span>부족한 포인트:</span>
<span id="amount">2800000</span>
</div>
</div>
<div class="d-grid gap-2 m-t-20">
<p>앗! 포인트 충전이 필요합니다!</p>
<button id="payment_card_button" class="btn btn-warning size-h-65 payment-btn" type="submit">
포인트 충전 바로가기
</button>
</div>
</div>
</div>
</div>
</section>
<!--/ End Contact Area -->
<!-- Start Footer Area -->
<div th:replace="fragments/footer :: footer"/>
<!--/ End Footer Area -->
<!-- ========================= scroll-top ========================= -->
<a href="#" class="scroll-top btn-hover">
<i class="lni lni-chevron-up"></i>
</a>
<!-- ========================= JS here ========================= -->
<script th:src="@{/awesome/js/bootstrap.min.js}"></script>
<script th:src="@{/awesome/js/wow.min.js}"></script>
<script th:src="@{/awesome/js/tiny-slider.js}"></script>
<script th:src="@{/awesome/js/glightbox.min.js}"></script>
<script th:src="@{/awesome/js/main.js}"></script>
</body>
<script type="text/javascript" th:inline="javascript">
var clientKey = 'test_ck_D5GePWvyJnrK0W0k6q8gLzN97Eoq';
var tossPayments = TossPayments(clientKey);
var button = document.getElementById('payment_card_button');
let amount = document.getElementById('amount').innerText;
let orderId = document.getElementById('orderId').innerText;
let orderName = document.getElementById('orderName').innerText;
let customerName = document.getElementById('customerName').innerText;
button.addEventListener('click', function () {
tossPayments.requestPayment('카드', { // 결제 수단 파라미터
amount: amount,
orderId: orderId,
orderName: orderName,
customerName: customerName,
successUrl: 'http://localhost:8080/success',
failUrl: 'http://localhost:8080/fail',
})
.catch(function (error) {
if (error.code === 'USER_CANCEL') {
// 결제 고객이 결제창을 닫았을 때 에러 처리
} else if (error.code === 'INVALID_CARD_COMPANY') {
// 유효하지 않은 카드 코드에 대한 에러 처리
}
})
});
</script>
</html>
tossPayments.requestPayment('카드', { // 결제 수단 파라미터
// 결제 정보 파라미터
amount: 15000,
orderId: '4ossyovuj3gfx3RJHFnaT',
orderName: '토스 티셔츠 외 2건',
customerName: '박토스',
successUrl: 'http://localhost:8080/success',
failUrl: 'http://localhost:8080/fail',
})
.catch(function (error) {
if (error.code === 'USER_CANCEL') {
// 결제 고객이 결제창을 닫았을 때 에러 처리
} else if (error.code === 'INVALID_CARD_COMPANY') {
// 유효하지 않은 카드 코드에 대한 에러 처리
}
})
성공하거나 실패하면 위에서 설정한 successUrl, failUrl로 이동
결제 성공 리다이렉트 URL에는 paymentKey, orderId, amount 세 가지 쿼리 파라미터가 들어있습니다.
//성공
https://{ORIGIN}/success?paymentKey={PAYMENT_KEY}&orderId={ORDER_ID}&amount={AMOUNT}
//실패
https://{ORIGIN}/fail?code={ERROR_CODE}&message={ERROR_MESSAGE}&orderId={ORDER_ID}
파라미터가 API로 정확히 정해져있고, 3개 미만이기 때문에 코드 가독성이 좋은 requestParam으로 받았다
@ApiOperation(value = "결제 요청 성공, API Redirect Url"
, notes = "결제 요청 금액 일치여부 확인 후 이체 API 호출/ 결제 정보 저장 후 사용자 포인트 및 예치금 상태를 업데이트 한다.")
@RequestMapping(value = "/success")
public ResponseEntity<Result> paymentSuccess(@RequestParam("paymentKey") String paymentKey
, @RequestParam("orderId") String orderId
, @RequestParam("amount") Long amount) throws IOException, InterruptedException {
paymentConfirmService.verifySuccessRequest(orderId, amount);
PaymentCardResponse paymentCardResponse = paymentConfirmService.requestFinalPayment(paymentKey, orderId, amount);
PointEventDetailResponse pointEventDetailResponse = pointManagerService.savePaymentAndPoint(paymentCardResponse);
UserPointResponse userPointResponse = userPointService.checkUserPointInfo(pointEventDetailResponse.getPointUserEmail());
return ResponseEntity
.ok()
.body(Result.success(new PaymentToPointResponse(paymentCardResponse, pointEventDetailResponse, userPointResponse)));
}
log
실패 파라미터
결제 요청 실패 했을 때
@ApiOperation(value = "결제 요청 실패, API Redirect Url"
, notes = "결제 요청 실패시 에러를 반환한다.")
@RequestMapping("/fail")
public Result transferFail(@Valid PaymentErrorResponse paymentErrorResponse) {
return Result.error(paymentErrorResponse);
}
@Data
public class PaymentErrorResponse {
private String code;
private String message;
private String orderId;
}
결제창을 열 때 requestPayment() 메서드에 담아 보냈던 amount 값과 리다이렉트 URL에 있는 실제 결제 금액인 amount 값이 같은지 확인해보세요.
@ApiOperation(value = "결제 요청 성공, API Redirect Url"
, notes = "결제 요청 금액 일치여부 확인 후 이체 API 호출/ 결제 정보 저장 후 사용자 포인트 및 예치금 상태를 업데이트 한다.")
@RequestMapping(value = "/success")
public ResponseEntity<Result> paymentSuccess(@RequestParam("paymentKey") String paymentKey
, @RequestParam("orderId") String orderId
, @RequestParam("amount") Long amount) throws IOException, InterruptedException {
paymentConfirmService.verifySuccessRequest(orderId, amount);
(...) 검증 메서드 이외 제거
return ResponseEntity
.ok()
.body(Result.success(new PaymentToPointResponse(paymentCardResponse, pointEventDetailResponse, userPointResponse)));
}
@Transactional(readOnly = true, timeout = 2)
public void verifySuccessRequest(String orderId, Long requestOrderAmount) {
userPaymentOrderJpaRepository.findByPostOrderId(orderId)
.filter(userPaymentOrder -> userPaymentOrder.getPaymentOrderAmount().equals(requestOrderAmount))
.orElseThrow(() -> {
throw new AwesomeVegeAppException(AppErrorCode.INVOICE_AMOUNT_MISMATCH
, AppErrorCode.INVOICE_AMOUNT_MISMATCH.getMessage());
});
}
paymentConfirmService.verifySuccessRequest() 메서드로 금액 검증이 완료되면,
requestFinalPayment() 메서드를 통해 결제 요청을 진행합니다
@ApiOperation(value = "결제 요청 성공, API Redirect Url"
, notes = "결제 요청 금액 일치여부 확인 후 이체 API 호출/ 결제 정보 저장 후 사용자 포인트 및 예치금 상태를 업데이트 한다.")
@RequestMapping(value = "/success")
public ResponseEntity<Result> paymentSuccess(@RequestParam("paymentKey") String paymentKey
, @RequestParam("orderId") String orderId
, @RequestParam("amount") Long amount) throws IOException, InterruptedException {
paymentConfirmService.verifySuccessRequest(orderId, amount);
PaymentCardResponse paymentCardResponse = paymentConfirmService.requestFinalPayment(paymentKey, orderId, amount);
(...) 이외 하단 메서드 삭제
return ResponseEntity
.ok()
.body(Result.success(new PaymentToPointResponse(paymentCardResponse, pointEventDetailResponse, userPointResponse)));
}
POST /v1/payments/confirm
paymentKey에 해당하는 결제를 검증하고 승인합니다.
- 결제 승인 엔드포인트가 /v1/payments/{paymentKey}에서 /v1/payments/confirm으로 변경되었습니다. 이전 엔드포인트는 사용을 권장하지 않습니다.
//토스 테스트용 시크릿키
test_sk_zXLkKEypNArWmo50nX3lmeaxYG5R
public class PaymentConfirmService {
@Value("${toss.api.testSecretApiKey}")
private String testSecretApiKey;
private final ObjectMapper objectMapper;
@Transactional(timeout = 300, rollbackFor = Exception.class)
public PaymentCardResponse requestFinalPayment(String paymentKey, String orderId, Long amount) throws IOException, InterruptedException {
testSecretApiKey = testSecretApiKey + ":";
String authKey = new String(Base64.getEncoder().encode(testSecretApiKey.getBytes(StandardCharsets.UTF_8)));
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.tosspayments.com/v1/payments/confirm"))
.header("Authorization", "Basic " + authKey)
.header("Content-Type", "application/json")
.method("POST"
, HttpRequest
.BodyPublishers
.ofString("{\"paymentKey\":\"" + paymentKey + "\",\"amount\":\"" + amount + "\",\"orderId\":\"" + orderId + "\"}")
).build();
HttpResponse<String> response = HttpClient
.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());
return objectMapper.readValue(response.body(), PaymentCardResponse.class);
}
}
toss:
api:
testSecretApiKey: test_sk_zXLkKEypNArWmo50nX3lmeaxYG5R
HttpResponse에 String 타입의 Json 문자를 반환받습니다.
이때 ObjectMapper를 사용하여 java Object로 변경해줍니다
링크: json 중첩 데이터 객체로 변환
package com.i5e2.likeawesomevegetable.payment.api.dto;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;
@ToString
@Getter
@AllArgsConstructor
@NoArgsConstructor
public class PaymentCardResponse {
private String paymentKey; //결제 키 값
private String type; //결제 타입 정보 NORMAL(일반 결제), BILLING(자동 결제), BRANDPAY(브랜드페이)
private String orderId; //주문 ID
private String orderName; //주문명
private String method; //결제 수단
private Long totalAmount; //총 결제 금액
private Long balanceAmount; //취소할 수 있는 잔고
private String status; //결제 처리 상태
private String requestedAt; //결제가 일어난 날짜와 시간
private String approvedAt; //결제 승인이 일어난 날짜와 시간
private String lastTransactionKey; //마지막 거래 키 값
private Long vat; //부가세
private boolean isPartialCancelable; //부분 취소 가능 여부
private Card card; //카드 정보
private Receipt url; //영수증 확인 주소
}
package com.i5e2.likeawesomevegetable.payment.api.dto;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;
@ToString
@Getter
@AllArgsConstructor
@NoArgsConstructor
public class Card {
private Long amount; //카드로 결제한 금액
private String issuerCode; //카드사 숫자 코드
private String acquirerCode; //카드 매입사 숫자 코드
private String number; //카드 번호
private Integer installmentPlanMonths; //할부 개월 수
private String approveNo; //카드사 승인 번호
private boolean useCardPoint; //카드사 포인트 사용여부
private String cardType; //카드 종류 신용,체크,기프트
private String ownerTYpe; //카드 소유자 타입 개인,법인
private String acquireStatus; //카드 결제 매입 상태
private boolean isInterestFree; //무이자 할부 적용 여부
private String interestPayer; //할부 수수료 부담 주체
}
데이터 추출을 하지 않을 경우 아래와 같이 데이터를 반환 받을 수 있고,
데이터를 객체화하여 특정 데이터만 사용하는 경우 클래스를 새로 생성합니다.
{
"mId": "tvivarepublica",
"lastTransactionKey": "5A42689F7164399C42C8B04DA9E0DCAE",
"paymentKey": "vdX0wJDpj5mBZ1gQ4YVXgGNnpzobW23l2KPoqNbMGOkn9EW7",
"orderId": "QaFu0UOiscew0YSBbDksK",
"orderName": "비트 1톤 입찰합니다",
"taxExemptionAmount": 0,
"status": "DONE",
"requestedAt": "2023-01-31T13:50:35+09:00",
"approvedAt": "2023-01-31T13:59:59+09:00",
"useEscrow": false,
"cultureExpense": false,
"card": {
"company": "농협",
"issuerCode": "91",
"acquirerCode": "91",
"number": "54258604****109*",
"installmentPlanMonths": 0,
"isInterestFree": false,
"interestPayer": null,
"approveNo": "00000000",
"useCardPoint": false,
"cardType": "신용",
"ownerType": "개인",
"acquireStatus": "READY",
"receiptUrl": "https://dashboard.tosspayments.com/sales-slip?transactionId=Fzh38LC1YWbaitqmgNesV%2FLgBG8m2nTo7OT67Pp41j4aGL2oyZMRqv8fngXfWZ9C&ref=PX",
"provider": null,
"amount": 15000
},
"virtualAccount": null,
"transfer": null,
"mobilePhone": null,
"giftCertificate": null,
"cashReceipt": null,
"discount": null,
"cancels": null,
"secret": "ps_vZnjEJeQVxn6bQGQ7EGd8PmOoBN0",
"type": "NORMAL",
"easyPay": null,
"easyPayAmount": 0,
"easyPayDiscountAmount": 0,
"country": "KR",
"failure": null,
"isPartialCancelable": true,
"receipt": {
"url": "https://dashboard.tosspayments.com/sales-slip?transactionId=jWORm0D5euQQ12rKPCmx3hOmuoz6Muo89xwBZzQJlW8FfNqHZrYk%2F8j6kF%2FE4Ygz&ref=PX"
},
"checkout": {
"url": "https://api.tosspayments.com/v1/payments/vdX0wJDpj5mBZ1gQ4YVXgGNnpzobW23l2KPoqNbMGOkn9EW7/checkout"
},
"transactionKey": "5A42689F7164399C42C8B04DA9E0DCAE",
"currency": "KRW",
"totalAmount": 15000,
"balanceAmount": 15000,
"suppliedAmount": 13636,
"vat": 1364,
"taxFreeAmount": 0,
"method": "카드",
"version": "1.4"
}
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@Slf4j
@RestController
public class PaymentConfirmController {
@RequestMapping(value = "/success", method = RequestMethod.GET)
public String paymentSuccess(@RequestParam("paymentKey") String paymentKey
, @RequestParam("orderId") String orderId
, @RequestParam("amount") Long amount) {
log.info("paymentKey: {}, orderId:{}, amount:{} ", paymentKey, orderId, amount);
return "";
}
}
승인된 결제를 취소하려면 결제 승인 API 요청 결과로 발급 받은 paymentKey와 결제 취소 이유를 담은 cancelReason이 필요합니다.
결제 취소 API 엔드포인트에 paymentKey를 Path 파라미터로 추가하고 cancelReason은 요청 본문에 추가해주세요.
: SDK, 시크릿키 설정 등은 위에서 설명한 것과 동일하기 때문에 설명 제외했습니다
@ApiOperation(value = "전액 환불 요청 성공, API Redirect Url"
, notes = "환불 요청 금액 일치여부 확인 후 이체 API 호출/ 환불 정보 저장 후 사용자 포인트 및 예치금 상태를 업데이트 한다.")
@RequestMapping("/refund/success")
public ResponseEntity<Result> paymentRefund(@RequestParam("paymentKey") String paymentKey
, @RequestParam("cancelReason") String cancelReason
, @RequestParam("cancelUserEmail") String cancelUserEmail) throws IOException, InterruptedException {
//TODO: 하나의 트랜젝션으로 관리해야한다 결제와 포인트 연결이기 때문에
PaymentRefundResponse paymentRefundResponse = paymentConfirmService.requestRefundPayment(cancelReason, paymentKey);
PointEventDetailResponse pointCancelDetailResponse = pointManagerService.cancelPaymentAndPoint(paymentRefundResponse);
UserPointResponse userPointResponse = userPointService.refundPoint(paymentRefundResponse, cancelUserEmail);
return ResponseEntity
.ok()
.body(Result.success(new PaymentToCancelResponse(paymentRefundResponse, pointCancelDetailResponse, userPointResponse)));
}
@Slf4j
@Service
@RequiredArgsConstructor
public class PaymentConfirmService {
@Value("${toss.api.testSecretApiKey}")
private String testSecretApiKey;
private final UserPaymentOrderJpaRepository userPaymentOrderJpaRepository;
private final ObjectMapper objectMapper;
@Transactional(timeout = 300, rollbackFor = Exception.class)
public PaymentRefundResponse requestRefundPayment(String cancelReason, String paymentKey) throws IOException, InterruptedException {
testSecretApiKey = testSecretApiKey + ":";
String authKey = new String(Base64.getEncoder().encode(testSecretApiKey.getBytes(StandardCharsets.UTF_8)));
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.tosspayments.com/v1/payments/" + paymentKey + "/cancel"))
.header("Authorization", "Basic " + authKey)
.header("Content-Type", "application/json")
.method("POST"
, HttpRequest
.BodyPublishers
.ofString("{\"cancelReason\":\"" + cancelReason + "\"}"))
.build();
HttpResponse<String> response = HttpClient
.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());
log.info("response:{}", response.body());
return objectMapper.readValue(response.body(), PaymentRefundResponse.class);
}
}
{
"mId": "tosspayments",
"version": "2022-11-16",
"lastTransactionKey": "Gxx1Qyz2szPFQDc7Gewf-",
"paymentKey": "1_k8zb8k06ivRafnZLfi6",
"orderId": "2gfOpFf6REJBTZOVg6jE-",
"orderName": "토스 티셔츠 외 2건",
"currency": "KRW",
"method": "카드",
"status": "CANCELED",
"requestedAt": "2022-01-01T11:31:29+09:00",
"approvedAt": "2022-01-01T11:31:51+09:00",
"useEscrow": false,
"cultureExpense": false,
"checkout": {
"url": "https://api.tosspayments.com/v1/payments/1_k8zb8k06ivRafnZLfi6/checkout"
},
"card": {
"issuerCode": "33",
"acquirerCode": "31",
"number": "12341234****123*",
"installmentPlanMonths": 0,
"isInterestFree": false,
"interestPayer": null,
"approveNo": "00000000",
"useCardPoint": false,
"cardType": "신용",
"ownerType": "개인",
"acquireStatus": "READY"
},
"virtualAccount": null,
"transfer": null,
"mobilePhone": null,
"giftCertificate": null,
"foreignEasyPay": null,
"cashReceipt": null,
"discount": null,
"cancels": [
{
"cancelReason": "고객이 취소를 원함",
"canceledAt": "2022-01-01T11:32:04+09:00",
"cancelAmount": 10000,
"taxFreeAmount": 0,
"taxExemptionAmount": 0,
"refundableAmount": 0,
"easyPayDiscountAmount": 0,
"transactionKey": "8B4F646A829571D870A3011A4E13D640"
}
],
"secret": null,
"type": "NORMAL",
"easyPay": "토스페이",
"country": "KR",
"failure": null,
"totalAmount": 10000,
"balanceAmount": 0,
"suppliedAmount": 0,
"vat": 0,
"taxFreeAmount": 0,
"taxExemptionAmount": 0
}
필요한 데이터를 가공하여 사용할 경우, 객체를 새로 생성하여 데이터를 추출합니다
기존에 json으로 들어오는 데이터가 어떤 형태로 들어오는지 확인해야 객체를 설계 할 수 있습니다
package com.i5e2.likeawesomevegetable.payment.api.dto;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class PaymentRefundResponse {
private String paymentKey; //결제 키 값
private String type; //결제 타입 정보 NORMAL(일반 결제), BILLING(자동 결제), BRANDPAY(브랜드페이)
private String orderId; //주문 ID
private String orderName; //주문명
private String method; //결제 수단
private Long totalAmount; //총 결제 금액
private Long balanceAmount; //취소할 수 있는 잔고
private String status; //결제 처리 상태
private String requestedAt; //결제가 일어난 날짜와 시간
private String approvedAt; //결제 승인이 일어난 날짜와 시간
private String lastTransactionKey; //마지막 거래 키 값
private Long vat; //부가세
private boolean isPartialCancelable; //부분 취소 가능 여부
private Cancels[] cancels; //환불 정보
private Receipt url; //영수증 확인 주소
}
package com.i5e2.likeawesomevegetable.payment.api.dto;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class Cancels {
private Long cancelAmount;
private String cancelReason;
private Long taxFreeAmount;
private Long taxExemptionAmount;
private Long refundableAmount;
private Long easyPayDiscountAmount;
private String canceledAt;
private String transactionKey;
}