chore(phase0): PortOne 잔재 제거 — 계좌이체 단일 소스 확정, saju 결제 CTA 제거

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-02 14:12:38 +09:00
parent 8e1cf9b4e1
commit 1e926fcb19
11 changed files with 4 additions and 729 deletions

View File

@@ -1,135 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { checkRateLimit, getClientIp } from '@/lib/security';
export async function POST(request: NextRequest) {
try {
// ── Rate Limit: IP당 1분 10회 (결제 재시도 남용 방지) ─────
const ip = getClientIp(request);
const rl = checkRateLimit(`payment:${ip}`, 60_000, 10);
if (!rl.allowed) {
return NextResponse.json(
{ error: '요청이 너무 많습니다. 잠시 후 다시 시도해주세요.' },
{ status: 429 }
);
}
const body = await request.json();
const { paymentId } = body;
// ── 기본 파라미터 검증 ────────────────────────────────────
if (!paymentId || typeof paymentId !== 'string' || paymentId.length > 200) {
return NextResponse.json({ error: '필수 파라미터 누락' }, { status: 400 });
}
// ── 로그인 사용자 확인 ────────────────────────────────────
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: '로그인이 필요합니다' }, { status: 401 });
}
// ── DB에서 주문 확인 ──────────────────────────────────────
const { data: order, error: orderFetchError } = await supabase
.from('orders')
.select('*')
.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 });
}
if (order.status === 'paid') {
return NextResponse.json({ error: '이미 처리된 주문입니다' }, { status: 400 });
}
// ── 포트원 V2 결제 조회 API ───────────────────────────────
const apiSecret = process.env.PORTONE_API_SECRET;
if (!apiSecret) {
console.error('[Payment] PORTONE_API_SECRET 미설정');
return NextResponse.json({ error: '결제 서비스 설정 오류' }, { status: 500 });
}
const portoneRes = await fetch(
`https://api.portone.io/payments/${encodeURIComponent(paymentId)}`,
{
method: 'GET',
headers: {
Authorization: `PortOne ${apiSecret}`,
'Content-Type': 'application/json',
},
}
);
if (!portoneRes.ok) {
const err = await portoneRes.json().catch(() => ({}));
console.error(`[Payment] 포트원 조회 실패 paymentId=${paymentId} status=${portoneRes.status}`, err);
return NextResponse.json(
{ error: '결제 확인에 실패했습니다. 고객센터에 문의해주세요.' },
{ status: 400 }
);
}
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', paymentId);
if (updateError) {
console.error('[Payment] Order update error:', updateError.message);
return NextResponse.json({ error: '주문 상태 업데이트 실패' }, { status: 500 });
}
// ── payments 레코드 생성 ──────────────────────────────────
const pgPaymentId = paymentData.pgResponse?.pgTxId ?? paymentData.paymentId ?? paymentId;
const { error: paymentError } = await supabase.from('payments').insert({
user_id: order.user_id,
order_id: paymentId,
product_name: order.metadata?.product_name ?? order.product_id,
amount: order.amount,
status: 'paid',
pg_provider: 'portone_kcp',
pg_payment_key: pgPaymentId,
});
if (paymentError) {
console.error('[Payment] Payment insert error:', paymentError.message);
return NextResponse.json({ error: '결제 내역 저장 실패' }, { status: 500 });
}
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 });
}
}

View File

@@ -1,202 +0,0 @@
'use client';
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;
className?: string;
style?: React.CSSProperties;
children: React.ReactNode;
returnUrl?: string;
}
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 processPayment = async (channel: PaymentChannel) => {
setShowMethodPicker(false);
setLoading(true);
try {
// 1. 로그인 확인
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
router.push('/login?next=' + encodeURIComponent(window.location.pathname));
return;
}
// 2. 프로필 없으면 생성
await supabase.from('profiles').upsert({ id: user.id, email: user.email }, { onConflict: 'id' });
// 3. Supabase에 order 생성
const paymentId = crypto.randomUUID();
const { error: orderError } = await supabase
.from('orders')
.insert({
id: paymentId,
user_id: user.id,
product_id: productId,
amount: product.price,
status: 'pending',
metadata: { product_name: product.name, pay_channel: channel.id },
});
if (orderError) throw new Error('주문 생성 실패: ' + orderError.message);
// 4. 포트원 V2 결제 요청
const response = await PortOne.requestPayment({
storeId: process.env.NEXT_PUBLIC_PORTONE_STORE_ID ?? '',
channelKey: channel.channelKey,
paymentId,
orderName: product.name,
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' || 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_PORTONE_STORE_ID
|| process.env.NODE_ENV === 'development';
return (
<>
<div style={{ display: style ? 'block' : 'inline-block', position: 'relative' }}>
<button
onClick={handleClick}
disabled={loading}
className={className}
style={style}
>
{loading ? '결제 처리 중...' : children}
</button>
{isTestMode && (
<span style={{
position: 'absolute', top: -8, right: -8,
background: '#f59e0b', color: '#fff',
fontSize: 9, fontWeight: 800, letterSpacing: '0.05em',
padding: '2px 6px', borderRadius: 4,
pointerEvents: 'none', userSelect: 'none',
}}>
TEST
</span>
)}
</div>
{/* 결제수단 선택 모달 */}
{showMethodPicker && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"
onClick={() => setShowMethodPicker(false)}
>
<div
className="bg-white rounded-2xl shadow-2xl w-full max-w-sm mx-4 overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
<div className="bg-[#04102b] px-5 py-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded-md bg-[#1a56db] flex items-center justify-center text-white font-bold text-[10px]">
</div>
<span className="text-white font-bold text-sm"> </span>
</div>
<button
onClick={() => setShowMethodPicker(false)}
className="text-white/60 hover:text-white transition text-lg leading-none"
>
</button>
</div>
<div className="p-4">
<p className="text-slate-500 text-xs mb-3">
{product.name} · {product.price.toLocaleString()}
</p>
<div className="space-y-2">
{channels.map((channel) => (
<button
key={channel.id}
onClick={() => processPayment(channel)}
className="w-full flex items-center gap-3 px-4 py-3.5 rounded-xl border border-slate-200 hover:border-[#1a56db] hover:bg-blue-50/50 transition text-left group"
>
<span className="text-xl">{channel.icon}</span>
<span className="text-sm font-semibold text-slate-700 group-hover:text-[#1a56db]">
{channel.label}
</span>
<svg className="w-4 h-4 text-slate-300 group-hover:text-[#1a56db] ml-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
))}
</div>
</div>
</div>
</div>
)}
</>
);
}

View File

@@ -1,62 +0,0 @@
'use client';
import { Suspense } from 'react';
import { useSearchParams } from 'next/navigation';
import Link from 'next/link';
function FailContent() {
const params = useSearchParams();
const message = params.get('message') ?? '결제가 취소되었거나 실패했습니다.';
const code = params.get('code') ?? '';
return (
<div className="text-center py-20 px-6">
<div className="w-16 h-16 rounded-full bg-slate-100 border-2 border-slate-200 flex items-center justify-center mx-auto mb-5">
<svg className="w-8 h-8 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<div className="inline-block bg-slate-100 border border-slate-200 text-slate-600 text-xs font-bold px-3 py-1 rounded-full mb-4">
{code === 'USER_CANCEL' || code === 'PAY_PROCESS_CANCELED' ? '결제 취소' : '결제 실패'}
</div>
<h2 className="text-xl font-bold mb-2" style={{ color: 'var(--jsm-ink)' }}>
{code === 'USER_CANCEL' || code === 'PAY_PROCESS_CANCELED' ? '결제를 취소하셨습니다' : '결제에 실패했습니다'}
</h2>
<p className="text-slate-500 text-sm mb-8 max-w-xs mx-auto leading-relaxed">{message}</p>
<div className="flex justify-center gap-3 flex-wrap">
<button
onClick={() => window.history.back()}
className="inline-flex items-center gap-2 bg-[#1a56db] hover:bg-[#1e4fc2] text-white px-6 py-3 rounded-xl font-semibold text-sm shadow-lg shadow-blue-600/20 transition"
>
</button>
<Link
href="/"
className="inline-flex items-center gap-2 bg-white border border-[#dbe8ff] text-slate-600 px-6 py-3 rounded-xl font-semibold text-sm hover:bg-slate-50 transition"
>
</Link>
</div>
</div>
);
}
export default function PaymentFailPage() {
return (
<div className="min-h-full bg-[#f0f5ff] flex items-center justify-center px-6 py-16">
<div className="w-full max-w-md bg-white rounded-2xl border border-[#dbe8ff] shadow-lg overflow-hidden">
<div className="px-6 py-4 border-b" style={{ background: 'var(--jsm-navy)' }}>
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-lg flex items-center justify-center text-white font-bold text-xs" style={{ background: 'var(--jsm-accent)' }}>
</div>
<span className="text-white font-bold text-sm"> </span>
</div>
</div>
<Suspense fallback={<div className="py-20 text-center text-slate-400 text-sm"> ...</div>}>
<FailContent />
</Suspense>
</div>
</div>
);
}

View File

@@ -1,68 +0,0 @@
'use client';
import { Suspense } from 'react';
import { useSearchParams } from 'next/navigation';
import Link from 'next/link';
function SuccessContent() {
const params = useSearchParams();
const paymentId = params.get('paymentId');
return (
<div className="text-center py-20 px-6">
<div className="w-16 h-16 rounded-full bg-emerald-50 border-2 border-emerald-400 flex items-center justify-center mx-auto mb-5">
<svg className="w-8 h-8 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
</svg>
</div>
<div className="inline-block bg-emerald-50 border border-emerald-200 text-emerald-700 text-xs font-bold px-3 py-1 rounded-full mb-4">
</div>
<h2 className="text-2xl font-extrabold mb-2" style={{ color: 'var(--jsm-ink)' }}> !</h2>
{paymentId && (
<p className="text-slate-400 text-xs mb-1">: {paymentId}</p>
)}
<p className="text-slate-500 text-sm mb-8">
.
</p>
<div className="flex justify-center gap-3 flex-wrap">
<Link
href="/mypage?tab=payments"
className="inline-flex items-center gap-2 bg-[#1a56db] hover:bg-[#1e4fc2] text-white px-6 py-3 rounded-xl font-semibold text-sm shadow-lg shadow-blue-600/20 transition"
>
</Link>
<Link
href="/"
className="inline-flex items-center gap-2 bg-white border border-[#dbe8ff] text-slate-600 px-6 py-3 rounded-xl font-semibold text-sm hover:bg-slate-50 transition"
>
</Link>
</div>
</div>
);
}
export default function PaymentSuccessPage() {
return (
<div className="min-h-full bg-[#f0f5ff] flex items-center justify-center px-6 py-16">
<div className="w-full max-w-md bg-white rounded-2xl border border-[#dbe8ff] shadow-lg overflow-hidden">
<div className="px-6 py-4 border-b" style={{ background: 'var(--jsm-navy)' }}>
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-lg flex items-center justify-center text-white font-bold text-xs" style={{ background: 'var(--jsm-accent)' }}>
</div>
<span className="text-white font-bold text-sm"> </span>
</div>
</div>
<Suspense fallback={
<div className="py-20 text-center">
<div className="w-8 h-8 border-2 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto" />
</div>
}>
<SuccessContent />
</Suspense>
</div>
</div>
);
}

View File

@@ -1,53 +0,0 @@
'use client';
import PaymentButton from '@/app/components/PaymentButton';
import { PRODUCTS } from '@/lib/products';
// DB products 테이블에 등록된 상품만 테스트 가능
const TEST_PRODUCTS = [
'saju_detail', // 1,000원
];
export default function PaymentTestPage() {
return (
<div className="max-w-2xl mx-auto px-6 py-12">
<div className="mb-8">
<h1 className="text-2xl font-extrabold text-[#04102b] mb-2"> </h1>
<p className="text-slate-500 text-sm">
V2 .
</p>
<div className="mt-3 bg-amber-50 border border-amber-200 text-amber-800 text-xs px-4 py-2.5 rounded-xl">
. .
</div>
</div>
<div className="space-y-4">
{TEST_PRODUCTS.map((id) => {
const product = PRODUCTS[id];
if (!product) return null;
return (
<div
key={id}
className="flex items-center justify-between bg-white border border-slate-200 rounded-xl px-5 py-4"
>
<div>
<p className="font-semibold text-sm text-slate-800">{product.name}</p>
<p className="text-xs text-slate-400 mt-0.5">
{product.price.toLocaleString()}
{product.type === 'monthly' && ' / 월'}
<span className="ml-2 text-slate-300">({id})</span>
</p>
</div>
<PaymentButton
productId={id}
className="bg-[#1a56db] hover:bg-[#1e4fc2] text-white px-5 py-2.5 rounded-xl text-sm font-bold transition shadow-lg shadow-blue-600/20"
>
</PaymentButton>
</div>
);
})}
</div>
</div>
);
}

View File

@@ -2,7 +2,6 @@
import { useEffect, useState } from 'react';
import Link from 'next/link';
import PaymentButton from '@/app/components/PaymentButton';
import { createClient } from '@/lib/supabase/client';
const faqItems = [

View File

@@ -3,7 +3,6 @@
import { useState, useEffect, useRef } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import PaymentButton from '@/app/components/PaymentButton';
interface BirthKey {
birth_year: number;
@@ -313,13 +312,10 @@ export default function SajuAISection({
))}
</div>
<PaymentButton
productId="saju_detail"
className="inline-flex items-center gap-2 bg-amber-400 hover:bg-amber-300 text-[#04102b] font-bold px-7 py-3 rounded-xl transition-all"
>
AI 1,000
</PaymentButton>
<p className="text-blue-200/40 text-xs mt-3"> AI · </p>
<p className="inline-flex items-center gap-2 bg-white/10 text-blue-100/80 font-semibold px-7 py-3 rounded-xl">
AI
</p>
<p className="text-blue-200/40 text-xs mt-3"> (Phase 2) </p>
</div>
</div>
);