d3ro-voice/apps/web/src/app/(app)/billing/page.tsx
Yun Chan b6fe588a7c feat(web): serve the web app under /app and send every billing link there (WS-B)
apps/web was never deployed, so /billing on the public domain returned the
landing page and d3ro.dev (desktop "upgrade") did not resolve.

- apps/web runs with basePath /app and output standalone; /download and
  /releases redirect to the site's #download. A Dockerfile and a d3ro-web
  compose service (port 3002) deploy it to the NAS with the other images.
- The site bridge worker forwards /app/* to WEB_APP_ORIGIN (the tunnel host)
  and rewrites upstream redirects; everything else still goes to Pages.
  With no origin configured /app answers 503 instead of the landing page.
- Desktop upgrade, desktop Stripe return, mobile subscription management,
  the web checkout/portal returns and the site all use billingUrl(); the
  return query is success=1 / canceled=1, which the billing page reads.
  The billing page highlights ?tier=pro|pro_plus, and signing in from a
  billing link returns to the same plan.
- auth/callback pins the redirect origin in production and rejects
  protocol-relative next= values (open redirect).
- Mobile legal links use SITE_URLS (fixes the missing slash on /terms).
- Compose drops the unused NEXT_PUBLIC_API_URL and the dead wwwroot legal
  mounts; deploy scripts add the web image and the SUPABASE_* values the NAS
  compose already required; .dockerignore keeps app .env files out of images.
- Supabase auth redirects allow /app/** (remote dashboard must match).

Policy: docs/REFACTOR_POLICY.md Wave 3, W3-3 and W3-4.
2026-09-26 15:48:30 +09:00

377 lines
14 KiB
TypeScript

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<Record<string, string | string[] | undefined>>
}
interface Plan {
tier: SubscriptionTier
name: string
features: string[]
highlight?: boolean
}
const VALID_TIERS = new Set<SubscriptionTier>(['free', 'pro', 'pro_plus'])
const VALID_PROVIDERS = new Set<BillingProvider>([
'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<BillingProvider, string> = {
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<BillingState> {
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<React.ReactElement> {
const params = await searchParams
let state: BillingState
try {
state = await loadBillingState()
} catch {
return <BillingLoadError />
}
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 (
<Box sx={{ maxWidth: 1120, mx: 'auto', p: { xs: 2.5, md: 4 }, pt: 4, pb: 10 }}>
<Box sx={{ textAlign: 'center', mb: 4 }}>
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<TactileBadge ledColor="amber" tone="accent">D3RO VOICE PRO</TactileBadge>
</Box>
<Typography component="h1" sx={{ fontSize: 28, fontWeight: 500, color: 'var(--d3-text-inverse)', letterSpacing: '-0.02em', mb: 1 }}>
구독 및 결제
</Typography>
<Typography sx={{ fontSize: 14, color: 'var(--d3-text-label)' }}>
결제 계정과 provider를 확인한 뒤 필요한 플랜을 선택해 주세요.
</Typography>
</Box>
{stripeCanceled && (
<Alert severity="info" sx={{ mb: 2 }} data-testid="stripe-canceled-message">
Stripe Checkout을 취소했습니다. 결제나 구독 변경은 발생하지 않았습니다.
</Alert>
)}
{stripeReturned && (
<Alert severity={isPaid ? 'success' : 'warning'} sx={{ mb: 2 }} data-testid="stripe-return-message">
{isPaid
? 'Stripe 결제가 확인되어 구독 정보가 갱신되었습니다.'
: 'Stripe 결제 결과를 확인 중입니다. 잠시 후 이 페이지를 새로고침해 주세요.'}
</Alert>
)}
<MetalCard sx={{ p: { xs: 2.5, md: 3.5 }, mb: 4 }}>
<Stack direction={{ xs: 'column', md: 'row' }} justifyContent="space-between" gap={3}>
<Box>
<Typography sx={{ fontFamily: d3roFontMono, fontSize: 11, letterSpacing: 1.5, color: 'var(--d3-text-label)', mb: 1 }}>
CURRENT SUBSCRIPTION
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flexWrap: 'wrap' }}>
<Typography data-testid="billing-current-tier" sx={{ fontSize: 28, fontWeight: 600, color: 'var(--d3-text-inverse)' }}>
{tierLabel(subscription.tier)}
</Typography>
<Chip
data-testid="billing-current-provider"
size="small"
label={providerLabel(subscription.provider)}
color={subscription.provider === 'none' ? 'default' : 'primary'}
/>
{subscription.status && <Chip size="small" variant="outlined" label={subscription.status} />}
</Box>
<Typography data-testid="billing-account" sx={{ mt: 1.5, color: 'var(--d3-text-label)', fontSize: 13 }}>
결제 계정: {state.email}
</Typography>
{cancellationDate ? (
<Typography data-testid="billing-cancel-at" sx={{ mt: 1, color: 'var(--d3-status-warning)', fontSize: 12 }}>
{cancellationDate}에 구독이 종료됩니다.
</Typography>
) : periodEnd && isPaid ? (
<Typography sx={{ mt: 1, color: 'var(--d3-text-label)', fontSize: 12 }}>
현재 결제 기간: {periodEnd}까지
</Typography>
) : null}
</Box>
<SubscriptionManagement subscription={subscription} />
</Stack>
</MetalCard>
{!canPurchase && !isPaid && (
<Alert severity="warning" sx={{ mb: 3 }}>
기존 결제 provider 상태가 정리되지 않아 새 결제를 시작할 수 없습니다. 고객센터에 문의해 주세요.
</Alert>
)}
{!state.catalog && (
<Alert severity="error" sx={{ mb: 3 }} data-testid="billing-catalog-unavailable">
검증된 결제 가격을 불러오지 못했습니다. 가격이 확인될 때까지 새 결제를 시작할 수 없습니다.
</Alert>
)}
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: 'repeat(3, 1fr)' }, gap: 2.5 }}>
{PLANS.map((plan) => (
<PlanCard
key={plan.tier}
plan={plan}
currentTier={subscription.tier}
canPurchase={canPurchase}
catalog={state.catalog}
selectedTier={selectedTier}
/>
))}
</Box>
<Typography sx={{ mt: 4, textAlign: 'center', color: 'var(--d3-text-label)', fontSize: 11 }}>
국내 카드는 Payple, 해외 카드는 Stripe가 처리합니다. 활성 구독이 있으면 같은 provider에서만 관리할 수 있습니다.
</Typography>
</Box>
)
}
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 (
<MetalCard
data-testid={`billing-plan-${plan.tier}`}
data-selected={selected ? 'true' : undefined}
aria-current={selected ? 'true' : undefined}
sx={{
p: 3,
display: 'flex',
flexDirection: 'column',
minHeight: 390,
border: active || selected
? '2px solid var(--d3-accent-main)'
: recommended
? '1px solid var(--d3-accent-glow)'
: undefined
}}
>
<Box sx={{ flex: 1 }}>
<Typography sx={{ fontFamily: d3roFontMono, fontSize: 11, color: active || selected ? 'var(--d3-accent-main)' : 'var(--d3-text-label)', mb: 0.75 }}>
{active ? 'CURRENT PLAN' : selected ? 'SELECTED PLAN' : recommended ? 'RECOMMENDED' : 'PLAN'}
</Typography>
<Typography component="h2" sx={{ fontSize: 24, fontWeight: 600, color: 'var(--d3-text-inverse)' }}>{plan.name}</Typography>
<Typography sx={{ mt: 0.5, mb: 3, color: plan.tier === 'free' ? 'var(--d3-text-label)' : 'var(--d3-accent-main)', fontFamily: d3roFontMono, fontSize: 16 }}>
{priceLabel}
</Typography>
<Stack spacing={1.5} sx={{ mb: 3 }}>
{plan.features.map((feature) => (
<Box key={feature} sx={{ display: 'flex', alignItems: 'flex-start', gap: 1.25 }}>
<Check size={15} color="var(--d3-accent-main)" style={{ marginTop: 2, flexShrink: 0 }} />
<Typography sx={{ color: 'var(--d3-text-secondary)', fontSize: 13 }}>{feature}</Typography>
</Box>
))}
</Stack>
</Box>
{active ? (
<Box sx={{ py: 1.5, textAlign: 'center', border: '1px solid var(--d3-border-default)', borderRadius: 2, color: 'var(--d3-text-label)', fontSize: 12 }}>
현재 이용 중
</Box>
) : plan.tier === 'free' ? (
<Typography sx={{ py: 1.5, textAlign: 'center', color: 'var(--d3-text-label)', fontSize: 12 }}>
유료 구독 종료 후 적용됩니다.
</Typography>
) : canPurchase && catalogPrices.length > 0 ? (
<BillingCheckoutOptions tier={plan.tier} prices={catalogPrices} />
) : canPurchase ? (
<Typography data-testid={`billing-price-unavailable-${plan.tier}`} sx={{ py: 1.5, textAlign: 'center', color: 'var(--d3-status-warning)', fontSize: 12 }}>
검증된 가격을 불러온 뒤 결제할 수 있습니다.
</Typography>
) : (
<Typography sx={{ py: 1.5, textAlign: 'center', color: 'var(--d3-text-label)', fontSize: 12 }}>
현재 구독을 먼저 관리해 주세요.
</Typography>
)}
</MetalCard>
)
}
function SubscriptionManagement({ subscription }: { subscription: BillingSubscription }): React.ReactElement {
if (subscription.tier === 'free') {
return <Typography sx={{ color: 'var(--d3-text-label)', fontSize: 12 }}>활성 유료 구독이 없습니다.</Typography>
}
if (subscription.cancel_at || subscription.auto_renewing === false) {
return <Typography sx={{ color: 'var(--d3-status-warning)', fontSize: 12 }}>자동 갱신이 해지되었습니다.</Typography>
}
if (subscription.provider === 'payple') return <PaypleManageButton />
if (subscription.provider === 'stripe') return <PortalButton />
if (subscription.provider === 'google_play') {
return <Alert severity="info">Google Play 앱에서 구독을 관리해 주세요.</Alert>
}
if (subscription.provider === 'app_store') {
return <Alert severity="info">App Store 구독 설정에서 관리해 주세요.</Alert>
}
if (subscription.provider === 'admin') {
return <Alert severity="info">관리자가 부여한 구독입니다.</Alert>
}
return <Alert severity="warning">구독 provider를 확인할 수 없습니다.</Alert>
}
function BillingLoadError(): React.ReactElement {
return (
<Box sx={{ maxWidth: 720, mx: 'auto', p: 4 }}>
<Alert severity="error" data-testid="billing-load-error" sx={{ mb: 2 }}>
구독 정보를 불러오지 못했습니다. 결제를 시작하지 않았습니다.
</Alert>
<Link href="/billing" style={{ textDecoration: 'none' }}>
<Button variant="outlined">다시 시도</Button>
</Link>
</Box>
)
}