diff --git a/app/api/payment/confirm/route.ts b/app/api/payment/confirm/route.ts index d013de0..e72da97 100644 --- a/app/api/payment/confirm/route.ts +++ b/app/api/payment/confirm/route.ts @@ -15,20 +15,12 @@ export async function POST(request: NextRequest) { } const body = await request.json(); - const { paymentKey, orderId, amount } = body; + const { paymentId } = body; // ── 기본 파라미터 검증 ──────────────────────────────────── - if (!paymentKey || !orderId || amount === undefined) { + if (!paymentId || typeof paymentId !== 'string' || paymentId.length > 200) { return NextResponse.json({ error: '필수 파라미터 누락' }, { status: 400 }); } - // 타입 강제 검증 - if ( - typeof paymentKey !== 'string' || paymentKey.length > 200 || - typeof orderId !== 'string' || orderId.length > 200 || - typeof amount !== 'number' || amount <= 0 || !Number.isInteger(amount) - ) { - return NextResponse.json({ error: '잘못된 파라미터 형식' }, { status: 400 }); - } // ── 로그인 사용자 확인 ──────────────────────────────────── const supabase = await createClient(); @@ -37,66 +29,73 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: '로그인이 필요합니다' }, { status: 401 }); } - // ── DB에서 주문 확인 (금액 서버사이드 검증) ─────────────── + // ── DB에서 주문 확인 ────────────────────────────────────── const { data: order, error: orderFetchError } = await supabase .from('orders') .select('*') - .eq('id', orderId) + .eq('id', paymentId) .single(); if (orderFetchError || !order) { return NextResponse.json({ error: '주문을 찾을 수 없습니다' }, { status: 404 }); } - // 주문 소유자 검증 (다른 사용자 주문 처리 방지) if (order.user_id !== user.id) { return NextResponse.json({ error: '접근 권한이 없습니다' }, { status: 403 }); } - // 서버 DB 금액과 비교 (클라이언트 금액 위조 방어) - if (order.amount !== amount) { - console.warn(`[Payment] 금액 불일치 orderId=${orderId} db=${order.amount} req=${amount} user=${user.id}`); - return NextResponse.json({ error: '결제 금액이 올바르지 않습니다' }, { status: 400 }); - } if (order.status === 'paid') { return NextResponse.json({ error: '이미 처리된 주문입니다' }, { status: 400 }); } - // ── 토스페이먼츠 서버 승인 ──────────────────────────────── - const secretKey = process.env.TOSS_SECRET_KEY; - if (!secretKey) { - console.error('[Payment] TOSS_SECRET_KEY 미설정'); + // ── 포트원 V2 결제 조회 API ─────────────────────────────── + const apiSecret = process.env.PORTONE_API_SECRET; + if (!apiSecret) { + console.error('[Payment] PORTONE_API_SECRET 미설정'); return NextResponse.json({ error: '결제 서비스 설정 오류' }, { status: 500 }); } - if (!secretKey.startsWith('test_') && process.env.NODE_ENV === 'development') { - console.warn('[Payment] WARNING: live Toss key detected in development!'); - } - const encoded = Buffer.from(`${secretKey}:`).toString('base64'); - const tossRes = await fetch('https://api.tosspayments.com/v1/payments/confirm', { - method: 'POST', - headers: { - Authorization: `Basic ${encoded}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ paymentKey, orderId, amount }), - }); + const portoneRes = await fetch( + `https://api.portone.io/payments/${encodeURIComponent(paymentId)}`, + { + method: 'GET', + headers: { + Authorization: `PortOne ${apiSecret}`, + 'Content-Type': 'application/json', + }, + } + ); - if (!tossRes.ok) { - const err = await tossRes.json().catch(() => ({})); - // 내부 에러 코드는 서버 로그에만 기록 - console.error(`[Payment] Toss 승인 실패 orderId=${orderId} code=${err.code} msg=${err.message}`); + if (!portoneRes.ok) { + const err = await portoneRes.json().catch(() => ({})); + console.error(`[Payment] 포트원 조회 실패 paymentId=${paymentId} status=${portoneRes.status}`, err); return NextResponse.json( - { error: '결제 승인에 실패했습니다. 카드사 또는 고객센터에 문의해주세요.' }, + { error: '결제 확인에 실패했습니다. 고객센터에 문의해주세요.' }, { status: 400 } ); } - const tossData = await tossRes.json(); + const paymentData = await portoneRes.json(); + + // ── 결제 상태 & 금액 검증 ───────────────────────────────── + if (paymentData.status !== 'PAID') { + console.warn(`[Payment] 미완료 결제 paymentId=${paymentId} status=${paymentData.status}`); + return NextResponse.json( + { error: '결제가 완료되지 않았습니다.' }, + { status: 400 } + ); + } + + // 서버 DB 금액과 포트원 결제 금액 비교 (위조 방어) + const paidAmount = paymentData.amount?.total; + if (paidAmount !== order.amount) { + console.warn(`[Payment] 금액 불일치 paymentId=${paymentId} db=${order.amount} paid=${paidAmount} user=${user.id}`); + return NextResponse.json({ error: '결제 금액이 올바르지 않습니다' }, { status: 400 }); + } // ── orders 상태 업데이트 ────────────────────────────────── const { error: updateError } = await supabase .from('orders') .update({ status: 'paid' }) - .eq('id', orderId); + .eq('id', paymentId); if (updateError) { console.error('[Payment] Order update error:', updateError.message); @@ -104,14 +103,15 @@ export async function POST(request: NextRequest) { } // ── payments 레코드 생성 ────────────────────────────────── + const pgPaymentId = paymentData.pgResponse?.pgTxId ?? paymentData.paymentId ?? paymentId; const { error: paymentError } = await supabase.from('payments').insert({ user_id: order.user_id, - order_id: orderId, + order_id: paymentId, product_name: order.metadata?.product_name ?? order.product_id, amount: order.amount, status: 'paid', - pg_provider: 'toss', - pg_payment_key: paymentKey, + pg_provider: 'portone_kcp', + pg_payment_key: pgPaymentId, }); if (paymentError) { @@ -119,7 +119,15 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: '결제 내역 저장 실패' }, { status: 500 }); } - return NextResponse.json({ success: true, data: tossData }); + return NextResponse.json({ + success: true, + data: { + paymentId, + orderName: paymentData.orderName, + amount: paidAmount, + status: paymentData.status, + }, + }); } catch (error: unknown) { console.error('[Payment] Unexpected error:', error); return NextResponse.json({ error: '서버 오류가 발생했습니다' }, { status: 500 }); diff --git a/app/components/DashboardShell.tsx b/app/components/DashboardShell.tsx index 4bb3b5f..c12e959 100644 --- a/app/components/DashboardShell.tsx +++ b/app/components/DashboardShell.tsx @@ -55,6 +55,11 @@ export default function DashboardShell({ children }: { children: React.ReactNode 전화: 010-3907-1392 이메일: bgg8988@gmail.com +
© 2025 쟁승메이드. All rights reserved.
diff --git a/app/components/PaymentButton.tsx b/app/components/PaymentButton.tsx index aeed4fc..37ebc6d 100644 --- a/app/components/PaymentButton.tsx +++ b/app/components/PaymentButton.tsx @@ -4,6 +4,8 @@ import { useState } from 'react'; import { useRouter } from 'next/navigation'; import { createClient } from '@/lib/supabase/client'; import { PRODUCTS } from '@/lib/products'; +import { getActiveChannels, type PaymentChannel } from '@/lib/payment-channels'; +import PortOne from '@portone/browser-sdk/v2'; interface PaymentButtonProps { productId: string; @@ -15,11 +17,14 @@ interface PaymentButtonProps { export default function PaymentButton({ productId, className, style, children, returnUrl }: PaymentButtonProps) { const [loading, setLoading] = useState(false); + const [showMethodPicker, setShowMethodPicker] = useState(false); const router = useRouter(); const supabase = createClient(); const product = PRODUCTS[productId]; + const channels = getActiveChannels(); - const handlePayment = async () => { + const processPayment = async (channel: PaymentChannel) => { + setShowMethodPicker(false); setLoading(true); try { // 1. 로그인 확인 @@ -29,85 +34,171 @@ export default function PaymentButton({ productId, className, style, children, r return; } - // 2. 프로필 없으면 생성 (Google OAuth 등으로 트리거 미실행된 경우 대비) + // 2. 프로필 없으면 생성 await supabase.from('profiles').upsert({ id: user.id, email: user.email }, { onConflict: 'id' }); // 3. Supabase에 order 생성 - const orderId = crypto.randomUUID(); + const paymentId = `order_${Date.now()}_${crypto.randomUUID().slice(0, 8)}`; const { error: orderError } = await supabase .from('orders') .insert({ - id: orderId, + id: paymentId, user_id: user.id, product_id: productId, amount: product.price, status: 'pending', - metadata: { product_name: product.name }, + metadata: { product_name: product.name, pay_channel: channel.id }, }); if (orderError) throw new Error('주문 생성 실패: ' + orderError.message); - // 4. 토스페이먼츠 결제창 호출 - // dev: NEXT_PUBLIC_TOSS_CLIENT_KEY=test_ck_* → 테스트 결제 (실제 청구 없음) - // prod: NEXT_PUBLIC_TOSS_CLIENT_KEY=live_ck_* → 실결제 (Vercel 환경변수에 설정) - const { loadTossPayments } = await import('@tosspayments/tosspayments-sdk'); - const clientKey = process.env.NEXT_PUBLIC_TOSS_CLIENT_KEY!; - const tossPayments = await loadTossPayments(clientKey); + // 4. 포트원 V2 결제 요청 + const storeId = process.env.NEXT_PUBLIC_PORTONE_STORE_ID!; - const payment = tossPayments.payment({ - customerKey: user.id, - }); - - await payment.requestPayment({ - method: 'CARD', - amount: { - currency: 'KRW', - value: product.price, - }, - orderId, + const response = await PortOne.requestPayment({ + storeId, + channelKey: channel.channelKey, + paymentId, orderName: product.name, - successUrl: `${window.location.origin}/payment/success${returnUrl ? '?returnUrl=' + encodeURIComponent(returnUrl) : ''}`, - failUrl: `${window.location.origin}/payment/fail`, - customerEmail: user.email, + totalAmount: product.price, + currency: 'CURRENCY_KRW', + payMethod: channel.payMethod, + customer: { + email: user.email ?? undefined, + }, }); + + // 5. 결제 결과 처리 + if (!response || response.code != null) { + if (response?.code === 'FAILURE_TYPE_PG' || response?.message?.includes('cancel')) { + return; + } + throw new Error(response?.message ?? '결제 요청 실패'); + } + + // 6. 서버에서 결제 검증 + const confirmRes = await fetch('/api/payment/confirm', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ paymentId }), + }); + + const confirmData = await confirmRes.json(); + + if (!confirmRes.ok || !confirmData.success) { + throw new Error(confirmData.error || '결제 검증에 실패했습니다.'); + } + + // 7. 결제 성공 + if (returnUrl) { + router.push(returnUrl); + } else { + router.push(`/payment/success?paymentId=${paymentId}`); + } } catch (err: unknown) { const error = err as { code?: string; message?: string }; - // 사용자가 결제창 닫은 경우는 무시 - if (error?.code !== 'USER_CANCEL') { - alert('결제 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.'); - console.error(err); + if (error?.code === 'USER_CANCEL' || error?.message?.includes('cancel')) { + return; } + alert('결제 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.'); + console.error(err); } finally { setLoading(false); } }; + const handleClick = () => { + if (channels.length === 0) { + alert('결제 서비스가 준비 중입니다.'); + return; + } + // 채널이 1개면 바로 결제, 여러 개면 선택 UI + if (channels.length === 1) { + processPayment(channels[0]); + } else { + setShowMethodPicker(true); + } + }; + if (!product) return null; - const isTestMode = process.env.NEXT_PUBLIC_TOSS_CLIENT_KEY?.startsWith('test_'); + const isTestMode = process.env.NEXT_PUBLIC_PORTONE_STORE_ID?.includes('test') + || process.env.NODE_ENV === 'development'; return ( -+ {product.name} · {product.price.toLocaleString()}원 +
++ 쟁승메이드(이하 "회사")는 개인정보보호법 등 관련 법령을 준수하며, + 이용자의 개인정보를 보호하기 위해 다음과 같은 개인정보처리방침을 수립·공개합니다. +
+ +회사는 서비스 제공을 위해 다음의 개인정보를 수집합니다.
+| 수집 시점 | +수집 항목 | +
|---|---|
| 회원가입 | +이메일, 이름(닉네임) | +
| 문의 접수 | +이름, 이메일, 연락처, 문의 내용 | +
| 유료 결제 | +결제 수단 정보(PG사를 통해 처리, 회사 직접 저장하지 않음) | +
| 사주 분석 | +생년월일, 출생시간, 성별 | +
| 자동 수집 | +접속 IP, 브라우저 종류, 접속 일시, 쿠키 | +
+ 회사는 이용자의 동의 없이 개인정보를 제3자에게 제공하지 않습니다. + 다만, 다음의 경우는 예외로 합니다. +
+| 수탁업체 | +위탁 업무 | +
|---|---|
| 포트원(주) | +전자결제 처리 | +
| Supabase Inc. | +회원 인증 및 데이터 저장 | +
| Vercel Inc. | +웹사이트 호스팅 | +
| Resend Inc. | +이메일 발송 | +
이용자는 언제든지 다음의 권리를 행사할 수 있습니다.
++ 위 요청은 이메일(bgg8988@gmail.com)로 접수하면 10일 이내에 처리합니다. +
++ 회사는 서비스 이용 분석을 위해 Google Analytics를 사용하며, 이 과정에서 쿠키가 설치됩니다. + 이용자는 브라우저 설정을 통해 쿠키 저장을 거부할 수 있으나, 일부 서비스 이용이 제한될 수 있습니다. +
+개인정보 침해에 대한 신고·상담은 아래 기관에 문의하실 수 있습니다.
+
+ 시행일자: 2025년 4월 7일
+ 상호: 쟁승메이드 · 대표: 박재오 · 사업자등록번호: 267-53-00822
+
+ 쟁승메이드(이하 "회사")는 공정하고 투명한 환불 정책을 운영합니다. + 서비스 유형에 따라 환불 조건이 다르므로 아래 내용을 확인해주세요. +
+ +대상: 프롬프트 패키지, AI 사주 리포트, 로또 분석 등
+| 시점 | +환불 가능 여부 | +
|---|---|
| 결제 후 다운로드/열람 전 | +전액 환불 | +
| 다운로드/열람 후 7일 이내 | +서비스 하자 시 환불 (정상 제공 시 환불 불가) | +
| 7일 경과 | +환불 불가 | +
+ * 전자상거래법 제17조에 따라, 디지털 콘텐츠는 이용 후 청약철회가 제한될 수 있습니다. +
+대상: AI 자동화 키트 월 구독, 로또 월간 플랜, 주식 월 유지비 등
+| 시점 | +환불 가능 여부 | +
|---|---|
| 결제 후 서비스 이용 전 | +전액 환불 | +
| 이용 시작 후 7일 이내 | +이용일수 공제 후 환불 | +
| 7일 경과 | +당월 환불 불가, 다음 결제일 전 해지 가능 | +
+ * 구독 해지는 마이페이지 또는 이메일(bgg8988@gmail.com)로 요청 가능합니다. + 해지 시 다음 결제일부터 과금이 중지되며, 당월 잔여 기간은 계속 이용 가능합니다. +
+대상: 홈페이지 제작, 업무 자동화, 맞춤 개발 등
+| 시점 | +환불 가능 여부 | +
|---|---|
| 착수 전 (선금 결제 후 작업 시작 전) | +선금 전액 환불 | +
| 작업 진행 중 | +진행률에 따른 부분 환불 (협의) | +
| 납품 완료 후 | +환불 불가 (하자 보수는 별도) | +
+ * 외주 개발은 선금 50% / 잔금 50% 구조이며, 계약서에 명시된 조건을 우선합니다.
+
+ * 회사 귀책으로 납기 지연 시 1일당 계약금의 1%를 차감합니다.
+
+ 시행일자: 2025년 4월 7일
+ 상호: 쟁승메이드 · 대표: 박재오 · 사업자등록번호: 267-53-00822
+ 환불 문의: bgg8988@gmail.com · 010-3907-1392
+
+ 본 약관은 쟁승메이드(이하 "회사")가 운영하는 웹사이트(jaengseung-made.com)를 통해 + 제공하는 서비스(이하 "서비스")의 이용 조건 및 절차, 회사와 이용자의 권리·의무 및 + 책임사항을 규정함을 목적으로 합니다. +
++ 회사는 컴퓨터 등 정보통신설비의 보수·점검, 교체 및 고장, 통신두절 또는 천재지변 등의 사유가 + 발생한 경우 서비스의 제공을 일시적으로 중단할 수 있습니다. 이 경우 회사는 가능한 한 사전에 공지합니다. +
++ 본 약관에 관한 분쟁은 대한민국 법률을 준거법으로 하며, 서울중앙지방법원을 관할법원으로 합니다. +
+
+ 시행일자: 2025년 4월 7일
+ 상호: 쟁승메이드 · 대표: 박재오 · 사업자등록번호: 267-53-00822
+
{message}
결제를 확인하는 중...
-{errorMsg}
-{productName}
+ {paymentId && ( +주문번호: {paymentId}
)} -+
마이페이지에서 결제 내역과 서비스 이용 현황을 확인하세요.