import Link from 'next/link' import { Alert, Box, Button, Chip, Stack, Typography } from '@mui/material' import { Check } from 'lucide-react' import type { Subscription, SubscriptionTier } from '@d3ro/api-client' import type { PaidPlanTier } from '@d3ro/core/plan-catalog' import { MetalCard, TactileBadge } from '@d3ro/ui/components/ds' import { d3roFontMono } from '@d3ro/ui/theme' import { BillingCheckoutOptions } from '@/components/billing/billing-checkout-options' import { PaypleManageButton } from '@/components/billing/payple-manage-button' import { PortalButton } from '@/components/billing/portal-button' import { getSupabaseServerClient } from '@/lib/supabase-server' import { formatPlanCatalogPrice, parseBillingCatalog, type BillingCatalog, type BillingCatalogPrice } from '@/lib/billing-catalog' type BillingProvider = Subscription['provider'] interface BillingSubscription { tier: SubscriptionTier status: string | null provider: BillingProvider current_period_end: string | null cancel_at: string | null auto_renewing: boolean | null } interface BillingState { email: string subscription: BillingSubscription catalog: BillingCatalog | null } interface BillingPageProps { searchParams: Promise> } interface Plan { tier: SubscriptionTier name: string features: string[] highlight?: boolean } const VALID_TIERS = new Set(['free', 'pro', 'pro_plus']) const VALID_PROVIDERS = new Set([ 'none', 'stripe', 'payple', 'google_play', 'app_store', 'admin' ]) const PLANS: Plan[] = [ { tier: 'free', name: 'FREE', features: ['기본 음성 인식', '로컬 AI 무제한', '무료 클라우드 쿼터'] }, { tier: 'pro', name: 'PRO', highlight: true, features: [ 'Claude Sonnet / Opus 다듬기', '실시간 음성 대화', '기기 간 전사 동기화', '맞춤형 프롬프트와 자동 서식' ] }, { tier: 'pro_plus', name: 'PRO+', features: [ 'PRO 모든 기능', '프리미엄 AI 확장 쿼터', '고급 회의·요약 기능', '우선 지원' ] } ] /** 데스크톱·모바일·사이트가 billingUrl({ tier }) 로 넘긴 요금제. 모르는 값은 무시한다. */ function selectedTierFrom(value: string | string[] | undefined): PaidPlanTier | null { return value === 'pro' || value === 'pro_plus' ? value : null } function tierLabel(tier: SubscriptionTier): string { return tier === 'pro_plus' ? 'PRO+' : tier.toUpperCase() } function providerLabel(provider: BillingProvider): string { const labels: Record = { none: '없음', payple: 'Payple', stripe: 'Stripe', google_play: 'Google Play', app_store: 'App Store', admin: '관리자 부여' } return labels[provider] } function formatDate(value: string | null): string | null { if (!value) return null const date = new Date(value) if (!Number.isFinite(date.getTime())) return null return new Intl.DateTimeFormat('ko-KR', { dateStyle: 'medium' }).format(date) } async function loadBillingState(): Promise { const supabase = await getSupabaseServerClient() const { data: userData, error: userError } = await supabase.auth.getUser() if (userError || !userData.user) throw new Error('authenticated_session_required') const { data, error } = await supabase .from('subscriptions') .select('tier, status, provider, payment_provider, current_period_end, cancel_at, auto_renewing') .eq('user_id', userData.user.id) .maybeSingle() if (error) throw new Error('subscription_lookup_failed') if (!data) throw new Error('subscription_not_initialized') if (!VALID_TIERS.has(data.tier)) throw new Error('subscription_tier_invalid') const providerCandidate = data.provider === 'none' && data.payment_provider !== 'none' ? data.payment_provider : data.provider if (!VALID_PROVIDERS.has(providerCandidate)) throw new Error('subscription_provider_invalid') const { data: catalogData, error: catalogError } = await supabase.functions.invoke('billing-catalog', { body: {} }) const catalog = catalogError ? null : parseBillingCatalog(catalogData) return { email: userData.user.email ?? '이메일 없음', subscription: { tier: data.tier, status: data.status, provider: providerCandidate, current_period_end: data.current_period_end, cancel_at: data.cancel_at, auto_renewing: data.auto_renewing }, catalog } } export default async function BillingPage({ searchParams }: BillingPageProps): Promise { const params = await searchParams let state: BillingState try { state = await loadBillingState() } catch { return } const { subscription } = state const isPaid = subscription.tier !== 'free' const canPurchase = !isPaid && subscription.provider === 'none' const cancellationDate = formatDate(subscription.cancel_at) const periodEnd = formatDate(subscription.current_period_end) const stripeCanceled = params['canceled'] === '1' const stripeReturned = params['success'] === '1' const selectedTier = selectedTierFrom(params['tier']) return ( D3RO VOICE PRO 구독 및 결제 결제 계정과 provider를 확인한 뒤 필요한 플랜을 선택해 주세요. {stripeCanceled && ( Stripe Checkout을 취소했습니다. 결제나 구독 변경은 발생하지 않았습니다. )} {stripeReturned && ( {isPaid ? 'Stripe 결제가 확인되어 구독 정보가 갱신되었습니다.' : 'Stripe 결제 결과를 확인 중입니다. 잠시 후 이 페이지를 새로고침해 주세요.'} )} CURRENT SUBSCRIPTION {tierLabel(subscription.tier)} {subscription.status && } 결제 계정: {state.email} {cancellationDate ? ( {cancellationDate}에 구독이 종료됩니다. ) : periodEnd && isPaid ? ( 현재 결제 기간: {periodEnd}까지 ) : null} {!canPurchase && !isPaid && ( 기존 결제 provider 상태가 정리되지 않아 새 결제를 시작할 수 없습니다. 고객센터에 문의해 주세요. )} {!state.catalog && ( 검증된 결제 가격을 불러오지 못했습니다. 가격이 확인될 때까지 새 결제를 시작할 수 없습니다. )} {PLANS.map((plan) => ( ))} 국내 카드는 Payple, 해외 카드는 Stripe가 처리합니다. 활성 구독이 있으면 같은 provider에서만 관리할 수 있습니다. ) } function PlanCard({ plan, currentTier, canPurchase, catalog, selectedTier }: { plan: Plan currentTier: SubscriptionTier canPurchase: boolean catalog: BillingCatalog | null selectedTier: PaidPlanTier | null }): React.ReactElement { const active = plan.tier === currentTier // 외부에서 고른 요금제가 있으면 그 카드만, 없으면 기본 추천 카드를 강조한다. const selected = !active && selectedTier === plan.tier const recommended = !active && !selectedTier && Boolean(plan.highlight) const catalogPrices: BillingCatalogPrice[] = plan.tier === 'free' || !catalog ? [] : catalog.plans[plan.tier] const priceLabel = formatPlanCatalogPrice(plan.tier, catalog) ?? '가격 정보 이용 불가' return ( {active ? 'CURRENT PLAN' : selected ? 'SELECTED PLAN' : recommended ? 'RECOMMENDED' : 'PLAN'} {plan.name} {priceLabel} {plan.features.map((feature) => ( {feature} ))} {active ? ( 현재 이용 중 ) : plan.tier === 'free' ? ( 유료 구독 종료 후 적용됩니다. ) : canPurchase && catalogPrices.length > 0 ? ( ) : canPurchase ? ( 검증된 가격을 불러온 뒤 결제할 수 있습니다. ) : ( 현재 구독을 먼저 관리해 주세요. )} ) } function SubscriptionManagement({ subscription }: { subscription: BillingSubscription }): React.ReactElement { if (subscription.tier === 'free') { return 활성 유료 구독이 없습니다. } if (subscription.cancel_at || subscription.auto_renewing === false) { return 자동 갱신이 해지되었습니다. } if (subscription.provider === 'payple') return if (subscription.provider === 'stripe') return if (subscription.provider === 'google_play') { return Google Play 앱에서 구독을 관리해 주세요. } if (subscription.provider === 'app_store') { return App Store 구독 설정에서 관리해 주세요. } if (subscription.provider === 'admin') { return 관리자가 부여한 구독입니다. } return 구독 provider를 확인할 수 없습니다. } function BillingLoadError(): React.ReactElement { return ( 구독 정보를 불러오지 못했습니다. 결제를 시작하지 않았습니다. ) }