[Nextjs]토스페이먼츠를 써보자

코드왕·2025년 2월 7일
0

토스페이먼츠 정보

  1. 토스 페이먼츠 SDK를 설치하자

npm install @tosspayments/tosspayments-sdk --save

  1. 토스 페이먼츠를 로드하자
//결제페이지 안
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>
)
}

  1. 결제 확인 엔드포인트를 하나 만들자
//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 }
    );
  }
}
  1. 결제 완성 페이지를 만들자
//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>
  );
}
profile
CODE DIVE!

0개의 댓글