Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 151 additions & 0 deletions src/app/api/payments/danal/route.ts
Original file line number Diff line number Diff line change
@@ -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 },
);
}
}
123 changes: 107 additions & 16 deletions src/app/mentoring/apply/subscribe/SubscribePageClient.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
"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,
InputHTMLAttributes,
ReactNode,
} from "react";

import { loadDanalPaymentsSDK } from "@danalpay/javascript-sdk";
import type { MentoringPlanDetails, MentoringPlanId } from "./page";

type SubscribePageClientProps = {
Expand Down Expand Up @@ -63,14 +64,19 @@ export default function SubscribePageClient({
selectedPlan,
finalAmount,
}: SubscribePageClientProps) {
const router = useRouter();
const [danalPayments, setDanalPayments] = useState<
Awaited<ReturnType<typeof loadDanalPaymentsSDK>> | null
>(null);
const [danalError, setDanalError] = useState<string | null>(null);
const [ordererName, setOrdererName] = useState("");
const [contact, setContact] = useState("");
const [email, setEmail] = useState("");
const [studentName, setStudentName] = useState("");
const [studentPhone, setStudentPhone] = useState("");
const [agreeRequired, setAgreeRequired] = useState(false);
const [agreeMarketing, setAgreeMarketing] = useState(false);
const [isProcessingPayment, setIsProcessingPayment] = useState(false);
const [paymentError, setPaymentError] = useState<string | null>(null);

const [touched, setTouched] = useState<TouchedState>({
ordererName: false,
Expand All @@ -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) &&
Expand All @@ -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);
Expand All @@ -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<HTMLFormElement>) => {
event.preventDefault();
proceedToGuidePage();
void proceedToPayment();
};

return (
<main className="bg-white">
<>
<Script src="https://static.danalpay.com/d1/sdk/index.js" strategy="afterInteractive" />
<main className="bg-white">
<section className="mx-auto w-full max-w-[1200px] px-5 py-16 md:px-6 md:py-24">
<div className="space-y-12 md:space-y-16">
<header className="space-y-4">
Expand Down Expand Up @@ -306,14 +390,14 @@ export default function SubscribePageClient({

<button
type="submit"
disabled={!isPaymentEnabled}
disabled={!isPaymentEnabled || isProcessingPayment}
className={`inline-flex w-full items-center justify-center rounded-[16px] px-6 py-4 text-[16px] font-semibold transition-colors duration-200 ${
isPaymentEnabled
isPaymentEnabled && !isProcessingPayment
? "bg-main-600 text-white hover:bg-main-600/90"
: "bg-gray-200 text-ink-900/40"
}`}
>
결제하기
{isProcessingPayment ? "결제 페이지로 이동 중..." : "결제하기"}
</button>
</section>
</form>
Expand Down Expand Up @@ -348,16 +432,22 @@ export default function SubscribePageClient({

<button
type="button"
disabled={!isPaymentEnabled}
disabled={!isPaymentEnabled || isProcessingPayment}
className={`mt-6 inline-flex w-full items-center justify-center rounded-[14px] px-5 py-3 text-[15px] font-semibold transition-colors duration-200 ${
isPaymentEnabled
isPaymentEnabled && !isProcessingPayment
? "bg-main-600 text-white hover:bg-main-600/90"
: "bg-gray-200 text-ink-900/40"
}`}
onClick={proceedToGuidePage}
onClick={() => void proceedToPayment()}
>
결제하기
{isProcessingPayment ? "결제 페이지로 이동 중..." : "결제하기"}
</button>

{paymentError ? (
<p className="text-[12px] leading-[20px] text-red-500">
{paymentError}
</p>
) : null}
</div>

<p className="text-[12px] leading-[20px] text-ink-900/50 md:text-[13px]">
Expand All @@ -369,6 +459,7 @@ export default function SubscribePageClient({
</div>
</section>
</main>
</>
);
}

Expand Down
Loading