diff --git a/apps/desktop/src/main/ipc/license-handlers.ts b/apps/desktop/src/main/ipc/license-handlers.ts index bd5d2dd..91a76eb 100644 --- a/apps/desktop/src/main/ipc/license-handlers.ts +++ b/apps/desktop/src/main/ipc/license-handlers.ts @@ -1,7 +1,7 @@ // src/main/ipc/license-handlers.ts // Phase 11: 라이센스 IPC 핸들러 -import { ipcMain, BrowserWindow } from 'electron' +import { ipcMain, BrowserWindow, shell } from 'electron' import { IPC_CHANNELS } from '@d3ro/core/ipc-channels' import { ipcSuccess, ipcError, ErrorCode } from '@d3ro/core/errors' import { getLicenseService } from '../services/LicenseService' @@ -97,4 +97,16 @@ export function registerLicenseHandlers(): void { return ipcError(ErrorCode.UnknownError, `Tier comparison failed: ${err}`) } }) + + ipcMain.handle(IPC_CHANNELS.LICENSE.OPEN_BILLING, async (_event, params: { tier: 'pro' | 'pro_plus' }) => { + try { + // Phase 3.2-B: 웹 결제 페이지 열기 + // 프로덕션: https://d3ro.dev/billing, 개발: NEXT_PUBLIC_SITE_URL 참조 + const billingUrl = `https://d3ro.dev/billing?tier=${params.tier}` + await shell.openExternal(billingUrl) + return ipcSuccess(undefined) + } catch (err) { + return ipcError(ErrorCode.UnknownError, `Failed to open billing: ${err}`) + } + }) } diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 673e709..74ed8af 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -476,6 +476,8 @@ const electronAPI = { invoke(IPC_CHANNELS.LICENSE.GET_ALL_USAGE), getTierComparison: () => invoke(IPC_CHANNELS.LICENSE.GET_TIER_COMPARISON), + openBilling: (params: { tier: 'pro' | 'pro_plus' }) => + invoke(IPC_CHANNELS.LICENSE.OPEN_BILLING, params), onUpgradePrompt: (cb: (e: UpgradePromptEvent) => void): Unsubscribe => on(IPC_CHANNELS.LICENSE.UPGRADE_PROMPT, cb), onTierChanged: (cb: (e: LicenseInfo) => void): Unsubscribe => diff --git a/apps/desktop/src/renderer/components/LicenseModal.tsx b/apps/desktop/src/renderer/components/LicenseModal.tsx index 5766e31..686264b 100644 --- a/apps/desktop/src/renderer/components/LicenseModal.tsx +++ b/apps/desktop/src/renderer/components/LicenseModal.tsx @@ -83,9 +83,9 @@ export function LicenseModal({ open, onClose }: LicenseModalProps): React.ReactE const isFree = currentTier === 'free' const isPro = currentTier === 'pro' - const handleUpgrade = useCallback(() => { - alert(t('license.paymentPending')) - }, [t]) + const handleUpgrade = useCallback((tier: 'pro' | 'pro_plus' = 'pro') => { + window.electronAPI.license.openBilling({ tier }) + }, []) return ( - + handleUpgrade('pro')} sx={{ width: '100%' }}> @@ -149,7 +149,7 @@ export function LicenseModal({ open, onClose }: LicenseModalProps): React.ReactE - + handleUpgrade('pro_plus')} sx={{ width: '100%' }}> @@ -168,7 +168,7 @@ export function LicenseModal({ open, onClose }: LicenseModalProps): React.ReactE {t('license.currentPlan')} — {t('license.proPlan')} - + handleUpgrade('pro_plus')} sx={{ width: '100%' }}> diff --git a/apps/desktop/src/renderer/pages/DashboardPage.tsx b/apps/desktop/src/renderer/pages/DashboardPage.tsx index 1498d26..a495d74 100644 --- a/apps/desktop/src/renderer/pages/DashboardPage.tsx +++ b/apps/desktop/src/renderer/pages/DashboardPage.tsx @@ -13,6 +13,23 @@ import { formatHotkeyLabel } from '../utils/format-hotkey' import { FileDropZone } from '../components/FileDropZone' import type { StatsSummary, HistoryEntry, HotkeyBinding, CaptionState, LicenseTier, UsageQuota } from '@d3ro/core/types' +// ── Premium 쿼터 모델별 한도 (서버 quota.ts / LicenseService QUOTA_LIMITS 와 동기) ── +const PREMIUM_MODEL_LIMITS: Record> = { + free: [ + { model: 'llm_haiku', i18nKey: 'dashboard.modelHaiku', limit: 250, period: 'weekly' }, + ], + pro: [ + { model: 'llm_haiku', i18nKey: 'dashboard.modelHaiku', limit: 1500, period: 'daily' }, + { model: 'llm_sonnet', i18nKey: 'dashboard.modelSonnet', limit: 300, period: 'daily' }, + { model: 'llm_opus', i18nKey: 'dashboard.modelOpus', limit: 50, period: 'daily' }, + ], + pro_plus: [ + { model: 'llm_haiku', i18nKey: 'dashboard.modelHaiku', limit: -1, period: 'daily' }, + { model: 'llm_sonnet', i18nKey: 'dashboard.modelSonnet', limit: 1500, period: 'daily' }, + { model: 'llm_opus', i18nKey: 'dashboard.modelOpus', limit: 300, period: 'daily' }, + ], +} + // ── 메인 컴포넌트 ───────────────────────────────────── export function DashboardPage(): React.ReactElement { @@ -27,6 +44,7 @@ export function DashboardPage(): React.ReactElement { const audioDecayRef = useRef | null>(null) const [licenseTier, setLicenseTier] = useState('free') const [usageQuotas, setUsageQuotas] = useState([]) + const [premiumStatus, setPremiumStatus] = useState<{ available: boolean; backend: 'local' | 'premium' } | null>(null) const loadData = useCallback(() => { window.electronAPI.stats.getSummary().then((r) => { @@ -50,6 +68,9 @@ export function DashboardPage(): React.ReactElement { window.electronAPI.license.getAllUsage().then((r) => { if (r.success) setUsageQuotas(r.data) }) + window.electronAPI.llm.premium.getStatus().then((r) => { + if (r.success) setPremiumStatus(r.data) + }) }, []) useEffect(() => { @@ -89,9 +110,14 @@ export function DashboardPage(): React.ReactElement { const services = useMemo(() => [ { name: t('service.sttEngine'), status: t('service.ready'), ok: true }, { name: t('service.ollamaLlm'), status: ollamaConnected ? t('service.connected') : t('service.offline'), ok: ollamaConnected }, + { + name: t('service.premiumLlm'), + status: premiumStatus?.available ? t('service.connected') : t('service.notConfigured'), + ok: premiumStatus?.available ?? false, + }, { name: t('service.hotkeyHook'), status: t('service.active'), ok: true }, { name: t('service.audioInput'), status: t('service.standby'), ok: true }, - ], [t, ollamaConnected]) + ], [t, ollamaConnected, premiumStatus]) return ( @@ -250,8 +276,51 @@ export function DashboardPage(): React.ReactElement { ))} - {/* ── 2.4. 사용량 바 (Free 티어) ─────────────── */} - {licenseTier === 'free' && usageQuotas.length > 0 && ( + {/* ── 2.4. 백엔드 인디케이터 + 사용량 바 ─────────────── */} + + {/* 현재 백엔드 카드 */} + + + + {t('dashboard.currentBackend').toUpperCase()} + + + + + {premiumStatus?.backend === 'premium' + ? t('dashboard.backendPremium').toUpperCase() + : t('dashboard.backendLocal').toUpperCase()} + + + + + + {/* 티어 카드 */} + + + + {t('license.currentTier').toUpperCase()} + + + + + {t(`license.${licenseTier === 'pro_plus' ? 'proPlus' : licenseTier}`).toUpperCase()} + + + + + + + {/* 사용량 바 — 모든 티어에서 표시 */} + {usageQuotas.length > 0 && ( @@ -288,6 +357,45 @@ export function DashboardPage(): React.ReactElement { )} ))} + + {/* Premium 모델별 쿼터 표시 */} + {premiumStatus?.backend === 'premium' && PREMIUM_MODEL_LIMITS[licenseTier].length > 0 && ( + <> + + {t('dashboard.premiumQuota').toUpperCase()} + + {PREMIUM_MODEL_LIMITS[licenseTier].map((m) => ( + + + + {t(m.i18nKey)} + + + {m.limit === -1 + ? t('license.unlimited') + : `${m.limit}/${t(m.period === 'weekly' ? 'dashboard.quotaWeekly' : 'dashboard.quotaDaily')}`} + + + {m.limit > 0 && ( + + + + )} + + ))} + + )} diff --git a/apps/web/src/app/(app)/billing/page.tsx b/apps/web/src/app/(app)/billing/page.tsx index c8a9f03..3c297ce 100644 --- a/apps/web/src/app/(app)/billing/page.tsx +++ b/apps/web/src/app/(app)/billing/page.tsx @@ -1,15 +1,15 @@ // apps/web/src/app/(app)/billing/page.tsx -// 구독 및 결제 페이지 — auth/Sidebar는 (app)/layout.tsx가 제공 +// 구독 및 결제 페이지 — Payple 결제 연동 (Phase 3.2-B) import { Box, Grid, Stack } from '@mui/material' import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds' import { d3roPalette, typoSx } from '@d3ro/ui/theme' -import { CheckoutButton } from '@/components/billing/checkout-button' -import { PortalButton } from '@/components/billing/portal-button' +import { PaypleCheckoutButton } from '@/components/billing/payple-checkout-button' +import { PaypleManageButton } from '@/components/billing/payple-manage-button' import { getSupabaseServerClient } from '@/lib/supabase-server' interface Plan { - tier: 'free' | 'pro' | 'team' + tier: 'free' | 'pro' | 'pro_plus' name: string price: string features: string[] @@ -20,40 +20,40 @@ const PLANS: Plan[] = [ { tier: 'free', name: 'Free', - price: '$0', + price: '₩0', features: [ - '월 5시간 전사', - '월 50회 LLM 처리', - '1기기 동기화', - '로컬 모델 (데스크톱)' - ] + '로컬 STT/LLM 무제한', + 'Haiku 250회/주간', + '히스토리 3일 보존', + ], }, { tier: 'pro', name: 'Pro', - price: '$10/월', + price: '₩9,900/월', highlight: true, features: [ - '무제한 전사', - '무제한 LLM 처리', - '5기기 동기화', - 'Sonnet 모델 사용', - '우선 지원' - ] + '로컬 STT/LLM 무제한', + 'Haiku 1,500회/일', + 'Sonnet 300회/일', + 'Opus 50회/일', + '히스토리 무제한', + '클라우드 동기화', + ], }, { - tier: 'team', - name: 'Team', - price: '$20/인/월', + tier: 'pro_plus', + name: 'Pro+', + price: '₩29,900/월', features: [ 'Pro 모든 기능', - '무제한 멤버', - '팀 회의 공유', - '관리자 대시보드', - 'Opus 모델 사용', - 'SSO/SAML (V2-8b)' - ] - } + 'Haiku 무제한', + 'Sonnet 1,500회/일', + 'Opus 300회/일', + '팀 협업 (회의 공유)', + '우선 지원', + ], + }, ] async function loadCurrentTier(): Promise { @@ -77,74 +77,74 @@ export default async function BillingPage(): Promise { BILLING - 현재 구독: {currentTier.toUpperCase()} + 현재 구독: {currentTier === 'pro_plus' ? 'PRO+' : currentTier.toUpperCase()} - {currentTier !== 'free' && } + {currentTier !== 'free' && } - - {PLANS.map((plan) => { - const active = currentTier === plan.tier - return ( - - - - {plan.tier.toUpperCase()} - - - {plan.name} - - - {plan.price} - + + {PLANS.map((plan) => { + const active = currentTier === plan.tier + return ( + + + + {plan.tier === 'pro_plus' ? 'PRO+' : plan.tier.toUpperCase()} + + + {plan.name} + + + {plan.price} + - - {plan.features.map((f) => ( - - {f} - - ))} - + + {plan.features.map((f) => ( + + {f} + + ))} + - {active ? ( - - 현재 구독 중 - - ) : plan.tier === 'free' ? ( - - 기본 플랜 - - ) : ( - - )} - - - ) - })} - + {active ? ( + + 현재 구독 중 + + ) : plan.tier === 'free' ? ( + + 기본 플랜 + + ) : ( + + )} + + + ) + })} + - 결제는 Stripe로 안전하게 처리됩니다. 언제든 취소할 수 있습니다. + 결제는 Payple로 안전하게 처리됩니다. 언제든 구독을 취소할 수 있습니다. ) diff --git a/apps/web/src/components/billing/payple-checkout-button.tsx b/apps/web/src/components/billing/payple-checkout-button.tsx new file mode 100644 index 0000000..a6d01b2 --- /dev/null +++ b/apps/web/src/components/billing/payple-checkout-button.tsx @@ -0,0 +1,165 @@ +'use client' + +// apps/web/src/components/billing/payple-checkout-button.tsx +// Payple 결제창 호출 → 빌링키 획득 → payple-checkout Edge Function 호출 + +import { useState, useEffect, useCallback } from 'react' +import { Button, CircularProgress, Alert, Box } from '@mui/material' +import Script from 'next/script' +import { getSupabaseBrowserClient } from '@/lib/supabase-browser' + +// Payple JS SDK 타입 (전역 함수) +declare global { + interface Window { + PaypleCpayAuthCheck: (obj: Record) => void + } +} + +interface PaypleCheckoutButtonProps { + tier: 'pro' | 'pro_plus' +} + +// 테스트 환경 감지: 환경변수가 없거나 test면 테스트 모드 +const PAYPLE_CLIENT_KEY = process.env.NEXT_PUBLIC_PAYPLE_CLIENT_KEY ?? 'test_DF55F29DA654A8CBC0F0A9DD4B556486' +const IS_TEST = PAYPLE_CLIENT_KEY.startsWith('test_') +const SDK_URL = IS_TEST + ? 'https://democpay.payple.kr/js/v1/payment.js' + : 'https://cpay.payple.kr/js/v1/payment.js' + +export function PaypleCheckoutButton({ tier }: PaypleCheckoutButtonProps): React.ReactElement { + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + const [success, setSuccess] = useState(false) + const [sdkReady, setSdkReady] = useState(false) + + // SDK가 이미 로드되어 있는지 확인 + useEffect(() => { + if (typeof window !== 'undefined' && typeof window.PaypleCpayAuthCheck === 'function') { + setSdkReady(true) + } + }, []) + + const handleCheckout = useCallback(async () => { + setError(null) + setSuccess(false) + + if (!sdkReady || typeof window.PaypleCpayAuthCheck !== 'function') { + setError('결제 모듈 로딩 중입니다. 잠시 후 다시 시도해주세요.') + return + } + + const supabase = getSupabaseBrowserClient() + const { data: { session } } = await supabase.auth.getSession() + + if (!session) { + setError('로그인이 필요합니다') + return + } + + setBusy(true) + + try { + // Payple 결제창 호출 (SPA 콜백 패턴) + const obj: Record = { + clientKey: PAYPLE_CLIENT_KEY, + PCD_PAY_TYPE: 'card', + PCD_PAY_WORK: 'AUTH', // 카드 등록만 (빌링키 발급) + PCD_CARD_VER: '01', // 정기결제용 + PCD_PAY_GOODS: tier === 'pro_plus' ? 'D3RO Voice Pro+' : 'D3RO Voice Pro', + PCD_PAY_TOTAL: tier === 'pro_plus' ? 29900 : 9900, + PCD_PAYER_NO: session.user.id, + PCD_PAYER_EMAIL: session.user.email ?? '', + PCD_RST_URL: '/billing', // 상대 경로 → callbackFunction 사용 + callbackFunction: async (result: Record) => { + try { + if (result['PCD_PAY_RST'] !== 'success') { + setError(result['PCD_PAY_MSG'] ?? '결제가 취소되었습니다') + setBusy(false) + return + } + + const payerId = result['PCD_PAYER_ID'] + if (!payerId) { + setError('빌링키를 받지 못했습니다') + setBusy(false) + return + } + + // Edge Function 호출: 빌링키로 첫 결제 실행 + const response = await fetch( + `${process.env.NEXT_PUBLIC_SUPABASE_URL}/functions/v1/payple-checkout`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${session.access_token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + payer_id: payerId, + tier, + pcd_pay_cardname: result['PCD_PAY_CARDNAME'], + pcd_pay_cardnum: result['PCD_PAY_CARDNUM'], + }), + } + ) + + if (!response.ok) { + const txt = await response.text() + throw new Error(`결제 처리 실패: ${response.status} ${txt}`) + } + + setSuccess(true) + // 2초 후 페이지 리로드하여 구독 상태 반영 + setTimeout(() => { + window.location.reload() + }, 2000) + } catch (e) { + setError(e instanceof Error ? e.message : '알 수 없는 오류') + } finally { + setBusy(false) + } + }, + } + + window.PaypleCpayAuthCheck(obj) + } catch (e) { + setError(e instanceof Error ? e.message : '알 수 없는 오류') + setBusy(false) + } + }, [sdkReady, tier]) + + return ( + +