From 1e926fcb192b18a89c689cbbea86ce252cea84d5 Mon Sep 17 00:00:00 2001 From: gahusb Date: Thu, 2 Jul 2026 14:12:38 +0900 Subject: [PATCH] =?UTF-8?q?chore(phase0):=20PortOne=20=EC=9E=94=EC=9E=AC?= =?UTF-8?q?=20=EC=A0=9C=EA=B1=B0=20=E2=80=94=20=EA=B3=84=EC=A2=8C=EC=9D=B4?= =?UTF-8?q?=EC=B2=B4=20=EB=8B=A8=EC=9D=BC=20=EC=86=8C=EC=8A=A4=20=ED=99=95?= =?UTF-8?q?=EC=A0=95,=20saju=20=EA=B2=B0=EC=A0=9C=20CTA=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- app/api/payment/confirm/route.ts | 135 ----------------- app/components/PaymentButton.tsx | 202 ------------------------- app/payment/fail/page.tsx | 62 -------- app/payment/success/page.tsx | 68 --------- app/payment/test/page.tsx | 53 ------- app/work/saju/page.tsx | 1 - app/work/saju/result/SajuAISection.tsx | 12 +- lib/payment-channels.ts | 50 ------ lib/products.ts | 143 ----------------- package-lock.json | 6 - package.json | 1 - 11 files changed, 4 insertions(+), 729 deletions(-) delete mode 100644 app/api/payment/confirm/route.ts delete mode 100644 app/components/PaymentButton.tsx delete mode 100644 app/payment/fail/page.tsx delete mode 100644 app/payment/success/page.tsx delete mode 100644 app/payment/test/page.tsx delete mode 100644 lib/payment-channels.ts delete mode 100644 lib/products.ts diff --git a/app/api/payment/confirm/route.ts b/app/api/payment/confirm/route.ts deleted file mode 100644 index e72da97..0000000 --- a/app/api/payment/confirm/route.ts +++ /dev/null @@ -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 }); - } -} diff --git a/app/components/PaymentButton.tsx b/app/components/PaymentButton.tsx deleted file mode 100644 index 250f017..0000000 --- a/app/components/PaymentButton.tsx +++ /dev/null @@ -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 ( - <> -
- - {isTestMode && ( - - TEST - - )} -
- - {/* 결제수단 선택 모달 */} - {showMethodPicker && ( -
setShowMethodPicker(false)} - > -
e.stopPropagation()} - > -
-
-
- 쟁 -
- 결제수단 선택 -
- -
- -
-

- {product.name} · {product.price.toLocaleString()}원 -

-
- {channels.map((channel) => ( - - ))} -
-
-
-
- )} - - ); -} diff --git a/app/payment/fail/page.tsx b/app/payment/fail/page.tsx deleted file mode 100644 index a417324..0000000 --- a/app/payment/fail/page.tsx +++ /dev/null @@ -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 ( -
-
- - - -
-
- {code === 'USER_CANCEL' || code === 'PAY_PROCESS_CANCELED' ? '결제 취소' : '결제 실패'} -
-

- {code === 'USER_CANCEL' || code === 'PAY_PROCESS_CANCELED' ? '결제를 취소하셨습니다' : '결제에 실패했습니다'} -

-

{message}

-
- - - 홈으로 - -
-
- ); -} - -export default function PaymentFailPage() { - return ( -
-
-
-
-
- 쟁 -
- 쟁승메이드 결제 -
-
- 로딩 중...
}> - - -
- - ); -} diff --git a/app/payment/success/page.tsx b/app/payment/success/page.tsx deleted file mode 100644 index c747a14..0000000 --- a/app/payment/success/page.tsx +++ /dev/null @@ -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 ( -
-
- - - -
-
- 결제 완료 -
-

결제가 완료되었습니다!

- {paymentId && ( -

주문번호: {paymentId}

- )} -

- 마이페이지에서 결제 내역과 서비스 이용 현황을 확인하세요. -

-
- - 결제 내역 확인 → - - - 홈으로 - -
-
- ); -} - -export default function PaymentSuccessPage() { - return ( -
-
-
-
-
- 쟁 -
- 쟁승메이드 결제 -
-
- -
-
- }> - -
-
-
- ); -} diff --git a/app/payment/test/page.tsx b/app/payment/test/page.tsx deleted file mode 100644 index 83158f1..0000000 --- a/app/payment/test/page.tsx +++ /dev/null @@ -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 ( -
-
-

결제 테스트

-

- 포트원 V2 테스트 모드 — 실제 청구되지 않습니다. -

-
- 이 페이지는 관리자 테스트 전용입니다. 배포 전 삭제하세요. -
-
- -
- {TEST_PRODUCTS.map((id) => { - const product = PRODUCTS[id]; - if (!product) return null; - return ( -
-
-

{product.name}

-

- {product.price.toLocaleString()}원 - {product.type === 'monthly' && ' / 월'} - ({id}) -

-
- - 결제 테스트 - -
- ); - })} -
-
- ); -} diff --git a/app/work/saju/page.tsx b/app/work/saju/page.tsx index 3032558..be5f288 100644 --- a/app/work/saju/page.tsx +++ b/app/work/saju/page.tsx @@ -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 = [ diff --git a/app/work/saju/result/SajuAISection.tsx b/app/work/saju/result/SajuAISection.tsx index 4581af3..4f7284a 100644 --- a/app/work/saju/result/SajuAISection.tsx +++ b/app/work/saju/result/SajuAISection.tsx @@ -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({ ))} - - AI 상세 해석 받기 — 1,000원 - -

결제 후 즉시 AI 분석 시작 · 로그인 필요

+

+ AI 상세 해석은 서비스 개편 준비 중입니다 +

+

사주 서비스 개편(Phase 2)에서 무료 제공 예정

); diff --git a/lib/payment-channels.ts b/lib/payment-channels.ts deleted file mode 100644 index 0ab839d..0000000 --- a/lib/payment-channels.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** - * 포트원 V2 결제 채널 설정 - * 포트원 admin에서 채널 추가 후 각 Channel Key를 .env.local에 설정 - */ - -export type PayMethod = 'CARD' | 'EASY_PAY'; - -export interface PaymentChannel { - id: string; - label: string; - icon: string; // SVG or emoji placeholder - channelKey: string; - payMethod: PayMethod; -} - -export const PAYMENT_CHANNELS: PaymentChannel[] = [ - { - id: 'card', - label: '신용/체크카드', - icon: '💳', - channelKey: process.env.NEXT_PUBLIC_PORTONE_CHANNEL_KPN ?? '', - payMethod: 'CARD', - }, - { - id: 'kakaopay', - label: '카카오페이', - icon: '🟡', - channelKey: process.env.NEXT_PUBLIC_PORTONE_CHANNEL_KAKAOPAY ?? '', - payMethod: 'EASY_PAY', - }, - { - id: 'naverpay', - label: '네이버페이', - icon: '🟢', - channelKey: process.env.NEXT_PUBLIC_PORTONE_CHANNEL_NAVERPAY ?? '', - payMethod: 'EASY_PAY', - }, - { - id: 'tosspay', - label: '토스페이', - icon: '🔵', - channelKey: process.env.NEXT_PUBLIC_PORTONE_CHANNEL_TOSSPAY ?? '', - payMethod: 'EASY_PAY', - }, -]; - -/** channelKey가 설정된 채널만 반환 */ -export function getActiveChannels(): PaymentChannel[] { - return PAYMENT_CHANNELS.filter((ch) => ch.channelKey.length > 0); -} diff --git a/lib/products.ts b/lib/products.ts deleted file mode 100644 index 61aa686..0000000 --- a/lib/products.ts +++ /dev/null @@ -1,143 +0,0 @@ -export interface Product { - id: string; - name: string; - price: number; - type: 'one_time' | 'monthly' | 'annual'; - description: string; -} - -export const PRODUCTS: Record = { - stock_starter_install: { - id: 'stock_starter_install', - name: '주식 스타터 설치', - price: 99000, - type: 'one_time', - description: '1개 종목 자동 매매 설치', - }, - stock_pro_install: { - id: 'stock_pro_install', - name: '주식 프로 설치', - price: 199000, - type: 'one_time', - description: '5개 종목 + 전략 커스터마이징 설치', - }, - stock_starter_monthly: { - id: 'stock_starter_monthly', - name: '주식 스타터 월 유지비', - price: 29000, - type: 'monthly', - description: '스타터 월 유지보수 비용', - }, - stock_pro_monthly: { - id: 'stock_pro_monthly', - name: '주식 프로 월 유지비', - price: 49000, - type: 'monthly', - description: '프로 월 유지보수 비용', - }, - saju_detail: { - id: 'saju_detail', - name: 'AI 사주 상세 리포트', - price: 1000, - type: 'one_time', - description: 'AI 12가지 항목 상세 해석', - }, - prompt_single: { - id: 'prompt_single', - name: '프롬프트 단건 설계', - price: 30000, - type: 'one_time', - description: '업무 특화 프롬프트 1개 맞춤 설계 · 수정 1회 포함', - }, - prompt_business: { - id: 'prompt_business', - name: '프롬프트 비즈니스 패키지', - price: 99000, - type: 'one_time', - description: '업무 유형별 프롬프트 5개 설계 · 수정 3회 · 1:1 교육 포함', - }, - prompt_team: { - id: 'prompt_team', - name: '프롬프트 팀/기업 패키지', - price: 249000, - type: 'one_time', - description: '팀 전체 프롬프트 시스템 구축 · 10개 이상 설계 · 교육 자료 포함', - }, - automation_basic: { - id: 'automation_basic', - name: '단순 업무 자동화', - price: 50000, - type: 'one_time', - description: '단일 반복 업무 자동화 1건 개발 · 1~3일 납품', - }, - automation_advanced: { - id: 'automation_advanced', - name: '업무 자동화 심화', - price: 150000, - type: 'one_time', - description: '복합 업무 자동화 개발 · RPA·API 연동 · 1~2주 납품', - }, - website_starter: { - id: 'website_starter', - name: '홈페이지 스타터 패키지', - price: 200000, - type: 'one_time', - description: '5페이지 이내 반응형 홈페이지 · 기본 SEO · 3~5영업일 납품', - }, - website_business: { - id: 'website_business', - name: '홈페이지 비즈니스 패키지', - price: 1000000, - type: 'one_time', - description: '10페이지 이내 · 관리자 페이지 · SEO 최적화 · 1~2주 납품', - }, - website_premium: { - id: 'website_premium', - name: '홈페이지 프리미엄 패키지', - price: 2000000, - type: 'one_time', - description: '페이지 수 무제한 · 결제/회원 시스템 · DB 연동 · 일정 협의', - }, - prompt_image_gen: { - id: 'prompt_image_gen', - name: 'AI 이미지 생성 마스터 프롬프트 패키지', - price: 12900, - type: 'one_time', - description: '50종 이미지 생성 프롬프트 · 구도/조명/후처리 공식 포함 · 즉시 다운로드', - }, - prompt_resume: { - id: 'prompt_resume', - name: 'AI 자소서·이력서 첨삭 마스터 프롬프트', - price: 9900, - type: 'one_time', - description: '7가지 유형별 자소서 프롬프트 · STAR 기법 · ATS 최적화 · 즉시 다운로드', - }, - prompt_email: { - id: 'prompt_email', - name: '비즈니스 이메일 마스터 프롬프트 패키지', - price: 10900, - type: 'one_time', - description: '20종 비즈니스 이메일 프롬프트 · 상황별 템플릿 · 즉시 다운로드', - }, - prompt_marketing: { - id: 'prompt_marketing', - name: '마케팅 카피·SNS 콘텐츠 프롬프트 패키지', - price: 12900, - type: 'one_time', - description: '플랫폼별 카피 프롬프트 30종 · 광고 문구 · SNS 캡션 · 즉시 다운로드', - }, - prompt_report: { - id: 'prompt_report', - name: '업무 보고서·기획서 작성 프롬프트 패키지', - price: 10900, - type: 'one_time', - description: '보고서/기획서/회의록 유형별 프롬프트 25종 · 즉시 다운로드', - }, - ai_kit_monthly: { - id: 'ai_kit_monthly', - name: 'AI 자동화 월 구독 키트', - price: 19900, - type: 'monthly', - description: '소상공인·직장인을 위한 AI 자동화 도구 월 구독 · 매월 업데이트', - }, -}; diff --git a/package-lock.json b/package-lock.json index 9f184fc..b9b45d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,6 @@ "@anthropic-ai/sdk": "^0.79.0", "@google-analytics/data": "^5.2.1", "@google/generative-ai": "^0.24.1", - "@portone/browser-sdk": "^0.1.3", "@supabase/ssr": "^0.5.2", "@supabase/supabase-js": "^2.99.0", "clsx": "^2.1.1", @@ -1381,11 +1380,6 @@ "node": ">=14" } }, - "node_modules/@portone/browser-sdk": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@portone/browser-sdk/-/browser-sdk-0.1.3.tgz", - "integrity": "sha512-GRmXb8gBLs/23CES1YRvzMggbL8+tFeH5WS9YSA6WUPu5/9kgRKKZLeC25N2raq3zVJd9OfDTeSf0tUr27vF1Q==" - }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", diff --git a/package.json b/package.json index 08870c6..54fc63c 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,6 @@ "@anthropic-ai/sdk": "^0.79.0", "@google-analytics/data": "^5.2.1", "@google/generative-ai": "^0.24.1", - "@portone/browser-sdk": "^0.1.3", "@supabase/ssr": "^0.5.2", "@supabase/supabase-js": "^2.99.0", "clsx": "^2.1.1",