토스페이먼츠 정보
- 토스 페이먼츠 SDK를 설치하자
npm install @tosspayments/tosspayments-sdk --save
- 토스 페이먼츠를 로드하자
//결제페이지 안
import { loadTossPayments, ANONYMOUS } from "@tosspayments/tosspayments-sdk";
export default function Page(){
const [payment,setPayment] = useState(null) //payment를 만들어준다.
const [selectedPaymentMethod, setSelectedPaymentMethod] = useState(null);
//SDK를 일단 로드한다.
useEffect(() => {
async function fetchPayment() {
try {
const tossPayments = await loadTossPayments(clientKey);
// 회원 결제
// @docs https://docs.tosspayments.com/sdk/v2/js#tosspaymentspayment
const payment = tossPayments.payment({
customerKey,
});
// 비회원 결제
// const payment = tossPayments.payment({ customerKey: ANONYMOUS });
setPayment(payment);
} catch (error) {
console.error("Error fetching payment:", error);
}
}
fetchPayment();
}, [clientKey, customerKey]);
//결제 함수를 만들자
async function requestPayment() {
//기본적으로 생성되는 searchParams말고 필요한건 여기다가 더 적자
const successUrlWithParams = `${window.location.origin}/inquiries/complete?time_slot_id=${selectedResult.slot_id}&user_id=${userData.id}&participants=${selectedResult.noParticipants}`;
switch (selectedPaymentMethod) {
case "CARD": // 결제 종류에 따라서 값을 넣어줘야한다. 버튼같은거를 만들어서 이걸 선택하게 하자.
await payment.requestPayment({
method: "CARD",
amount: { currency: "KRW", value: selectedResult.totalPrice },
orderId: generateRandomString(),
orderName: selectedResult.program,
successUrl: successUrlWithParams,
failUrl: window.location.origin + "/fail",
customerEmail: profile.email,
customerName: profile.name,
customerMobilePhone: removeSpecialCharacters(profile.phone),
card: {
useEscrow: false,
flowMode: "DEFAULT",
useCardPoint: false,
useAppCardOnly: false,
},
});
break;
case "TRANSFER":
await payment.requestPayment({
method: "TRANSFER",
amount: { currency: "KRW", value: selectedResult.totalPrice },
orderId: generateRandomString(),
orderName: selectedResult.program,
successUrl: successUrlWithParams,
failUrl: window.location.origin + "/fail",
customerEmail: profile.email,
customerName: profile.name,
customerMobilePhone: removeSpecialCharacters(profile.phone),
transfer: {
cashReceipt: {
type: "소득공제",
},
useEscrow: false,
},
});
break;
case "VIRTUAL_ACCOUNT":
await payment.requestPayment({
method: "VIRTUAL_ACCOUNT",
amount: { currency: "KRW", value: selectedResult.totalPrice },
orderId: generateRandomString(),
orderName: selectedResult.program,
successUrl: successUrlWithParams,
failUrl: window.location.origin + "/fail",
customerEmail: profile.email,
customerName: profile.name,
customerMobilePhone: removeSpecialCharacters(profile.phone),
virtualAccount: {
cashReceipt: {
type: "소득공제",
},
useEscrow: false,
validHours: 24,
},
});
break;
case "MOBILE_PHONE":
await payment.requestPayment({
method: "MOBILE_PHONE",
amount: { currency: "KRW", value: selectedResult.totalPrice },
orderId: generateRandomString(),
orderName: selectedResult.program,
successUrl: successUrlWithParams,
failUrl: window.location.origin + "/fail",
customerEmail: profile.email,
customerName: profile.name,
customerMobilePhone: removeSpecialCharacters(profile.phone),
});
break;
case "CULTURE_GIFT_CERTIFICATE":
await payment.requestPayment({
method: "CULTURE_GIFT_CERTIFICATE",
amount: { currency: "KRW", value: selectedResult.totalPrice },
orderId: generateRandomString(),
orderName: selectedResult.program,
successUrl: successUrlWithParams,
failUrl: window.location.origin + "/fail",
customerEmail: profile.email,
customerName: profile.name,
customerMobilePhone: removeSpecialCharacters(profile.phone),
});
break;
case "FOREIGN_EASY_PAY":
await payment.requestPayment({
method: "FOREIGN_EASY_PAY",
amount: {
value: selectedResult.totalPrice,
currency: "KRW",
},
orderId: generateRandomString(),
orderName: selectedResult.program,
successUrl: successUrlWithParams,
failUrl: window.location.origin + "/fail",
customerEmail: userData.email,
customerName: profile.name,
customerMobilePhone: removeSpecialCharacters(profile.phone),
foreignEasyPay: {
provider: "PAYPAL",
country: "KR",
},
});
break;
}
}
// 아래와 같이 모달 같은거 만들어서 선택하게 하고 그거 기반으로 requestPayment 한다.
return(
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent>
{(onClose) => (
<>
<ModalHeader className="flex flex-col gap-1">
결제 방식 선택
</ModalHeader>
<ModalBody>
<div className="grid grid-cols-2 gap-4 ">
<Button
className="flex flex-col items-center justify-center h-full p-6 bg-[#eee] hover:bg-gray-200 rounded-lg hover:scale-105 transition-all duration-300 hover:border-2 hover:border-blue-500"
onPress={() => {
setSelectedPaymentMethod("CARD");
requestPayment();
onClose();
}}
>
<span className="text-3xl mb-2">💳</span>
<span className="text-sm">카드결제</span>
</Button>
<Button
className="flex flex-col items-center justify-center h-full p-6 bg-[#eee] hover:bg-gray-200 rounded-lg hover:scale-105 transition-all duration-300 hover:border-2 hover:border-blue-500"
onPress={() => {
setSelectedPaymentMethod("VIRTUAL_ACCOUNT");
requestPayment();
onClose();
}}
>
<span className="text-3xl mb-2">🏦</span>
<span className="text-sm">가상계좌</span>
</Button>
<Button
className="flex flex-col items-center justify-center h-full p-6 bg-[#eee] hover:bg-gray-200 rounded-lg hover:scale-105 transition-all duration-300 hover:border-2 hover:border-blue-500"
onPress={() => {
setSelectedPaymentMethod("TRANSFER");
requestPayment();
onClose();
}}
>
<span className="text-3xl mb-2">🏧</span>
<span className="text-sm">계좌이체</span>
</Button>
<Button
className="flex flex-col items-center justify-center h-full p-6 bg-[#eee] hover:bg-gray-200 rounded-lg hover:scale-105 transition-all duration-300 hover:border-2 hover:border-blue-500"
onPress={() => {
setSelectedPaymentMethod("MOBILE_PHONE");
requestPayment();
onClose();
}}
>
<span className="text-3xl mb-2">📱</span>
<span className="text-sm">휴대폰결제</span>
</Button>
</div>
</ModalBody>
<ModalFooter>
<Button color="danger" variant="light" onPress={onClose}>
취소
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
)
}
- 결제 확인 엔드포인트를 하나 만들자
//app/api/payment/route.js
import { NextResponse } from 'next/server';
export async function POST(request) {
try {
const { paymentKey, orderId, amount, customerData } = await request.json();
const secretKey = process.env.NEXT_PUBLIC_TOSSPAYMENTS_SECRET_KEY;
// 토스페이먼츠 API는 시크릿 키를 사용자 ID로 사용하고, 비밀번호는 사용하지 않습니다.
// 비밀번호가 없다는 것을 알리기 위해 시크릿 키 뒤에 콜론을 추가합니다.
// @docs https://docs.tosspayments.com/reference/using-api/authorization#%EC%9D%B8%EC%A6%9D
const encryptedSecretKey =
'Basic ' + Buffer.from(secretKey + ':').toString('base64');
// ------ 결제 승인 API 호출 ------
// @docs https://docs.tosspayments.com/guides/payment-widget/integration#3-결제-승인하기
const response = await fetch(
'https://api.tosspayments.com/v1/payments/confirm',
{
method: 'POST',
body: JSON.stringify({ orderId, amount, paymentKey }),
headers: {
Authorization: encryptedSecretKey,
'Content-Type': 'application/json',
},
}
);
if (!response.ok) {
const errorData = await response.json();
console.error('Payment API Error:', errorData);
return NextResponse.json(
{ message: '결제 처리 중 오류가 발생했습니다.' },
{ status: response.status }
);
}
const data = await response.json();
console.log("confirm data:", data);
return NextResponse.json(data);
} catch (error) {
console.error('Payment Processing Error:', error);
return NextResponse.json(
{ message: '결제 요청을 처리할 수 없습니다.' },
{ status: 400 }
);
}
}
- 결제 완성 페이지를 만들자
//app/reservation/complete/page.js
import React from "react";
import Image from "next/image";
import { Divider } from "@heroui/react";
import { Button } from "@heroui/react";
import Link from "next/link";
import { FaCheckCircle } from "react-icons/fa";
import { createClient } from "@/utils/supabase/server";
import { redirect } from "next/navigation";
export default async function page({searchParams}) {
const {orderId, time_slot_id, user_id, participants, paymentKey, amount} = searchParams;
console.log("받은 파라미터들:", {orderId, time_slot_id, user_id, participants, paymentKey,amount}); // 디버깅용
// 결제 확인 로직
try {
const baseUrl = process.env.NEXT_PUBLIC_NODE_ENV === 'development'
? 'http://localhost:3000'
: 'https://www.bdndive.co.kr';
const response = await fetch(`${baseUrl}/api/payment`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
orderId,
amount,
paymentKey,
}),
});
if (!response.ok) {
const errorData = await response.json();
// 에러 발생시 fail 페이지로 리다이렉트
redirect(`/fail?code=${errorData.code}&message=${errorData.message}`);
}
const paymentData = await response.json();
console.log("paymentData:",paymentData);
// 기존의 예약 생성 로직
const supabase = await createClient();
if(orderId) {
// 먼저 예약 존재 여부 확인
const { data: existingReservation } = await supabase
.from("reservation")
.select("*")
.eq("order_id", orderId)
.single();
if (!existingReservation) {
// 예약이 없는 경우에만 새로운 예약 생성
const { error: reservationError } = await supabase
.from("reservation")
.insert([
{
order_id: orderId,
time_slot_id: time_slot_id,
user_id: user_id,
status: '예약확정',
participants: participants,
payment_key: paymentKey
}
]);
if (reservationError) {
console.log("예약 생성 오류:", reservationError);
return;
}
// time_slot 테이블 업데이트
const { data: timeSlot } = await supabase
.from("timeslot")
.select("*")
.eq("id", time_slot_id)
.single();
console.log("timeSlot", timeSlot);
if (timeSlot) {
console.log("슬롯잇음")
const newParticipants = timeSlot.current_participants + 1;
const isFullyBooked = newParticipants >= timeSlot.max_participants;
const { error: updateError } = await supabase
.from("timeslot")
.update({
current_participants: newParticipants,
available: !isFullyBooked,
current_participants: timeSlot.current_participants + parseInt(participants),
})
.eq("id", time_slot_id);
console.log("timeSlot", updateError);
if (updateError) {
console.log("타임슬롯 업데이트 오류:", updateError);
}
}
}
}
} catch (error) {
redirect(`/fail?code=${error.code}&message=${error.message}`);
}
return (
<div className="flex h-full w-full flex-col items-center justify-center mt-[100px] gap-y-6">
<div className="text-4xl font-bold w-full h-[calc(100vh-100px)] flex flex-col justify-center items-center gap-y-12">
<FaCheckCircle
className="text-[100px] text-[#0077B6] animate-scale-fade-in"
></FaCheckCircle>
<div className="text-2xl font-bold">강습프로그램 결제가 완료되었습니다.</div>
<div className="text-lg text-center">
<p>예약하신 강습프로그램 내역은 마이페이지에서 확인 가능하며,</p>
<p>예약환불은 환불 규정에 따라 진행됩니다. (교육일정 변경은 교육 시작일로부터 4일전까지만 가능)</p>
<p>궁금하신 점은 언제든지 전화, 카카오톡으로 문의 부탁드립니다.</p>
</div>
<Link className="text-2xl font-bold text-[#0077B6]" href="/">
홈으로 이동
</Link>
</div>
</div>
);
}