diff --git a/src/app/api/payments/danal/route.ts b/src/app/api/payments/danal/route.ts new file mode 100644 index 0000000..1f2acae --- /dev/null +++ b/src/app/api/payments/danal/route.ts @@ -0,0 +1,151 @@ +import { NextResponse } from "next/server"; + +type DanalPaymentRequest = { + amount: number; + planId: string; + planName: string; + ordererName: string; + contact: string; + email: string; + studentName?: string; + studentPhone?: string; + agreeMarketing?: boolean; +}; + +type DanalReadyResponse = { + NextURL?: string; + PaymentURL?: string; + StartURL?: string; + returnUrl?: string; + TID?: string; + tid?: string; +}; + +const requiredEnv = { + readyEndpoint: process.env.DANAL_READY_ENDPOINT, + merchantId: process.env.DANAL_MERCHANT_ID, + apiKey: process.env.DANAL_API_KEY, + returnUrl: process.env.DANAL_RETURN_URL, +}; + +const missingEnvKeys = Object.entries(requiredEnv) + .filter(([, value]) => !value) + .map(([key]) => key); + +export async function POST(request: Request) { + if (missingEnvKeys.length > 0) { + return NextResponse.json( + { + message: `환경 변수(${missingEnvKeys.join(", ")}) 설정을 확인해주세요.`, + }, + { status: 500 }, + ); + } + + let payload: DanalPaymentRequest; + + try { + payload = (await request.json()) as DanalPaymentRequest; + } catch (error) { + return NextResponse.json( + { message: "요청 본문을 읽을 수 없습니다.", detail: String(error) }, + { status: 400 }, + ); + } + + if (!payload.amount || typeof payload.amount !== "number" || payload.amount <= 0) { + return NextResponse.json( + { message: "결제 금액이 올바르지 않습니다." }, + { status: 400 }, + ); + } + + if (!payload.ordererName?.trim() || !payload.contact?.trim() || !payload.email?.trim()) { + return NextResponse.json( + { message: "주문자 정보를 모두 입력해주세요." }, + { status: 400 }, + ); + } + + const orderId = `MENTORING-${Date.now()}`; + + const readyRequestBody = { + MID: requiredEnv.merchantId, + Amt: payload.amount, + BuyerName: payload.ordererName, + BuyerTel: payload.contact, + BuyerEmail: payload.email, + GoodsName: payload.planName, + ReturnURL: requiredEnv.returnUrl, + CancelURL: process.env.DANAL_CANCEL_URL ?? requiredEnv.returnUrl, + OrderID: orderId, + ItemCode: payload.planId, + UserInfo: { + studentName: payload.studentName, + studentPhone: payload.studentPhone, + agreeMarketing: payload.agreeMarketing, + }, + }; + + const authorization = Buffer.from( + `${requiredEnv.merchantId}:${requiredEnv.apiKey}`, + "utf-8", + ).toString("base64"); + + try { + const danalResponse = await fetch(requiredEnv.readyEndpoint as string, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Basic ${authorization}`, + }, + body: JSON.stringify(readyRequestBody), + }); + + const responseText = await danalResponse.text(); + let parsedResponse: DanalReadyResponse | string = responseText; + + try { + parsedResponse = JSON.parse(responseText) as DanalReadyResponse; + } catch { + parsedResponse = responseText; + } + + if (!danalResponse.ok) { + return NextResponse.json( + { + message: "다날 결제 연동 중 오류가 발생했습니다.", + detail: parsedResponse, + }, + { status: danalResponse.status || 502 }, + ); + } + + const readyResponse = parsedResponse as DanalReadyResponse; + const redirectUrl = + readyResponse.NextURL || + readyResponse.PaymentURL || + readyResponse.StartURL || + readyResponse.returnUrl; + + if (!redirectUrl) { + return NextResponse.json( + { message: "결제 페이지 주소를 받아오지 못했습니다." }, + { status: 502 }, + ); + } + + return NextResponse.json({ + redirectUrl, + transactionId: readyResponse.TID ?? readyResponse.tid ?? orderId, + }); + } catch (error) { + return NextResponse.json( + { + message: "결제 연동 중 예기치 못한 오류가 발생했습니다.", + detail: error instanceof Error ? error.message : String(error), + }, + { status: 502 }, + ); + } +} diff --git a/src/app/mentoring/apply/subscribe/SubscribePageClient.tsx b/src/app/mentoring/apply/subscribe/SubscribePageClient.tsx index b6cceaf..e54fc5f 100644 --- a/src/app/mentoring/apply/subscribe/SubscribePageClient.tsx +++ b/src/app/mentoring/apply/subscribe/SubscribePageClient.tsx @@ -1,7 +1,7 @@ "use client"; -import { useMemo, useState } from "react"; -import { useRouter } from "next/navigation"; +import Script from "next/script"; +import { useEffect, useMemo, useState } from "react"; import type { ChangeEvent, FormEvent, @@ -9,6 +9,7 @@ import type { ReactNode, } from "react"; +import { loadDanalPaymentsSDK } from "@danalpay/javascript-sdk"; import type { MentoringPlanDetails, MentoringPlanId } from "./page"; type SubscribePageClientProps = { @@ -63,7 +64,10 @@ export default function SubscribePageClient({ selectedPlan, finalAmount, }: SubscribePageClientProps) { - const router = useRouter(); + const [danalPayments, setDanalPayments] = useState< + Awaited> | null + >(null); + const [danalError, setDanalError] = useState(null); const [ordererName, setOrdererName] = useState(""); const [contact, setContact] = useState(""); const [email, setEmail] = useState(""); @@ -71,6 +75,8 @@ export default function SubscribePageClient({ const [studentPhone, setStudentPhone] = useState(""); const [agreeRequired, setAgreeRequired] = useState(false); const [agreeMarketing, setAgreeMarketing] = useState(false); + const [isProcessingPayment, setIsProcessingPayment] = useState(false); + const [paymentError, setPaymentError] = useState(null); const [touched, setTouched] = useState({ ordererName: false, @@ -87,6 +93,27 @@ export default function SubscribePageClient({ const requiredAgreementError = isSubmitted && !agreeRequired ? "필수 약관에 동의해주세요." : ""; + useEffect(() => { + const clientKey = + process.env.NEXT_PUBLIC_DANAL_CLIENT_KEY || + "CL_TEST_I4d8FWYSSKl-42F7y3o9g_7iexSCyHbL8qthpZxPnpY="; + + loadDanalPaymentsSDK({ clientKey }) + .then((sdk) => { + setDanalPayments(sdk); + setDanalError(null); + }) + .catch((error) => { + console.error(error); + setDanalPayments(null); + setDanalError( + error instanceof Error + ? error.message + : "결제 모듈을 불러오지 못했습니다.", + ); + }); + }, []); + const isPaymentEnabled = useMemo(() => { return ( !getNameError(ordererName) && @@ -106,7 +133,7 @@ export default function SubscribePageClient({ setTouched((prev) => ({ ...prev, contact: true })); }; - const proceedToGuidePage = () => { + const proceedToPayment = async () => { const nameValidation = getNameError(ordererName); const contactValidation = getContactError(contact); const emailValidation = getEmailError(email); @@ -123,18 +150,75 @@ export default function SubscribePageClient({ return; } - router.push( - `/mentoring/apply/subscribe/bank-transfer-guide?plan=${planId}`, - ); + try { + setIsProcessingPayment(true); + setPaymentError(null); + + const response = await fetch("/api/payments/danal", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + amount: finalAmount, + planId, + planName: selectedPlan.displayName, + ordererName, + contact, + email, + studentName, + studentPhone, + agreeMarketing, + }), + }); + + if (!response.ok) { + const errorPayload = await response.json().catch(() => null); + throw new Error( + errorPayload?.message || "결제 요청 처리 중 오류가 발생했습니다.", + ); + } + + const { redirectUrl } = (await response.json()) as { + redirectUrl?: string; + }; + + if (!redirectUrl) { + throw new Error("결제 페이지를 불러오지 못했습니다."); + } + + if (!danalPayments) { + throw new Error( + danalError || "결제 모듈을 불러오는 중입니다. 잠시 후 다시 시도해주세요.", + ); + } + + await Promise.resolve( + danalPayments.requestPayment({ redirectUrl }), + ).catch(() => { + window.location.href = redirectUrl; + }); + } catch (error) { + console.error(error); + setPaymentError( + error instanceof Error + ? error.message + : "결제 요청 처리 중 오류가 발생했습니다.", + ); + } finally { + setIsProcessingPayment(false); + } }; const handleSubmit = (event: FormEvent) => { event.preventDefault(); - proceedToGuidePage(); + void proceedToPayment(); }; return ( -
+ <> +