Преглед изворни кода

Wave 1: swap Danal charge flow for Toss Payments V2 widget

- Remove @danalpay/javascript-sdk, add @tosspayments/tosspayments-sdk (V2 widget)
- Rewrite /charge: order -> loadTossPayments -> widgets/setAmount/render -> requestPayment
- /charge/success: POST confirm, status-branched UI (paid/waiting-deposit/fail), double-call guard
- /charge/fail: code-mapped friendly messages + retry link
- New catch-all proxy app/api/payments/[...path] (plural, matches backend); drop old singular payment shims
- Types/config updated to Toss contract (orders/confirm), Config.toss DTO
- Fix stale Danal reference in CLAUDE.md
- Build succeeds (61/61 static pages)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
KIM-JINO5 пре 3 недеља
родитељ
комит
81460d1208

+ 1 - 1
CLAUDE.md

@@ -90,7 +90,7 @@ middleware.ts          → 인증 미들웨어 (토큰 검증, 리다이렉트)
 - **실시간**: SignalR WebSocket
   - AppHub (`/hubs/app`) — 채팅, 알림, 쪽지, 접속자, 크루 초대
   - DonationHub (`/hubs/donation`) — 후원 위젯/리모콘
-- **결제**: 다날 PG (`@danalpay/javascript-sdk`)
+- **결제**: 토스페이먼츠 PG (`@tosspayments/tosspayments-sdk`, V2 위젯)
 
 ## Code Conventions
 

+ 5 - 5
app/(main)/attendance/page.tsx

@@ -130,7 +130,7 @@ export default function AttendancePage()
 			body: { greeting: greeting.trim() || '' }
 		}).then((res) => {
 			const data = res.data!;
-			let msg = `출석 완료! 순위: ${data.rank}등 | 연속: ${data.consecutiveDays}일 | 경험치: +${data.expRewarded} | 포인트: +${data.pointRewarded}P`;
+			let msg = `출석 완료! 순위: ${data.rank}등 | 연속: ${data.consecutiveDays}일 | 경험치: +${data.expRewarded} | 코인: +${data.pointRewarded}P`;
 			if (data.rankBonusExp > 0 || data.rankBonusPoint > 0) {
 				msg += ` (순위 보상: +${data.rankBonusExp}EXP, +${data.rankBonusPoint}P)`;
 			}
@@ -273,13 +273,13 @@ export default function AttendancePage()
 							<div className="attendance__guide">
 								<section className="attendance__guide-section">
 									<h3>기본 보상</h3>
-									<p>매일 출석 시 경험치 <strong>{config.attendance?.baseExp ?? 0}</strong>, 포인트 <strong>{config.attendance?.basePoint ?? 0}P</strong>가 지급됩니다.</p>
+									<p>매일 출석 시 경험치 <strong>{config.attendance?.baseExp ?? 0}</strong>, 코인 <strong>{config.attendance?.basePoint ?? 0}P</strong>가 지급됩니다.</p>
 								</section>
 
 								{config.attendance?.useStreakBonus && (
 									<section className="attendance__guide-section">
 										<h3>연속 출석 보상</h3>
-										<p>연속 출석 시 1일당 추가 경험치 <strong>+{config.attendance.streakBonusPerDay}</strong>, 포인트 <strong>+{config.attendance.streakBonusPointPerDay}P</strong>가 지급됩니다.</p>
+										<p>연속 출석 시 1일당 추가 경험치 <strong>+{config.attendance.streakBonusPerDay}</strong>, 코인 <strong>+{config.attendance.streakBonusPointPerDay}P</strong>가 지급됩니다.</p>
 										{config.attendance.streakBonusMaxDays > 0 && (
 											<p>최대 <strong>{config.attendance.streakBonusMaxDays}일</strong>까지 적용됩니다.</p>
 										)}
@@ -295,7 +295,7 @@ export default function AttendancePage()
 												<tr>
 													<th scope="col">순위</th>
 													<th scope="col">추가 경험치</th>
-													<th scope="col">추가 포인트</th>
+													<th scope="col">추가 코인</th>
 												</tr>
 											</thead>
 											<tbody>
@@ -398,7 +398,7 @@ export default function AttendancePage()
 						<span>별명</span>
 						<span>출석 인사</span>
 						<span>출석 일시</span>
-						<span>포인트</span>
+						<span>코인</span>
 						<span>경험치</span>
 						<span>개근(일)</span>
 					</div>

+ 0 - 47
app/api/payment/fail/route.ts

@@ -1,47 +0,0 @@
-import { NextRequest, NextResponse } from 'next/server';
-
-// 다날 failUrl 핸들러
-// PC: POST (form body), 모바일: GET (query string)
-export async function GET(request: NextRequest) {
-	const params = request.nextUrl.searchParams;
-	const orderId = params.get('orderId') || '';
-	const errorCode = params.get('errorCode') || params.get('code') || '';
-	const errorMessage = params.get('errorMessage') || params.get('message') || '';
-
-	const url = new URL('/charge/fail', request.url);
-	url.searchParams.set('orderId', orderId);
-	url.searchParams.set('errorCode', errorCode);
-	url.searchParams.set('errorMessage', errorMessage);
-
-	return NextResponse.redirect(url);
-}
-
-export async function POST(request: NextRequest) {
-	const contentType = request.headers.get('content-type') || '';
-	let orderId = '';
-	let errorCode = '';
-	let errorMessage = '';
-
-	if (contentType.includes('application/x-www-form-urlencoded')) {
-		const formData = await request.formData();
-		orderId = (formData.get('orderId') as string) || '';
-		errorCode = (formData.get('errorCode') as string) || (formData.get('code') as string) || '';
-		errorMessage = (formData.get('errorMessage') as string) || (formData.get('message') as string) || '';
-	} else {
-		try {
-			const json = await request.json();
-			orderId = json.orderId || '';
-			errorCode = json.errorCode || json.code || '';
-			errorMessage = json.errorMessage || json.message || '';
-		} catch {
-			orderId = request.nextUrl.searchParams.get('orderId') || '';
-		}
-	}
-
-	const url = new URL('/charge/fail', request.url);
-	url.searchParams.set('orderId', orderId);
-	url.searchParams.set('errorCode', errorCode);
-	url.searchParams.set('errorMessage', errorMessage);
-
-	return NextResponse.redirect(url);
-}

+ 0 - 51
app/api/payment/success/route.ts

@@ -1,51 +0,0 @@
-import { NextRequest, NextResponse } from 'next/server';
-
-// 다날 successUrl 핸들러
-// PC: POST (form body), 모바일: GET (query string)
-export async function GET(request: NextRequest) {
-	const params = request.nextUrl.searchParams;
-	const orderId = params.get('orderId') || '';
-	const transactionId = params.get('transactionId') || '';
-	const method = params.get('method') || '';
-
-	const url = new URL('/charge/success', request.url);
-	url.searchParams.set('orderId', orderId);
-	url.searchParams.set('transactionId', transactionId);
-	url.searchParams.set('method', method);
-
-	return NextResponse.redirect(url);
-}
-
-export async function POST(request: NextRequest) {
-	// form-urlencoded 또는 JSON body 파싱
-	const contentType = request.headers.get('content-type') || '';
-	let orderId = '';
-	let transactionId = '';
-	let method = '';
-
-	if (contentType.includes('application/x-www-form-urlencoded')) {
-		const formData = await request.formData();
-		orderId = (formData.get('orderId') as string) || '';
-		transactionId = (formData.get('transactionId') as string) || '';
-		method = (formData.get('method') as string) || '';
-	} else {
-		try {
-			const json = await request.json();
-			orderId = json.orderId || '';
-			transactionId = json.transactionId || '';
-			method = json.method || '';
-		} catch {
-			// fallback: query string
-			orderId = request.nextUrl.searchParams.get('orderId') || '';
-			transactionId = request.nextUrl.searchParams.get('transactionId') || '';
-			method = request.nextUrl.searchParams.get('method') || '';
-		}
-	}
-
-	const url = new URL('/charge/success', request.url);
-	url.searchParams.set('orderId', orderId);
-	url.searchParams.set('transactionId', transactionId);
-	url.searchParams.set('method', method);
-
-	return NextResponse.redirect(url);
-}

+ 3 - 4
app/api/payment/[...path]/route.ts → app/api/payments/[...path]/route.ts

@@ -4,7 +4,7 @@ import { fetchJson } from '@/lib/utils/server';
 
 export async function GET(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
 	const { path } = await params;
-	const endpoint = `/api/payment/${path.join('/')}`;
+	const endpoint = `/api/payments/${path.join('/')}`;
 	const url = new URL(request.url);
 	const res: ResultDto = await fetchJson(`${endpoint}${url.search}`, { method: 'GET' });
 	return NextResponse.json(res);
@@ -12,9 +12,8 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
 
 export async function POST(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
 	const { path } = await params;
-	const endpoint = `/api/payment/${path.join('/')}`;
+	const endpoint = `/api/payments/${path.join('/')}`;
 	const jsonBody = await request.json();
-	console.log('[Payment Route] POST', endpoint, JSON.stringify(jsonBody));
 	const res: ResultDto = await fetchJson(endpoint, { method: 'POST', body: JSON.stringify(jsonBody) });
 	return NextResponse.json(res);
-}
+}

+ 21 - 9
app/charge/fail/page.tsx

@@ -3,15 +3,24 @@
 import '../style.scss';
 import { useSearchParams } from 'next/navigation';
 
+// 토스 SDK 에러 코드 → 사용자 친화 안내 (미매핑 코드는 토스가 전달한 message 사용)
+const CODE_MESSAGES: Record<string, string> = {
+	PAY_PROCESS_CANCELED: '결제를 취소했습니다.',
+	PAY_PROCESS_ABORTED: '결제가 중단되었습니다. 다시 시도해주세요.',
+	REJECT_CARD_COMPANY: '카드사에서 결제를 거절했습니다. 다른 결제수단을 이용해주세요.',
+	USER_CANCEL: '결제를 취소했습니다.'
+};
+
 export default function ChargeFailPage()
 {
 	const searchParams = useSearchParams();
-	const errorCode = searchParams.get('errorCode') || '';
-	const errorMessage = searchParams.get('errorMessage') || '결제가 취소되었거나 실패했습니다.';
+	const code = searchParams.get('code') || '';
+	const rawMessage = searchParams.get('message') || '';
+	const message = CODE_MESSAGES[code] || rawMessage || '결제가 취소되었거나 실패했습니다.';
 
-	if (errorCode === '9999') {
-		return window.location.replace('/charge');
-	}
+	const handleRetry = () => {
+		window.location.href = '/charge';
+	};
 
 	const handleClose = () => {
 		if (window.opener) {
@@ -26,13 +35,16 @@ export default function ChargeFailPage()
 			<div className="charge-page__card">
 				<div className="charge-page__result">
 					<div className="charge-page__result-icon charge-page__result-icon--error">✕</div>
-					<p className="charge-page__result-message">{errorMessage}</p>
-					{errorCode && <p className="charge-page__result-code">오류 코드: {errorCode}</p>}
-					<button type="button" className="charge-page__submit" onClick={handleClose}>
+					<p className="charge-page__result-message">{message}</p>
+					{code && <p className="charge-page__result-code">오류 코드: {code}</p>}
+					<button type="button" className="charge-page__submit" onClick={handleRetry}>
+						다시 충전하기
+					</button>
+					<button type="button" className="charge-page__reset" onClick={handleClose}>
 						닫기
 					</button>
 				</div>
 			</div>
 		</div>
 	);
-}
+}

+ 159 - 174
app/charge/page.tsx

@@ -1,84 +1,48 @@
 'use client';
 
-import { useState, useEffect, useMemo } from 'react';
+import './style.scss';
+import { useState, useEffect, useRef, useCallback } from 'react';
+import { loadTossPayments } from '@tosspayments/tosspayments-sdk';
+import type { TossPaymentsWidgets } from '@tosspayments/tosspayments-sdk';
 import { fetchApi } from '@/lib/utils/client';
 import useAuth from '@/hooks/useAuth';
 import { useConfigContext } from '@/contexts/configProvider';
-import { DanalCreateOrderRequest, PaymentMethodType } from '@/types/request/payment/charge';
-import { DanalCreateOrderResponse } from '@/types/response/payment/charge';
-import type { PaymentForm } from '@/types/config';
-import CreditCardIcon from '@/public/icons/payment/credit-card.svg';
-import KakaoIcon from '@/public/icons/payment/kakao.svg';
-import NaverIcon from '@/public/icons/payment/naver.svg';
-import BankIcon from '@/public/icons/payment/bank.svg';
-import PhoneIcon from '@/public/icons/payment/phone.svg';
-import { Landmark as VBankIcon } from 'lucide-react';
-
-type PaymentMethodOption = {
-	value: PaymentMethodType;
-	label: string;
-	danalMethod: string;
-	danalKey: string;
-	Icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
-	enabledKey: keyof PaymentForm;
-};
-
-const PAYMENT_METHODS: PaymentMethodOption[] = [
-	{ value: 'Card',           label: '신용카드',   danalMethod: 'CARD',            danalKey: 'card',           Icon: CreditCardIcon, enabledKey: 'isCardEnabled' },
-	{ value: 'KakaoPay',       label: '카카오페이', danalMethod: 'KAKAOPAY',        danalKey: 'kakaoPay',       Icon: KakaoIcon,      enabledKey: 'isKakaoPayEnabled' },
-	{ value: 'NaverPay',       label: '네이버페이', danalMethod: 'NAVERPAY',        danalKey: 'naverPay',       Icon: NaverIcon,      enabledKey: 'isNaverPayEnabled' },
-	{ value: 'Transfer',       label: '계좌이체',   danalMethod: 'TRANSFER',        danalKey: 'transfer',       Icon: BankIcon,       enabledKey: 'isTransferEnabled' },
-	{ value: 'Mobile',         label: '휴대폰',     danalMethod: 'MOBILE',          danalKey: 'mobile',         Icon: PhoneIcon,      enabledKey: 'isMobileEnabled' },
-	{ value: 'VirtualAccount', label: '가상계좌',   danalMethod: 'VIRTUAL_ACCOUNT', danalKey: 'virtualAccount', Icon: VBankIcon,      enabledKey: 'isVirtualAccountEnabled' },
-];
-
-// 결제수단별 다날 SDK methods 설정
-const METHOD_CONFIGS: Record<string, object> = {
-	card: {},
-	kakaoPay: {},
-	naverPay: {},
-	payco: {},
-	transfer: {},
-	mobile: { itemCode: '1270000000', itemType: '1' },
-	virtualAccount: { notiUrl: `${process.env.NEXT_PUBLIC_API_URL}/api/payment/noti/vaccount` }
-};
+import { TossCreateOrderRequest } from '@/types/request/payment/charge';
+import { TossCreateOrderResponse } from '@/types/response/payment/charge';
 
 const QUICK_AMOUNTS = [10000, 50000, 100000, 1000000];
 
 export default function ChargePage()
 {
-	// pointInput = 사용자가 충전하려는 포인트 (VAT 제외). VAT 10%는 별도 가산.
-	const [pointInput, setPointInput] = useState<number>(0);
-	const [paymentMethod, setPaymentMethod] = useState<PaymentMethodType>('Card');
+	// cashInput = 사용자가 충전하려는 캐시 (VAT 제외). VAT 10%는 서버에서 별도 가산.
+	const [cashInput, setCashInput] = useState<number>(0);
+	const [order, setOrder] = useState<TossCreateOrderResponse|null>(null);
 	const [loading, setLoading] = useState(false);
+	const [paying, setPaying] = useState(false);
 	const [isPopup, setIsPopup] = useState(false);
+	const [error, setError] = useState<string|null>(null);
 	const { member, loginCheck } = useAuth();
 	const config = useConfigContext();
-	const isDanalTestMode = config?.external?.danalPayMode === 'test';
-
-	// 결제 수단별 활성 여부 매핑
-	const methodEnabled = useMemo<Record<PaymentMethodType, boolean>>(() => {
-		const payment = config?.payment;
-		const map = {} as Record<PaymentMethodType, boolean>;
-		for (const m of PAYMENT_METHODS) {
-			map[m.value] = payment ? Boolean(payment[m.enabledKey]) : false;
-		}
-		return map;
-	}, [config?.payment]);
+	const isTossTestMode = config?.toss?.tossPayMode === 'test';
+
+	const widgetsRef = useRef<TossPaymentsWidgets|null>(null);
 
 	useEffect(() => {
 		setIsPopup(!!window.opener);
 	}, []);
 
-	// 초기 선택된 결제 수단이 비활성이면 첫 번째 활성 수단으로 자동 전환
-	useEffect(() => {
-		if (!methodEnabled[paymentMethod]) {
-			const firstEnabled = PAYMENT_METHODS.find(m => methodEnabled[m.value]);
-			if (firstEnabled) {
-				setPaymentMethod(firstEnabled.value);
-			}
-		}
-	}, [methodEnabled, paymentMethod]);
+	// 주문 생성 후 토스 위젯 초기화 — clientKey/customerKey 로 widgets 생성, 금액 설정, UI 렌더링
+	const initWidgets = useCallback(async (created: TossCreateOrderResponse) => {
+		const tossPayments = await loadTossPayments(created.clientKey);
+		const widgets = tossPayments.widgets({ customerKey: created.customerKey });
+		widgetsRef.current = widgets;
+
+		await widgets.setAmount({ value: created.amount, currency: 'KRW' });
+		await Promise.all([
+			widgets.renderPaymentMethods({ selector: '#toss-payment-methods' }),
+			widgets.renderAgreement({ selector: '#toss-agreement' })
+		]);
+	}, []);
 
 	if (!member || !loginCheck()) {
 		return null;
@@ -86,157 +50,178 @@ export default function ChargePage()
 
 	const handleAmountChange = (value: string) => {
 		const num = parseInt(value.replace(/[^0-9]/g, ''), 10);
-		setPointInput(isNaN(num) ? 0 : num);
+		setCashInput(isNaN(num) ? 0 : num);
 	};
 
 	const addAmount = (add: number) => {
-		setPointInput(prev => prev + add);
+		setCashInput(prev => prev + add);
 	};
 
-	// VAT 10% 별도 가산. 최종 결제 금액 = pointInput + vat.
-	const vatAmount = Math.round(pointInput * 0.1);
-	const totalAmount = pointInput + vatAmount;
+	// VAT 10% 별도 가산 (표시용). 최종 결제 금액은 서버 주문(order.amount)이 확정한다.
+	const vatAmount = Math.round(cashInput * 0.1);
+	const totalAmount = cashInput + vatAmount;
 
-	const handleSubmit = async () => {
-		if (pointInput < 1000) {
+	// 1단계: 서버에 주문 생성 → 토스 위젯 초기화
+	const handleCreateOrder = async () => {
+		if (cashInput < 1000) {
 			alert('최소 충전 금액은 1,000원입니다.');
 			return;
 		}
 
 		setLoading(true);
+		setError(null);
 
 		try {
-			const res = await fetchApi<DanalCreateOrderResponse>('/api/payment/order', {
+			const res = await fetchApi<TossCreateOrderResponse>('/api/payments/orders', {
 				method: 'POST',
-				body: {
-					amount: pointInput,
-					paymentMethod
-				} as DanalCreateOrderRequest
+				body: { amount: cashInput } as TossCreateOrderRequest
 			});
 
-			if (!res.data) {
-				alert(res.message || '주문 생성에 실패했습니다.');
-				return setLoading(false);
+			if (!res.success || !res.data) {
+				setError(res.message || '주문 생성에 실패했습니다.');
+				setLoading(false);
+				return;
 			}
 
-			const order = res.data;
-			const selected = PAYMENT_METHODS.find(m => m.value === paymentMethod)!;
-
-			const { loadDanalPaymentsSDK } = await import('@danalpay/javascript-sdk');
-			const danalPayments = await loadDanalPaymentsSDK({ clientKey: order.clientKey });
-
-			// eslint-disable-next-line @typescript-eslint/no-explicit-any
-			const paymentParams: any = {
-				paymentsMethod: selected.danalMethod,
-				methods: {
-					[selected.danalKey]: METHOD_CONFIGS[selected.danalKey]
-				},
-				orderName: `개미투자 ${pointInput.toLocaleString()}(P) 충전`,
-				amount: order.amount,
-				merchantId: order.merchantID,
+			const created = res.data;
+			setOrder(created);
+			await initWidgets(created);
+		} catch (e) {
+			console.error('주문 생성/위젯 초기화 실패:', e);
+			setError('결제 준비 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.');
+		} finally {
+			setLoading(false);
+		}
+	};
+
+	// 2단계: 결제 요청 (redirect 방식) — successUrl/failUrl 로 이동
+	const handleRequestPayment = async () => {
+		const widgets = widgetsRef.current;
+		if (!widgets || !order) {
+			return;
+		}
+
+		setPaying(true);
+		setError(null);
+
+		try {
+			const origin = window.location.origin;
+			await widgets.requestPayment({
 				orderId: order.orderID,
-				userId: member.id,
-				userEmail: member.email,
-				userName: member.name,
-				successUrl: order.successUrl,
-				failUrl: order.failUrl
-			};
-
-			danalPayments.requestPayment(paymentParams);
+				orderName: order.orderName,
+				successUrl: `${origin}/charge/success`,
+				failUrl: `${origin}/charge/fail`,
+				customerEmail: member.email ?? undefined,
+				customerName: member.name ?? undefined
+			});
 		} catch (e) {
+			// 사용자가 결제창을 닫거나 요청이 실패한 경우 — 페이지에 머물러 재시도 가능
 			console.error('결제 요청 실패:', e);
-			alert('결제 요청에 실패했습니다.');
-			setLoading(false);
+			const message = e instanceof Error ? e.message : '결제 요청에 실패했습니다.';
+			setError(message);
+			setPaying(false);
 		}
 	};
 
+	// 금액 재선택 — 위젯을 닫고 1단계로 복귀 (금액이 바뀌면 새 주문이 필요)
+	const handleReset = () => {
+		widgetsRef.current = null;
+		setOrder(null);
+		setError(null);
+	};
+
 	return (
 		<div className="charge-page">
-			{isDanalTestMode && (
+			{isTossTestMode && (
 				<div className="charge-page__test-banner" role="alert">
-					현재 다날 충전은 테스트 환경입니다.
+					현재 토스 충전은 테스트 환경입니다.
 				</div>
 			)}
 			<div className="charge-page__card">
 				<div className="charge-page__header">
-					<h1>포인트 충전</h1>
+					<h1>캐시 충전</h1>
 					{isPopup && (
 						<button type="button" className="charge-page__close" onClick={() => window.close()}>×</button>
 					)}
 				</div>
 
 				<div className="charge-page__body">
-					<label className="charge-page__label">충전 포인트 (P)</label>
-					<input
-						type="text"
-						className="charge-page__amount-input"
-						value={pointInput > 0 ? pointInput.toLocaleString() : ''}
-						onChange={e => handleAmountChange(e.target.value)}
-						placeholder="0"
-						inputMode="numeric"
-						autoFocus
-					/>
-
-					<div className="charge-page__quick-btns">
-						{QUICK_AMOUNTS.map(v => (
-							<button type="button" key={v} className="charge-page__quick-btn" onClick={() => addAmount(v)}>
-								+{v >= 10000 ? `${v / 10000}만` : v.toLocaleString()}
-							</button>
-						))}
-					</div>
-
-					<div className="charge-page__method-section">
-						<label className="charge-page__label">결제 수단</label>
-						<div className="charge-page__method-grid">
-							{PAYMENT_METHODS.map(m => {
-								const enabled = methodEnabled[m.value];
-								const isActive = enabled && paymentMethod === m.value;
-								const cls = [
-									'charge-page__method-btn',
-									isActive ? 'charge-page__method-btn--active' : '',
-									!enabled ? 'charge-page__method-btn--disabled' : ''
-								].filter(Boolean).join(' ');
-
-								return (
-									<button
-										type="button"
-										key={m.value}
-										className={cls}
-										onClick={() => enabled && setPaymentMethod(m.value)}
-										disabled={!enabled}
-										aria-disabled={!enabled}
-										title={!enabled ? `${m.label} 결제는 현재 사용할 수 없습니다.` : undefined}
-									>
-										<span className="charge-page__method-icon">
-											<m.Icon width={24} height={24} aria-label={m.label} />
-										</span>
-										<span>{m.label}</span>
+					{!order && (
+						<>
+							<label className="charge-page__label">충전 캐시 (원)</label>
+							<input
+								type="text"
+								className="charge-page__amount-input"
+								value={cashInput > 0 ? cashInput.toLocaleString() : ''}
+								onChange={e => handleAmountChange(e.target.value)}
+								placeholder="0"
+								inputMode="numeric"
+								autoFocus
+							/>
+
+							<div className="charge-page__quick-btns">
+								{QUICK_AMOUNTS.map(v => (
+									<button type="button" key={v} className="charge-page__quick-btn" onClick={() => addAmount(v)}>
+										+{v >= 10000 ? `${v / 10000}만` : v.toLocaleString()}
 									</button>
-								);
-							})}
-						</div>
-					</div>
-
-					<div className="charge-page__receipt">
-						<div className="charge-page__receipt-row">
-							<span>충전 (P)</span>
-							<span>{pointInput.toLocaleString()} P</span>
-						</div>
-						<div className="charge-page__receipt-row">
-							<span>부가세 (10%)</span>
-							<span>{vatAmount.toLocaleString()} 원</span>
-						</div>
-						<div className="charge-page__receipt-row charge-page__receipt-row--total">
-							<span>총 결제 금액</span>
-							<span>{totalAmount.toLocaleString()} 원</span>
-						</div>
-					</div>
-
-					<button type="button" className="charge-page__submit" onClick={handleSubmit} disabled={loading || pointInput < 1000}>
-						{loading ? '처리 중...' : '충전하기'}
-					</button>
+								))}
+							</div>
+
+							<div className="charge-page__receipt">
+								<div className="charge-page__receipt-row">
+									<span>충전 캐시</span>
+									<span>{cashInput.toLocaleString()} 원</span>
+								</div>
+								<div className="charge-page__receipt-row">
+									<span>부가세 (10%)</span>
+									<span>{vatAmount.toLocaleString()} 원</span>
+								</div>
+								<div className="charge-page__receipt-row charge-page__receipt-row--total">
+									<span>총 결제 금액</span>
+									<span>{totalAmount.toLocaleString()} 원</span>
+								</div>
+							</div>
+
+							{error && <p className="charge-page__error" role="alert">{error}</p>}
+
+							<button type="button" className="charge-page__submit" onClick={handleCreateOrder} disabled={loading || cashInput < 1000}>
+								{loading ? '결제 준비 중...' : '결제 진행'}
+							</button>
+						</>
+					)}
+
+					{order && (
+						<>
+							<div className="charge-page__receipt">
+								<div className="charge-page__receipt-row">
+									<span>충전 캐시</span>
+									<span>{order.pointAmount.toLocaleString()} 원</span>
+								</div>
+								<div className="charge-page__receipt-row">
+									<span>부가세 (10%)</span>
+									<span>{order.vatAmount.toLocaleString()} 원</span>
+								</div>
+								<div className="charge-page__receipt-row charge-page__receipt-row--total">
+									<span>총 결제 금액</span>
+									<span>{order.amount.toLocaleString()} 원</span>
+								</div>
+							</div>
+
+							<div id="toss-payment-methods" className="charge-page__toss-methods" />
+							<div id="toss-agreement" className="charge-page__toss-agreement" />
+
+							{error && <p className="charge-page__error" role="alert">{error}</p>}
+
+							<button type="button" className="charge-page__submit" onClick={handleRequestPayment} disabled={paying}>
+								{paying ? '결제 요청 중...' : `${order.amount.toLocaleString()}원 결제하기`}
+							</button>
+							<button type="button" className="charge-page__reset" onClick={handleReset} disabled={paying}>
+								금액 다시 선택
+							</button>
+						</>
+					)}
 				</div>
 			</div>
 		</div>
 	);
-}
+}

+ 47 - 0
app/charge/style.scss

@@ -224,6 +224,48 @@
 		}
 	}
 
+	&__reset {
+		width: 100%;
+		padding: 8px 0;
+		margin-top: 8px;
+		border: 1px solid var(--border-default);
+		border-radius: 8px;
+		background: var(--bg-page);
+		color: var(--text-secondary);
+		font-size: 0.875rem;
+		font-weight: 500;
+		cursor: pointer;
+		transition: all 0.15s;
+
+		&:hover:not(:disabled) {
+			background: var(--bg-subtle);
+		}
+
+		&:disabled {
+			opacity: 0.5;
+			cursor: not-allowed;
+		}
+	}
+
+	&__error {
+		margin-top: 12px;
+		padding: 10px 12px;
+		border-radius: 8px;
+		background: #fee2e2;
+		color: #dc2626;
+		font-size: 0.8125rem;
+		font-weight: 500;
+	}
+
+	// 토스 위젯 컨테이너 (SDK 가 내부 UI 를 렌더링)
+	&__toss-methods {
+		margin-top: 12px;
+	}
+
+	&__toss-agreement {
+		margin-top: 4px;
+	}
+
 	// 결제 결과
 	&__result {
 		padding: 40px 20px;
@@ -250,6 +292,11 @@
 			color: #16a34a;
 		}
 
+		&--waiting {
+			background: #fef9c3;
+			color: #ca8a04;
+		}
+
 		&--error {
 			background: #fee2e2;
 			color: #dc2626;

+ 28 - 32
app/charge/success/page.tsx

@@ -4,22 +4,15 @@ import '../style.scss';
 import { useEffect, useRef, useState } from 'react';
 import { useSearchParams } from 'next/navigation';
 import { fetchApi, getDateTime } from '@/lib/utils/client';
-import { DanalConfirmRequest } from '@/types/request/payment/charge';
-import { DanalConfirmResponse } from '@/types/response/payment/charge';
+import { TossConfirmRequest } from '@/types/request/payment/charge';
+import { TossConfirmResponse } from '@/types/response/payment/charge';
 
-const METHOD_LABELS: Record<string, string> = {
-	CARD: '신용카드',
-	KAKAOPAY: '카카오페이',
-	NAVERPAY: '네이버페이',
-	TRANSFER: '계좌이체',
-	MOBILE: '휴대폰',
-	VIRTUAL_ACCOUNT: '가상계좌'
-};
+type ViewStatus = 'loading'|'success'|'waiting'|'error';
 
 export default function ChargeSuccessPage()
 {
 	const searchParams = useSearchParams();
-	const [status, setStatus] = useState<'loading'|'success'|'error'>('loading');
+	const [status, setStatus] = useState<ViewStatus>('loading');
 	const [message, setMessage] = useState('결제 승인 처리 중...');
 	const [pointAmount, setPointAmount] = useState<number>(0);
 	const [paidAt, setPaidAt] = useState<string|null>(null);
@@ -32,38 +25,47 @@ export default function ChargeSuccessPage()
 
 		calledRef.current = true;
 		confirmPayment();
-
 	}, []);
 
 	const confirmPayment = async () => {
+		const paymentKey = searchParams.get('paymentKey');
 		const orderID = searchParams.get('orderId');
-		const transactionID = searchParams.get('transactionId');
-		const method = searchParams.get('method');
+		const amountStr = searchParams.get('amount');
+		const amount = amountStr ? parseInt(amountStr, 10) : NaN;
 
-		if (!orderID || !transactionID || !method) {
+		if (!paymentKey || !orderID || isNaN(amount)) {
 			setStatus('error');
 			setMessage('결제 정보가 올바르지 않습니다.');
 			return;
 		}
 
 		try {
-			const res = await fetchApi<DanalConfirmResponse>('/api/payment/confirm', {
+			const res = await fetchApi<TossConfirmResponse>('/api/payments/confirm', {
 				method: 'POST',
-				body: { orderID, transactionID, method } as DanalConfirmRequest
+				body: { paymentKey, orderID, amount } as TossConfirmRequest,
+				silent: true
 			});
 
-			if (res.success && res.data) {
-				setPointAmount(res.data.pointAmount);
-				setPaidAt(res.data.paidAt);
+			const data = res.data;
+			if (res.success && data?.success) {
+				// 가상계좌 입금 대기 — 이 시점엔 적립 전. 입금 완료 후 지갑에 반영된다.
+				if (data.status === 'WaitingDeposit') {
+					setStatus('waiting');
+					setMessage(data.message || '가상계좌가 발급되었습니다. 입금 확인 후 충전됩니다.');
+					return;
+				}
+
+				setPointAmount(data.pointAmount);
+				setPaidAt(data.paidAt);
 				setStatus('success');
-				setMessage('포인트 충전 완료');
+				setMessage(data.message || '캐시 충전 완료');
 
 				if (window.opener) {
 					window.opener.postMessage({ type: 'CHARGE_COMPLETE' }, '*');
 				}
 			} else {
 				setStatus('error');
-				setMessage(res.message || '결제 승인에 실패했습니다.');
+				setMessage(data?.message || res.message || '결제 승인에 실패했습니다.');
 			}
 		} catch {
 			setStatus('error');
@@ -71,9 +73,6 @@ export default function ChargeSuccessPage()
 		}
 	};
 
-	const method = searchParams.get('method') ?? '';
-	const methodLabel = METHOD_LABELS[method] ?? method;
-
 	const handleClose = () => {
 		if (window.opener) {
 			window.close();
@@ -88,18 +87,15 @@ export default function ChargeSuccessPage()
 				<div className="charge-page__result">
 					{status === 'loading' && <div className="charge-page__spinner" />}
 					{status === 'success' && <div className="charge-page__result-icon charge-page__result-icon--success">✓</div>}
+					{status === 'waiting' && <div className="charge-page__result-icon charge-page__result-icon--waiting">…</div>}
 					{status === 'error' && <div className="charge-page__result-icon charge-page__result-icon--error">✕</div>}
 					<p className="charge-page__result-message">{message}</p>
 
 					{status === 'success' && (
 						<div className="charge-page__receipt">
 							<div className="charge-page__receipt-row">
-								<span>충전 포인트 (P)</span>
-								<span>{pointAmount.toLocaleString()} P</span>
-							</div>
-							<div className="charge-page__receipt-row">
-								<span>결제 수단</span>
-								<span>{methodLabel}</span>
+								<span>충전 캐시</span>
+								<span>{pointAmount.toLocaleString()} 원</span>
 							</div>
 							<div className="charge-page__receipt-row">
 								<span>결제 일시</span>
@@ -117,4 +113,4 @@ export default function ChargeSuccessPage()
 			</div>
 		</div>
 	);
-}
+}

+ 7 - 10
package-lock.json

@@ -10,7 +10,6 @@
             "dependencies": {
                 "@base-ui/react": "^1.4.0",
                 "@ckeditor/ckeditor5-react": "^9.5.0",
-                "@danalpay/javascript-sdk": "^1.1.1",
                 "@fortawesome/fontawesome-svg-core": "^7.2.0",
                 "@fortawesome/free-brands-svg-icons": "^7.2.0",
                 "@fortawesome/free-regular-svg-icons": "^7.2.0",
@@ -20,6 +19,7 @@
                 "@radix-ui/react-checkbox": "^1.3.3",
                 "@radix-ui/react-dialog": "^1.1.15",
                 "@react-oauth/google": "^0.13.4",
+                "@tosspayments/tosspayments-sdk": "^2.4.1",
                 "animate.css": "^4.1.1",
                 "class-variance-authority": "^0.7.1",
                 "clsx": "^2.1.1",
@@ -11016,15 +11016,6 @@
                 "benchmarks"
             ]
         },
-        "node_modules/@danalpay/javascript-sdk": {
-            "version": "1.1.1",
-            "resolved": "https://registry.npmjs.org/@danalpay/javascript-sdk/-/javascript-sdk-1.1.1.tgz",
-            "integrity": "sha512-tH0JOzXVX235JPYxmwwlB/QFrAPM0zB9qn0nTFX5gzeEP7vqH0T1diw/g3dIU9EKthx/GtJVqfUltscxQChbWg==",
-            "license": "MIT",
-            "engines": {
-                "node": ">=12.*"
-            }
-        },
         "node_modules/@date-fns/tz": {
             "version": "1.4.1",
             "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz",
@@ -13212,6 +13203,12 @@
                 "tslib": "^2.8.0"
             }
         },
+        "node_modules/@tosspayments/tosspayments-sdk": {
+            "version": "2.7.1",
+            "resolved": "https://registry.npmjs.org/@tosspayments/tosspayments-sdk/-/tosspayments-sdk-2.7.1.tgz",
+            "integrity": "sha512-nHInxgFvFGm1VtaCS+IPLdtmE+B2a+MCYE9Meq0aR/0L0hAgoGllLS4ciQGb1RtLvp3mHcj3lL80XMi4BqQ7uw==",
+            "license": "MIT"
+        },
         "node_modules/@tybys/wasm-util": {
             "version": "0.10.1",
             "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",

+ 1 - 1
package.json

@@ -11,7 +11,6 @@
     "dependencies": {
         "@base-ui/react": "^1.4.0",
         "@ckeditor/ckeditor5-react": "^9.5.0",
-        "@danalpay/javascript-sdk": "^1.1.1",
         "@fortawesome/fontawesome-svg-core": "^7.2.0",
         "@fortawesome/free-brands-svg-icons": "^7.2.0",
         "@fortawesome/free-regular-svg-icons": "^7.2.0",
@@ -21,6 +20,7 @@
         "@radix-ui/react-checkbox": "^1.3.3",
         "@radix-ui/react-dialog": "^1.1.15",
         "@react-oauth/google": "^0.13.4",
+        "@tosspayments/tosspayments-sdk": "^2.4.1",
         "animate.css": "^4.1.1",
         "class-variance-authority": "^0.7.1",
         "clsx": "^2.1.1",

+ 8 - 24
types/config.ts

@@ -7,7 +7,7 @@ export default interface Config {
 	account: AccountForm;
 	emailTemplate: EmailTemplate;
 	external: ExternalApiForm;
-	payment: PaymentForm;
+	toss: TossForm;
 	attendance: AttendanceForm;
 }
 
@@ -113,31 +113,15 @@ export interface ExternalApiForm {
 	googleClientId: string|null;
 	googleClientSecretEnc: string|null;
 	googleAppId: string|null;
-	danalPayMode: string|null;
 }
 
-export interface PaymentForm {
-	// 수수료 (% 가 우선 적용 후 원 가산)
-	danalCardFeeRate: number|null;
-	danalCardFeeAmount: number|null;
-	danalKakaoPayFeeRate: number|null;
-	danalKakaoPayFeeAmount: number|null;
-	danalNaverPayFeeRate: number|null;
-	danalNaverPayFeeAmount: number|null;
-	danalTransferFeeRate: number|null;
-	danalTransferFeeAmount: number|null;
-	danalMobileFeeRate: number|null;
-	danalMobileFeeAmount: number|null;
-	danalVbankFeeRate: number|null;
-	danalVbankFeeAmount: number|null;
-
-	// 결제 수단 사용 여부
-	isCardEnabled: boolean;
-	isKakaoPayEnabled: boolean;
-	isNaverPayEnabled: boolean;
-	isTransferEnabled: boolean;
-	isMobileEnabled: boolean;
-	isVirtualAccountEnabled: boolean;
+export interface TossForm {
+	// 토스페이먼츠 결제 환경 (test/live) — 키 원문은 응답에 실리지 않고 설정 여부만 노출
+	tossPayMode: string|null;
+	hasTestClientKey: boolean;
+	hasTestSecretKey: boolean;
+	hasLiveClientKey: boolean;
+	hasLiveSecretKey: boolean;
 }
 
 export interface AttendanceForm {

+ 8 - 8
types/request/payment/charge.ts

@@ -1,13 +1,13 @@
-export type PaymentMethodType = 'Card'|'VirtualAccount'|'Mobile'|'Transfer'|'NaverPay'|'KakaoPay'|'Payco';
+// 토스페이먼츠 V2 충전 요청 타입
 
-export type DanalCreateOrderRequest = {
-	/** 충전할 포인트 (VAT 제외). PG 결제 총액은 서버에서 VAT 10% 별도 가산. */
+export type TossCreateOrderRequest = {
+	/** 충전할 캐시 금액 (VAT 제외). PG 결제 총액은 서버에서 VAT 10% 별도 가산. */
 	amount: number;
-	paymentMethod: PaymentMethodType;
 };
 
-export type DanalConfirmRequest = {
+export type TossConfirmRequest = {
+	paymentKey: string;
 	orderID: string;
-	transactionID: string;
-	method: string
-}
+	/** successUrl 쿼리의 amount (정수). 서버가 저장 주문 금액과 대조해 변조를 차단한다. */
+	amount: number;
+};

+ 15 - 8
types/response/payment/charge.ts

@@ -1,18 +1,25 @@
-// 다날 충전 응답
-export type DanalCreateOrderResponse = {
+// 토스페이먼츠 V2 충전 응답 타입
+
+export type TossCreateOrderResponse = {
 	orderID: string;
-	clientKey: string;
-	merchantID: string;
+	orderName: string;
+	/** VAT 포함 최종 결제 금액 (정수) */
 	amount: number;
+	/** 충전되는 캐시 (VAT 제외) */
 	pointAmount: number;
+	/** 부가세 (10%) */
 	vatAmount: number;
-	successUrl: string;
-	failUrl: string;
+	clientKey: string;
+	/** 회원별 UUID — widgets({ customerKey }) 초기화에 사용 */
+	customerKey: string;
 };
 
-export type DanalConfirmResponse = {
+export type TossConfirmResponse = {
 	success: boolean;
+	/** PaymentOrder.Status 문자열 (Paid / WaitingDeposit / Failed / NeedsReconciliation ...) */
+	status: string;
+	/** 충전된 캐시 (가상계좌 입금 대기 시 0) */
 	pointAmount: number;
 	message: string|null;
 	paidAt: string|null;
-};
+};