feat(release): prepare 1.1.0 candidate

This commit is contained in:
Yun Chan 2026-08-29 18:33:45 +09:00
parent 5a34f66981
commit 5205dcdfa9
736 changed files with 115667 additions and 12203 deletions

View file

@ -1,151 +1,360 @@
// apps/web/src/app/(app)/billing/page.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 { PaypleCheckoutButton } from '@/components/billing/payple-checkout-button'
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 { 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: 'free' | 'pro' | 'pro_plus'
tier: SubscriptionTier
name: string
price: 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',
price: '₩0',
features: [
'로컬 STT/LLM 무제한',
'Haiku 250회/주간',
'히스토리 3일 보존',
],
name: 'FREE',
features: ['기본 음성 인식', '로컬 AI 무제한', '무료 클라우드 쿼터']
},
{
tier: 'pro',
name: 'Pro',
price: '₩9,900/월',
name: 'PRO',
highlight: true,
features: [
'로컬 STT/LLM 무제한',
'Haiku 1,500회/일',
'Sonnet 300회/일',
'Opus 50회/일',
'히스토리 무제한',
'클라우드 동기화',
],
'Claude Sonnet / Opus 다듬기',
'실시간 음성 대화',
'기기 간 전사 동기화',
'맞춤형 프롬프트와 자동 서식'
]
},
{
tier: 'pro_plus',
name: 'Pro+',
price: '₩29,900/월',
name: 'PRO+',
features: [
'Pro 모든 기능',
'Haiku 무제한',
'Sonnet 1,500회/일',
'Opus 300회/일',
'팀 협업 (회의 공유)',
'우선 지원',
],
},
'PRO 모든 기능',
'프리미엄 AI 확장 쿼터',
'고급 회의·요약 기능',
'우선 지원'
]
}
]
async function loadCurrentTier(): Promise<string> {
try {
const supabase = await getSupabaseServerClient()
const { data } = await supabase.from('subscriptions').select('tier').maybeSingle()
return ((data as { tier?: string } | null)?.tier as string) ?? 'free'
} catch {
return 'free'
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(): Promise<React.ReactElement> {
const currentTier = await loadCurrentTier()
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'
return (
<Box sx={{ p: 4 }}>
<Stack direction="row" alignItems="flex-end" justifyContent="space-between" sx={{ mb: 4 }}>
<Box>
<PhosphorText variant="title" sx={{ mb: 1 }}>
BILLING
</PhosphorText>
<Box sx={{ color: d3roPalette.text.muted, fontSize: 13 }}>
현재 구독: <strong>{currentTier === 'pro_plus' ? 'PRO+' : currentTier.toUpperCase()}</strong>
</Box>
<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>
{currentTier !== 'free' && <PaypleManageButton />}
</Stack>
<Grid container spacing={3}>
{PLANS.map((plan) => {
const active = currentTier === plan.tier
return (
<Grid size={{ xs: 12, md: 4 }} key={plan.tier}>
<MetalCard
sx={{
p: 4,
height: '100%',
border: active
? `2px solid ${d3roPalette.accent.main}`
: plan.highlight
? `2px solid ${d3roPalette.tag.purple}`
: undefined,
}}
>
<Box sx={{ ...typoSx('label'), color: d3roPalette.text.label, mb: 1 }}>
{plan.tier === 'pro_plus' ? 'PRO+' : plan.tier.toUpperCase()}
</Box>
<PhosphorText variant="title" sx={{ mb: 1 }}>
{plan.name}
</PhosphorText>
<Box sx={{ ...typoSx('value'), color: d3roPalette.text.primary, mb: 3 }}>
{plan.price}
</Box>
<Box component="ul" sx={{ pl: 2, mb: 3, color: d3roPalette.text.secondary }}>
{plan.features.map((f) => (
<Box component="li" key={f} sx={{ fontSize: 13, mb: 0.5 }}>
{f}
</Box>
))}
</Box>
{active ? (
<Box
sx={{
textAlign: 'center',
p: 1.5,
bgcolor: d3roPalette.bg.inset,
borderRadius: 1,
color: d3roPalette.accent.main,
...typoSx('label'),
}}
>
현재 구독 중
</Box>
) : plan.tier === 'free' ? (
<Box sx={{ textAlign: 'center', color: d3roPalette.text.muted, fontSize: 12 }}>
기본 플랜
</Box>
) : (
<PaypleCheckoutButton tier={plan.tier} />
)}
</MetalCard>
</Grid>
)
})}
</Grid>
<Box sx={{ mt: 4, color: d3roPalette.text.muted, fontSize: 11 }}>
결제는 Payple로 안전하게 처리됩니다. 언제든 구독을 취소할 수 있습니다.
<Typography component="h1" sx={{ fontSize: 28, fontWeight: 500, color: '#fff', 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: '#fff' }}>
{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: '#ffb000', 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}
/>
))}
</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
}: {
plan: Plan
currentTier: SubscriptionTier
canPurchase: boolean
catalog: BillingCatalog | null
}): React.ReactElement {
const active = plan.tier === currentTier
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}`}
sx={{
p: 3,
display: 'flex',
flexDirection: 'column',
minHeight: 390,
border: active
? '2px solid var(--d3-accent-main)'
: plan.highlight
? '1px solid rgba(59,130,246,0.45)'
: undefined
}}
>
<Box sx={{ flex: 1 }}>
<Typography sx={{ fontFamily: d3roFontMono, fontSize: 11, color: active ? 'var(--d3-accent-main)' : 'var(--d3-text-label)', mb: 0.75 }}>
{active ? 'CURRENT PLAN' : plan.highlight ? 'RECOMMENDED' : 'PLAN'}
</Typography>
<Typography component="h2" sx={{ fontSize: 24, fontWeight: 600, color: '#fff' }}>{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: '#ffb000', 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: '#ffb000', 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>
<Button component={Link} href="/billing" variant="outlined">다시 시도</Button>
</Box>
)
}