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.
377 lines
14 KiB
TypeScript
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>
|
|
)
|
|
}
|