feat(release): prepare 1.1.0 candidate
This commit is contained in:
parent
5a34f66981
commit
5205dcdfa9
736 changed files with 115667 additions and 12203 deletions
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue