refactor(core): keep plan prices, cloud quotas and public URLs in one contract (WS-A)
Prices, quotas and site URLs were copied by hand into the edge functions, admin, desktop and the landing site, and the copies disagreed (Payple billed 9,900/29,900 KRW, admin labels said 12,900/24,900 KRW and $9.9/$19.9, the site said 2,900/8,900 KRW). - packages/core/src/plan-catalog.ts is the single source for PLAN_PRICE_KRW (Free 0 / Pro 2,900 / Pro+ 8,900 a month) and PLAN_QUOTA. - packages/core/src/web-urls.ts is the single source for the public origin, the /app web-app base path, SITE_URLS and billingUrl(). - Deno cannot bundle packages/core, so scripts/ci/sync-core-contract.mjs generates _shared/core-contract.generated.ts; `npm run contract:check` fails on drift (same pattern as version:sync). - Payple checkout, renewal and webhook amount checks now bill the catalog price, so existing subscribers move to the new price at their next renewal. quota.ts, team-contract.ts and the tests read the generated values. - Admin MRR/ARR is computed in KRW from the catalog; license labels, the release link and desktop PREMIUM_LLM limits derive from core; the site imports prices and quotas directly. Policy: docs/REFACTOR_POLICY.md Wave 3, W3-1 and W3-2.
This commit is contained in:
parent
e689683b72
commit
88f24d84a1
21 changed files with 568 additions and 152 deletions
|
|
@ -22,11 +22,12 @@ export default async function AdminOverviewPage(): Promise<React.ReactElement> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const krw = new Intl.NumberFormat('ko-KR', { style: 'currency', currency: 'KRW', maximumFractionDigits: 0 })
|
||||||
const revenueCards = revenue
|
const revenueCards = revenue
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
title: 'Annual Recurring Revenue',
|
title: 'Annual Recurring Revenue',
|
||||||
value: `$${revenue.arrUsd.toLocaleString()}`,
|
value: krw.format(revenue.arrKrw),
|
||||||
subtext: 'Active subscriptions × 12 months',
|
subtext: 'Active subscriptions × 12 months',
|
||||||
color: 'green' as const,
|
color: 'green' as const,
|
||||||
badge: 'ARR',
|
badge: 'ARR',
|
||||||
|
|
@ -34,7 +35,7 @@ export default async function AdminOverviewPage(): Promise<React.ReactElement> {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Monthly Recurring Revenue',
|
title: 'Monthly Recurring Revenue',
|
||||||
value: `$${revenue.mrrUsd.toLocaleString()}`,
|
value: krw.format(revenue.mrrKrw),
|
||||||
subtext: `Pro ${revenue.tierBreakdown.pro.toLocaleString()} · Pro+ ${revenue.tierBreakdown.pro_plus.toLocaleString()}`,
|
subtext: `Pro ${revenue.tierBreakdown.pro.toLocaleString()} · Pro+ ${revenue.tierBreakdown.pro_plus.toLocaleString()}`,
|
||||||
color: 'blue' as const,
|
color: 'blue' as const,
|
||||||
badge: 'MRR',
|
badge: 'MRR',
|
||||||
|
|
@ -208,7 +209,7 @@ export default async function AdminOverviewPage(): Promise<React.ReactElement> {
|
||||||
>
|
>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 2 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 2 }}>
|
||||||
<StatRing color={card.color} size={46}>
|
<StatRing color={card.color} size={46}>
|
||||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '13px', fontWeight: 500 }}>$</Typography>
|
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '13px', fontWeight: 500 }}>₩</Typography>
|
||||||
</StatRing>
|
</StatRing>
|
||||||
<Box component="span" sx={statusBadgeSx(card.badgeColor)}>
|
<Box component="span" sx={statusBadgeSx(card.badgeColor)}>
|
||||||
{card.badge}
|
{card.badge}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
|
|
||||||
import { Box, Typography, Button } from '@mui/material'
|
import { Box, Typography, Button } from '@mui/material'
|
||||||
import { DoubleBezelCard, TactileBadge } from '@d3ro/ui/components/ds'
|
import { DoubleBezelCard, TactileBadge } from '@d3ro/ui/components/ds'
|
||||||
|
import { SITE_URLS } from '@d3ro/core/web-urls'
|
||||||
import { C, FONT_SANS, FONT_MONO, panelSx, tableSx, statusBadgeSx } from '@/lib/console-theme'
|
import { C, FONT_SANS, FONT_MONO, panelSx, tableSx, statusBadgeSx } from '@/lib/console-theme'
|
||||||
import { requireManager } from '@/lib/admin-guard'
|
import { requireManager } from '@/lib/admin-guard'
|
||||||
import { fetchReleaseHub, type ReleaseAssetPlatform, type ReleaseHub } from '@/lib/forgejo-releases'
|
import { fetchReleaseHub, type ReleaseAssetPlatform, type ReleaseHub } from '@/lib/forgejo-releases'
|
||||||
|
|
@ -107,7 +108,7 @@ function HeaderBar({ feedLive, repoHtmlUrl }: { feedLive: boolean; repoHtmlUrl:
|
||||||
Forgejo Releases ↗
|
Forgejo Releases ↗
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
href="https://d3ro.chanpaca.net/download.html"
|
href={SITE_URLS.download}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
sx={{
|
sx={{
|
||||||
|
|
|
||||||
|
|
@ -20,9 +20,12 @@ import {
|
||||||
Alert
|
Alert
|
||||||
} from '@mui/material'
|
} from '@mui/material'
|
||||||
import type { LicenseTier } from '@d3ro/core/types'
|
import type { LicenseTier } from '@d3ro/core/types'
|
||||||
|
import { PLAN_PRICE_KRW } from '@d3ro/core/plan-catalog'
|
||||||
import { d3roPalette, d3roFontMono, d3roRadius } from '@d3ro/ui/theme'
|
import { d3roPalette, d3roFontMono, d3roRadius } from '@d3ro/ui/theme'
|
||||||
import { C, FONT_SANS, FONT_MONO, primaryButtonSx } from '@/lib/console-theme'
|
import { C, FONT_SANS, FONT_MONO, primaryButtonSx } from '@/lib/console-theme'
|
||||||
|
|
||||||
|
const krw = new Intl.NumberFormat('ko-KR', { style: 'currency', currency: 'KRW', maximumFractionDigits: 0 })
|
||||||
|
|
||||||
interface LicenseIssuerDialogProps {
|
interface LicenseIssuerDialogProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
|
|
@ -150,9 +153,9 @@ export function LicenseIssuerDialog({ open, onClose }: LicenseIssuerDialogProps)
|
||||||
<FormControl fullWidth sx={inputSx}>
|
<FormControl fullWidth sx={inputSx}>
|
||||||
<InputLabel>Plan / Tier</InputLabel>
|
<InputLabel>Plan / Tier</InputLabel>
|
||||||
<Select value={tier} onChange={(e) => setTier(e.target.value as LicenseTier)} label="Plan / Tier">
|
<Select value={tier} onChange={(e) => setTier(e.target.value as LicenseTier)} label="Plan / Tier">
|
||||||
<MenuItem value="pro">Pro ($9.9 / ₩12,900)</MenuItem>
|
<MenuItem value="pro">Pro ({krw.format(PLAN_PRICE_KRW.pro)} / month)</MenuItem>
|
||||||
<MenuItem value="pro_plus">Pro+ ($19.9 / ₩24,900)</MenuItem>
|
<MenuItem value="pro_plus">Pro+ ({krw.format(PLAN_PRICE_KRW.pro_plus)} / month)</MenuItem>
|
||||||
<MenuItem value="team">Team ($25 / ₩32,000)</MenuItem>
|
<MenuItem value="team">Team</MenuItem>
|
||||||
<MenuItem value="enterprise">Enterprise (Custom)</MenuItem>
|
<MenuItem value="enterprise">Enterprise (Custom)</MenuItem>
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,12 @@
|
||||||
import 'server-only'
|
import 'server-only'
|
||||||
|
|
||||||
|
import { PLAN_PRICE_KRW } from '@d3ro/core/plan-catalog'
|
||||||
import { getSupabaseAdminClient } from './supabase-admin'
|
import { getSupabaseAdminClient } from './supabase-admin'
|
||||||
|
|
||||||
/** Monthly list price per tier, in USD. */
|
|
||||||
const TIER_MONTHLY_USD: Record<'pro' | 'pro_plus' | 'free', number> = {
|
|
||||||
pro: 9.9,
|
|
||||||
pro_plus: 19.9,
|
|
||||||
free: 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SubscriptionRevenue {
|
export interface SubscriptionRevenue {
|
||||||
mrrUsd: number
|
/** Monthly list-price revenue in KRW (PLAN_PRICE_KRW; provider currency is not tracked per row). */
|
||||||
arrUsd: number
|
mrrKrw: number
|
||||||
|
arrKrw: number
|
||||||
activeCount: number
|
activeCount: number
|
||||||
tierBreakdown: { pro: number; pro_plus: number; free: number }
|
tierBreakdown: { pro: number; pro_plus: number; free: number }
|
||||||
}
|
}
|
||||||
|
|
@ -19,7 +14,7 @@ export interface SubscriptionRevenue {
|
||||||
/**
|
/**
|
||||||
* Aggregates real subscription revenue from Supabase.
|
* Aggregates real subscription revenue from Supabase.
|
||||||
* Only status === 'active' subscriptions contribute to MRR/ARR.
|
* Only status === 'active' subscriptions contribute to MRR/ARR.
|
||||||
* MRR = Σ(monthly price of each active subscription's tier); ARR = MRR * 12.
|
* MRR = Σ(PLAN_PRICE_KRW of each active subscription's tier); ARR = MRR * 12.
|
||||||
*/
|
*/
|
||||||
export async function fetchSubscriptionRevenue(): Promise<SubscriptionRevenue> {
|
export async function fetchSubscriptionRevenue(): Promise<SubscriptionRevenue> {
|
||||||
const supabase = await getSupabaseAdminClient('manager')
|
const supabase = await getSupabaseAdminClient('manager')
|
||||||
|
|
@ -37,7 +32,7 @@ export async function fetchSubscriptionRevenue(): Promise<SubscriptionRevenue> {
|
||||||
}
|
}
|
||||||
|
|
||||||
const tierBreakdown = { pro: 0, pro_plus: 0, free: 0 }
|
const tierBreakdown = { pro: 0, pro_plus: 0, free: 0 }
|
||||||
let mrrUsd = 0
|
let mrrKrw = 0
|
||||||
let activeCount = 0
|
let activeCount = 0
|
||||||
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
|
|
@ -46,12 +41,9 @@ export async function fetchSubscriptionRevenue(): Promise<SubscriptionRevenue> {
|
||||||
const tier = row.tier
|
const tier = row.tier
|
||||||
if (tier === 'pro' || tier === 'pro_plus' || tier === 'free') {
|
if (tier === 'pro' || tier === 'pro_plus' || tier === 'free') {
|
||||||
tierBreakdown[tier] += 1
|
tierBreakdown[tier] += 1
|
||||||
mrrUsd += TIER_MONTHLY_USD[tier]
|
mrrKrw += PLAN_PRICE_KRW[tier]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
mrrUsd = Math.round(mrrUsd * 100) / 100
|
return { mrrKrw, arrKrw: mrrKrw * 12, activeCount, tierBreakdown }
|
||||||
const arrUsd = Math.round(mrrUsd * 12 * 100) / 100
|
|
||||||
|
|
||||||
return { mrrUsd, arrUsd, activeCount, tierBreakdown }
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import type {
|
||||||
} from '@d3ro/core/types'
|
} from '@d3ro/core/types'
|
||||||
import { Feature } from '@d3ro/core/types'
|
import { Feature } from '@d3ro/core/types'
|
||||||
import { normalizeEntitlementTier } from '@d3ro/core/entitlement'
|
import { normalizeEntitlementTier } from '@d3ro/core/entitlement'
|
||||||
|
import { PLAN_QUOTA } from '@d3ro/core/plan-catalog'
|
||||||
import { verifySignedLicenseKey, createDefaultTrialPayload } from '@d3ro/core/utils/crypto-license'
|
import { verifySignedLicenseKey, createDefaultTrialPayload } from '@d3ro/core/utils/crypto-license'
|
||||||
|
|
||||||
const logger = getLogger('license')
|
const logger = getLogger('license')
|
||||||
|
|
@ -56,20 +57,22 @@ function generateMachineId(): string {
|
||||||
// 클라이언트에서는 PREMIUM_LLM feature로 묶어서 canUse() 체크하고,
|
// 클라이언트에서는 PREMIUM_LLM feature로 묶어서 canUse() 체크하고,
|
||||||
// 실제 모델별 세분화는 서버 llm-proxy가 담당.
|
// 실제 모델별 세분화는 서버 llm-proxy가 담당.
|
||||||
// 여기의 값은 Settings UI 표시용 + upgrade 유도 시점 판단용.
|
// 여기의 값은 Settings UI 표시용 + upgrade 유도 시점 판단용.
|
||||||
const QUOTA_LIMITS: Record<LicenseTier, Partial<Record<Feature, number>>> = {
|
// 값의 정본은 @d3ro/core PLAN_QUOTA. PREMIUM_LLM은 모델별 한도의 합으로 표시하고,
|
||||||
free: {
|
// 한 모델이라도 무제한이면 한도를 두지 않는다(Free는 Haiku 주 250회).
|
||||||
// Haiku만, 250/주간. 클라이언트에서는 대략적 일환산(~36/일)으로 표시.
|
function premiumLlmLimit(tier: LicenseTier): number | undefined {
|
||||||
[Feature.PREMIUM_LLM]: 250,
|
const quota = PLAN_QUOTA[tier]
|
||||||
},
|
const models = [quota.llm_haiku, quota.llm_sonnet, quota.llm_opus].filter((q) => q.limit !== 0)
|
||||||
pro: {
|
if (models.some((q) => q.limit < 0)) return undefined
|
||||||
// 모델별: Haiku 1500 + Sonnet 300 + Opus 50 = 합산 표시
|
return models.reduce((sum, q) => sum + q.limit, 0)
|
||||||
[Feature.PREMIUM_LLM]: 1850,
|
|
||||||
},
|
|
||||||
pro_plus: {},
|
|
||||||
team: {},
|
|
||||||
enterprise: {},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const QUOTA_LIMITS: Record<LicenseTier, Partial<Record<Feature, number>>> = Object.fromEntries(
|
||||||
|
(['free', 'pro', 'pro_plus', 'team', 'enterprise'] as const).map((tier) => {
|
||||||
|
const limit = premiumLlmLimit(tier)
|
||||||
|
return [tier, limit === undefined ? {} : { [Feature.PREMIUM_LLM]: limit }]
|
||||||
|
}),
|
||||||
|
) as Record<LicenseTier, Partial<Record<Feature, number>>>
|
||||||
|
|
||||||
// ── 기능별 최소 필요 티어 ──────────────────────────────────
|
// ── 기능별 최소 필요 티어 ──────────────────────────────────
|
||||||
// 빅뱅 Phase 4: 모든 로컬 기능을 'free'로 해방.
|
// 빅뱅 Phase 4: 모든 로컬 기능을 'free'로 해방.
|
||||||
// 클라우드 기능(PREMIUM_LLM, CLOUD_SYNC)만 로그인 요구 + 티어 gate.
|
// 클라우드 기능(PREMIUM_LLM, CLOUD_SYNC)만 로그인 요구 + 티어 gate.
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,8 @@
|
||||||
"checksum": "node scripts/ci/generate-checksums.mjs",
|
"checksum": "node scripts/ci/generate-checksums.mjs",
|
||||||
"version:check": "node scripts/ci/sync-version.mjs --check",
|
"version:check": "node scripts/ci/sync-version.mjs --check",
|
||||||
"version:sync": "node scripts/ci/sync-version.mjs --write",
|
"version:sync": "node scripts/ci/sync-version.mjs --write",
|
||||||
|
"contract:check": "node scripts/ci/sync-core-contract.mjs --check",
|
||||||
|
"contract:sync": "node scripts/ci/sync-core-contract.mjs --write",
|
||||||
"release:metadata": "node scripts/ci/verify-release-metadata.mjs",
|
"release:metadata": "node scripts/ci/verify-release-metadata.mjs",
|
||||||
"release:metadata:test": "node scripts/ci/verify-release-metadata.mjs --self-test",
|
"release:metadata:test": "node scripts/ci/verify-release-metadata.mjs --self-test",
|
||||||
"release:forgejo": "node scripts/ci/publish-forgejo-release.mjs",
|
"release:forgejo": "node scripts/ci/publish-forgejo-release.mjs",
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,14 @@
|
||||||
"types": "./src/utils/crypto-license.ts",
|
"types": "./src/utils/crypto-license.ts",
|
||||||
"default": "./src/utils/crypto-license.ts"
|
"default": "./src/utils/crypto-license.ts"
|
||||||
},
|
},
|
||||||
|
"./plan-catalog": {
|
||||||
|
"types": "./src/plan-catalog.ts",
|
||||||
|
"default": "./src/plan-catalog.ts"
|
||||||
|
},
|
||||||
|
"./web-urls": {
|
||||||
|
"types": "./src/web-urls.ts",
|
||||||
|
"default": "./src/web-urls.ts"
|
||||||
|
},
|
||||||
"./supabase-config": {
|
"./supabase-config": {
|
||||||
"types": "./src/supabase-config.ts",
|
"types": "./src/supabase-config.ts",
|
||||||
"default": "./src/supabase-config.ts"
|
"default": "./src/supabase-config.ts"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
// src/shared/constants.ts
|
// src/shared/constants.ts
|
||||||
import type { LicenseTier } from './types'
|
import type { LicenseTier } from './types'
|
||||||
|
import { PLAN_QUOTA, type PlanQuotaFeature, type PlanQuotaPeriod } from './plan-catalog'
|
||||||
|
|
||||||
/** 타이밍 상수 (Speakly 리버스엔지니어링 기반) */
|
/** 타이밍 상수 (Speakly 리버스엔지니어링 기반) */
|
||||||
export const TIMING = {
|
export const TIMING = {
|
||||||
|
|
@ -51,39 +52,34 @@ export const AUDIO_FORMAT = {
|
||||||
BYTES_PER_SAMPLE: 2
|
BYTES_PER_SAMPLE: 2
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
/** Premium 모델별 쿼터 한도 (LicenseService QUOTA_LIMITS, 서버 quota.ts와 동기) */
|
/** Premium 모델별 쿼터 표시 행. 값의 정본은 plan-catalog.ts `PLAN_QUOTA`(서버 quota.ts도 같은 값을 생성 사본으로 읽는다). */
|
||||||
export interface PremiumModelQuota {
|
export interface PremiumModelQuota {
|
||||||
readonly model: string
|
readonly model: PlanQuotaFeature
|
||||||
readonly i18nKey: string
|
readonly i18nKey: string
|
||||||
readonly limit: number // -1 = 무제한
|
readonly limit: number // -1 = 무제한
|
||||||
readonly period: 'daily' | 'weekly'
|
readonly period: PlanQuotaPeriod
|
||||||
|
}
|
||||||
|
|
||||||
|
const PREMIUM_MODEL_ROWS: readonly { model: PlanQuotaFeature; i18nKey: string }[] = [
|
||||||
|
{ model: 'llm_haiku', i18nKey: 'license.modelHaiku' },
|
||||||
|
{ model: 'llm_sonnet', i18nKey: 'license.modelSonnet' },
|
||||||
|
{ model: 'llm_opus', i18nKey: 'license.modelOpus' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function premiumModelLimits(tier: LicenseTier): readonly PremiumModelQuota[] {
|
||||||
|
// limit 0(사용 불가) 모델은 표시하지 않는다 — Free 는 Haiku 한 줄만 남는다.
|
||||||
|
return PREMIUM_MODEL_ROWS
|
||||||
|
.map(({ model, i18nKey }) => ({ model, i18nKey, ...PLAN_QUOTA[tier][model] }))
|
||||||
|
.filter((row) => row.limit !== 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PREMIUM_MODEL_LIMITS: Record<LicenseTier, readonly PremiumModelQuota[]> = {
|
export const PREMIUM_MODEL_LIMITS: Record<LicenseTier, readonly PremiumModelQuota[]> = {
|
||||||
free: [
|
free: premiumModelLimits('free'),
|
||||||
{ model: 'llm_haiku', i18nKey: 'license.modelHaiku', limit: 250, period: 'weekly' },
|
pro: premiumModelLimits('pro'),
|
||||||
],
|
pro_plus: premiumModelLimits('pro_plus'),
|
||||||
pro: [
|
team: premiumModelLimits('team'),
|
||||||
{ model: 'llm_haiku', i18nKey: 'license.modelHaiku', limit: 1500, period: 'daily' },
|
enterprise: premiumModelLimits('enterprise'),
|
||||||
{ model: 'llm_sonnet', i18nKey: 'license.modelSonnet', limit: 300, period: 'daily' },
|
}
|
||||||
{ model: 'llm_opus', i18nKey: 'license.modelOpus', limit: 50, period: 'daily' },
|
|
||||||
],
|
|
||||||
pro_plus: [
|
|
||||||
{ model: 'llm_haiku', i18nKey: 'license.modelHaiku', limit: -1, period: 'daily' },
|
|
||||||
{ model: 'llm_sonnet', i18nKey: 'license.modelSonnet', limit: 1500, period: 'daily' },
|
|
||||||
{ model: 'llm_opus', i18nKey: 'license.modelOpus', limit: 300, period: 'daily' },
|
|
||||||
],
|
|
||||||
team: [
|
|
||||||
{ model: 'llm_haiku', i18nKey: 'license.modelHaiku', limit: -1, period: 'daily' },
|
|
||||||
{ model: 'llm_sonnet', i18nKey: 'license.modelSonnet', limit: 3000, period: 'daily' },
|
|
||||||
{ model: 'llm_opus', i18nKey: 'license.modelOpus', limit: 600, period: 'daily' },
|
|
||||||
],
|
|
||||||
enterprise: [
|
|
||||||
{ model: 'llm_haiku', i18nKey: 'license.modelHaiku', limit: -1, period: 'daily' },
|
|
||||||
{ model: 'llm_sonnet', i18nKey: 'license.modelSonnet', limit: -1, period: 'daily' },
|
|
||||||
{ model: 'llm_opus', i18nKey: 'license.modelOpus', limit: -1, period: 'daily' },
|
|
||||||
],
|
|
||||||
} as const
|
|
||||||
|
|
||||||
/** 윈도우 크기 */
|
/** 윈도우 크기 */
|
||||||
export const WINDOW_SIZE = {
|
export const WINDOW_SIZE = {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ export * from './errors'
|
||||||
export * from './ipc-channels'
|
export * from './ipc-channels'
|
||||||
export * from './constants'
|
export * from './constants'
|
||||||
export * from './entitlement'
|
export * from './entitlement'
|
||||||
|
export * from './plan-catalog'
|
||||||
|
export * from './web-urls'
|
||||||
export * from './utils/crypto-license'
|
export * from './utils/crypto-license'
|
||||||
export * from './utils/pii-redactor'
|
export * from './utils/pii-redactor'
|
||||||
export * from './utils/secure-memory'
|
export * from './utils/secure-memory'
|
||||||
|
|
|
||||||
82
packages/core/src/plan-catalog.ts
Normal file
82
packages/core/src/plan-catalog.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
// packages/core/src/plan-catalog.ts
|
||||||
|
// 요금제 가격 SSOT — 데스크톱·웹·관리자·모바일·랜딩 사이트가 모두 여기서 읽는다.
|
||||||
|
//
|
||||||
|
// Supabase Edge Function(Deno)은 저장소의 이 경로를 번들할 수 없으므로
|
||||||
|
// `npm run contract:sync`가 server/supabase/functions/_shared/core-contract.generated.ts 로
|
||||||
|
// 값을 복사하고, `npm run contract:check`가 CI에서 어긋남을 막는다.
|
||||||
|
// 그래서 이 파일은 import 없이 순수 값만 둔다.
|
||||||
|
|
||||||
|
export type PaidPlanTier = 'pro' | 'pro_plus'
|
||||||
|
export type PlanTier = 'free' | PaidPlanTier
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 월 요금(원). Payple 청구 금액이며, 기존 구독자도 다음 갱신부터 이 값으로 청구된다.
|
||||||
|
* Stripe(USD)·Google Play 가격은 각 콘솔에서 따로 관리한다.
|
||||||
|
*/
|
||||||
|
export const PLAN_PRICE_KRW: Readonly<Record<PlanTier, number>> = {
|
||||||
|
free: 0,
|
||||||
|
pro: 2900,
|
||||||
|
pro_plus: 8900,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 클라우드 사용량 한도 ─────────────────────────────────
|
||||||
|
// 서버(Deno `_shared/quota.ts`)가 실제로 집행하고, 데스크톱·사이트는 표시만 한다.
|
||||||
|
// Free 는 주간, 나머지는 일간. limit: -1=무제한, 0=사용 불가, 양수=한도.
|
||||||
|
|
||||||
|
/** 사용량 한도를 갖는 티어. 결제 티어 외에 팀·엔터프라이즈 계약 티어를 포함한다. */
|
||||||
|
export type PlanQuotaTier = PlanTier | 'team' | 'enterprise'
|
||||||
|
|
||||||
|
/** 쿼터 추적 키 — 서버 daily_usage.feature 값과 같다. */
|
||||||
|
export type PlanQuotaFeature =
|
||||||
|
| 'stt_transcribe'
|
||||||
|
| 'llm_haiku'
|
||||||
|
| 'llm_sonnet'
|
||||||
|
| 'llm_opus'
|
||||||
|
| 'realtime_session'
|
||||||
|
|
||||||
|
export type PlanQuotaPeriod = 'daily' | 'weekly'
|
||||||
|
|
||||||
|
export interface PlanQuota {
|
||||||
|
/** -1=무제한, 0=사용 불가, 양수=한도 */
|
||||||
|
readonly limit: number
|
||||||
|
readonly period: PlanQuotaPeriod
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PLAN_QUOTA: Readonly<Record<PlanQuotaTier, Readonly<Record<PlanQuotaFeature, PlanQuota>>>> = {
|
||||||
|
free: {
|
||||||
|
stt_transcribe: { limit: 250, period: 'weekly' },
|
||||||
|
llm_haiku: { limit: 250, period: 'weekly' },
|
||||||
|
llm_sonnet: { limit: 0, period: 'daily' },
|
||||||
|
llm_opus: { limit: 0, period: 'daily' },
|
||||||
|
realtime_session: { limit: 0, period: 'daily' },
|
||||||
|
},
|
||||||
|
pro: {
|
||||||
|
stt_transcribe: { limit: -1, period: 'daily' },
|
||||||
|
llm_haiku: { limit: 1500, period: 'daily' },
|
||||||
|
llm_sonnet: { limit: 300, period: 'daily' },
|
||||||
|
llm_opus: { limit: 50, period: 'daily' },
|
||||||
|
// 세션 수 기준 (~$0.016/분 mini — 세션당 평균 수 분 가정)
|
||||||
|
realtime_session: { limit: 30, period: 'daily' },
|
||||||
|
},
|
||||||
|
pro_plus: {
|
||||||
|
stt_transcribe: { limit: -1, period: 'daily' },
|
||||||
|
llm_haiku: { limit: -1, period: 'daily' },
|
||||||
|
llm_sonnet: { limit: 1500, period: 'daily' },
|
||||||
|
llm_opus: { limit: 300, period: 'daily' },
|
||||||
|
realtime_session: { limit: 120, period: 'daily' },
|
||||||
|
},
|
||||||
|
team: {
|
||||||
|
stt_transcribe: { limit: -1, period: 'daily' },
|
||||||
|
llm_haiku: { limit: -1, period: 'daily' },
|
||||||
|
llm_sonnet: { limit: 3000, period: 'daily' },
|
||||||
|
llm_opus: { limit: 600, period: 'daily' },
|
||||||
|
realtime_session: { limit: 300, period: 'daily' },
|
||||||
|
},
|
||||||
|
enterprise: {
|
||||||
|
stt_transcribe: { limit: -1, period: 'daily' },
|
||||||
|
llm_haiku: { limit: -1, period: 'daily' },
|
||||||
|
llm_sonnet: { limit: -1, period: 'daily' },
|
||||||
|
llm_opus: { limit: -1, period: 'daily' },
|
||||||
|
realtime_session: { limit: -1, period: 'daily' },
|
||||||
|
},
|
||||||
|
}
|
||||||
43
packages/core/src/web-urls.ts
Normal file
43
packages/core/src/web-urls.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
// packages/core/src/web-urls.ts
|
||||||
|
// 공개 웹 주소 SSOT — 결제·법률·다운로드·초대 링크는 모두 여기서 만든다.
|
||||||
|
//
|
||||||
|
// 한 도메인 아래 두 앱이 있다.
|
||||||
|
// / 랜딩 사이트(site/, Cloudflare Pages) — 다운로드, 법률 문서, 초대 수락
|
||||||
|
// /app/... 웹앱(apps/web, Next basePath '/app') — 로그인, 결제, 대시보드
|
||||||
|
// 사이트 브리지 워커(server/cloudflare-site-bridge)가 /app 요청을 웹앱으로 보낸다.
|
||||||
|
// Deno 쪽 사본은 plan-catalog.ts 와 같은 방식으로 생성된다. 이 파일도 순수 값만 둔다.
|
||||||
|
|
||||||
|
import type { PaidPlanTier } from './plan-catalog'
|
||||||
|
|
||||||
|
export const PUBLIC_SITE_ORIGIN = 'https://d3ro.chanpaca.net'
|
||||||
|
|
||||||
|
/** apps/web 의 Next basePath. next.config 와 브리지 워커 라우팅이 이 값을 따른다. */
|
||||||
|
export const WEB_APP_BASE_PATH = '/app'
|
||||||
|
|
||||||
|
export const WEB_APP_URL = `${PUBLIC_SITE_ORIGIN}${WEB_APP_BASE_PATH}`
|
||||||
|
|
||||||
|
export const SITE_URLS = {
|
||||||
|
home: `${PUBLIC_SITE_ORIGIN}/`,
|
||||||
|
download: `${PUBLIC_SITE_ORIGIN}/#download`,
|
||||||
|
privacy: `${PUBLIC_SITE_ORIGIN}/privacy/`,
|
||||||
|
terms: `${PUBLIC_SITE_ORIGIN}/terms/`,
|
||||||
|
deleteAccount: `${PUBLIC_SITE_ORIGIN}/delete-account/`,
|
||||||
|
acceptInvite: `${PUBLIC_SITE_ORIGIN}/accept-invite/`,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
/** 결제 결과로 돌아올 때 붙는 쿼리. apps/web 결제 페이지가 읽는 이름과 같다. */
|
||||||
|
export type BillingReturn = 'success' | 'canceled'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 웹 결제 페이지 URL.
|
||||||
|
* - `tier`: 고를 요금제를 미리 선택한다.
|
||||||
|
* - `result`: 외부 결제(Stripe 등)에서 돌아올 때의 결과.
|
||||||
|
*/
|
||||||
|
export function billingUrl(options: { tier?: PaidPlanTier; result?: BillingReturn } = {}): string {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (options.tier) params.set('tier', options.tier)
|
||||||
|
if (options.result === 'success') params.set('success', '1')
|
||||||
|
if (options.result === 'canceled') params.set('canceled', '1')
|
||||||
|
const query = params.toString()
|
||||||
|
return `${WEB_APP_URL}/billing${query ? `?${query}` : ''}`
|
||||||
|
}
|
||||||
170
scripts/ci/sync-core-contract.mjs
Normal file
170
scripts/ci/sync-core-contract.mjs
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
// Core contract (plans + public URLs) generator for runtimes that cannot import packages/core.
|
||||||
|
//
|
||||||
|
// Supabase Edge Functions run on Deno and are deployed per function directory, so they
|
||||||
|
// cannot bundle `packages/core`. Instead of hand-copied constants, this script copies the
|
||||||
|
// canonical sources verbatim into one generated file and CI verifies it never drifts.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// node scripts/ci/sync-core-contract.mjs --check (default; exit 1 when out of sync)
|
||||||
|
// node scripts/ci/sync-core-contract.mjs --write
|
||||||
|
|
||||||
|
import { readFileSync, writeFileSync, existsSync } from 'node:fs'
|
||||||
|
import { createRequire } from 'node:module'
|
||||||
|
import { dirname, join } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..')
|
||||||
|
const args = process.argv.slice(2)
|
||||||
|
const write = args.includes('--write')
|
||||||
|
const check = args.includes('--check') || !write
|
||||||
|
|
||||||
|
if (write && args.includes('--check')) fail('Choose exactly one mode: --check or --write.')
|
||||||
|
const unknown = args.filter((arg) => arg !== '--check' && arg !== '--write')
|
||||||
|
if (unknown.length > 0) fail(`Unknown arguments: ${unknown.join(' ')}`)
|
||||||
|
|
||||||
|
const TARGET = 'server/supabase/functions/_shared/core-contract.generated.ts'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonical sources, concatenated in this order. `allowedImports` lists the only
|
||||||
|
* import statements a source may contain; each must point at an earlier source so the
|
||||||
|
* concatenated file stays self-contained.
|
||||||
|
*/
|
||||||
|
const SOURCES = [
|
||||||
|
{ path: 'packages/core/src/plan-catalog.ts', allowedImports: [] },
|
||||||
|
{
|
||||||
|
path: 'packages/core/src/web-urls.ts',
|
||||||
|
allowedImports: ["import type { PaidPlanTier } from './plan-catalog'"],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const sections = SOURCES.map(({ path, allowedImports }) => {
|
||||||
|
const absolutePath = join(root, path)
|
||||||
|
if (!existsSync(absolutePath)) fail(`Canonical source is missing: ${path}`)
|
||||||
|
const text = readFileSync(absolutePath, 'utf8').replace(/\r\n/g, '\n')
|
||||||
|
|
||||||
|
const importLines = text.split('\n').filter((line) => /^\s*(import|export\s+\*|export\s+\{[^}]*\}\s+from)\b/.test(line))
|
||||||
|
for (const line of importLines) {
|
||||||
|
if (!allowedImports.includes(line.trim())) {
|
||||||
|
fail(
|
||||||
|
`${path} must stay import-free (pure values). Unexpected: "${line.trim()}".\n` +
|
||||||
|
' Deno cannot resolve it from the generated copy. Move the value into the canonical file instead.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (/\bDeno\.|\bprocess\.env\b|\bimport\.meta\b|\brequire\(/.test(text)) {
|
||||||
|
fail(`${path} must not read the runtime environment; it is copied into Deno verbatim.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = text
|
||||||
|
.split('\n')
|
||||||
|
.filter((line) => !allowedImports.includes(line.trim()))
|
||||||
|
.join('\n')
|
||||||
|
.trim()
|
||||||
|
return { path, body }
|
||||||
|
})
|
||||||
|
|
||||||
|
const generated = [
|
||||||
|
'// 생성 파일 — 직접 수정 금지.',
|
||||||
|
`// 정본: ${SOURCES.map(({ path }) => path).join(', ')}`,
|
||||||
|
'// 갱신: npm run contract:sync / 검사: npm run contract:check',
|
||||||
|
'// Deno Edge Function은 packages/core를 번들할 수 없어서 정본 소스를 그대로 복사한다.',
|
||||||
|
'',
|
||||||
|
...sections.flatMap(({ path, body }) => [`// ── 원본: ${path} ${'─'.repeat(Math.max(4, 60 - path.length))}`, '', body, '']),
|
||||||
|
].join('\n')
|
||||||
|
|
||||||
|
await validateContract(generated)
|
||||||
|
|
||||||
|
const targetPath = join(root, TARGET)
|
||||||
|
const current = existsSync(targetPath) ? readFileSync(targetPath, 'utf8').replace(/\r\n/g, '\n') : null
|
||||||
|
|
||||||
|
if (current === generated) {
|
||||||
|
process.stdout.write(`[contract] GREEN ${TARGET}\n`)
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (check) {
|
||||||
|
process.stderr.write(`[contract] out of sync: ${TARGET}${current === null ? ' (missing)' : ''}\n`)
|
||||||
|
fail('Run `npm run contract:sync` and commit the generated file.')
|
||||||
|
}
|
||||||
|
|
||||||
|
writeFileSync(targetPath, generated, 'utf8')
|
||||||
|
process.stdout.write(`[contract] synchronized ${TARGET}\n`)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transpile the generated TypeScript with the repository's TypeScript compiler and
|
||||||
|
* evaluate it, so a syntax error or a malformed value fails here instead of at deploy.
|
||||||
|
*/
|
||||||
|
async function validateContract(source) {
|
||||||
|
let ts
|
||||||
|
try {
|
||||||
|
ts = createRequire(import.meta.url)('typescript')
|
||||||
|
} catch {
|
||||||
|
fail('The `typescript` package is required. Run `npm ci` at the repository root first.')
|
||||||
|
}
|
||||||
|
const output = ts.transpileModule(source, {
|
||||||
|
reportDiagnostics: true,
|
||||||
|
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
|
||||||
|
})
|
||||||
|
const diagnostics = output.diagnostics ?? []
|
||||||
|
if (diagnostics.length > 0) {
|
||||||
|
const messages = diagnostics.map((d) => ts.flattenDiagnosticMessageText(d.messageText, '\n'))
|
||||||
|
fail(`Generated contract does not parse:\n ${messages.join('\n ')}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
let contract
|
||||||
|
try {
|
||||||
|
contract = await import(`data:text/javascript;base64,${Buffer.from(output.outputText).toString('base64')}`)
|
||||||
|
} catch (error) {
|
||||||
|
fail(`Generated contract cannot be evaluated: ${error instanceof Error ? error.message : String(error)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const prices = contract.PLAN_PRICE_KRW
|
||||||
|
expectKeys('PLAN_PRICE_KRW', prices, ['free', 'pro', 'pro_plus'])
|
||||||
|
if (prices.free !== 0) fail('PLAN_PRICE_KRW.free must be 0.')
|
||||||
|
for (const tier of ['pro', 'pro_plus']) {
|
||||||
|
if (!Number.isSafeInteger(prices[tier]) || prices[tier] < 100) {
|
||||||
|
fail(`PLAN_PRICE_KRW.${tier} must be a whole KRW amount >= 100; got ${prices[tier]}.`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const quota = contract.PLAN_QUOTA
|
||||||
|
const features = ['stt_transcribe', 'llm_haiku', 'llm_sonnet', 'llm_opus', 'realtime_session']
|
||||||
|
expectKeys('PLAN_QUOTA', quota, ['free', 'pro', 'pro_plus', 'team', 'enterprise'])
|
||||||
|
for (const [tier, perFeature] of Object.entries(quota)) {
|
||||||
|
expectKeys(`PLAN_QUOTA.${tier}`, perFeature, features)
|
||||||
|
for (const [feature, value] of Object.entries(perFeature)) {
|
||||||
|
if (!Number.isSafeInteger(value?.limit) || value.limit < -1 || !['daily', 'weekly'].includes(value?.period)) {
|
||||||
|
fail(`PLAN_QUOTA.${tier}.${feature} must be { limit: integer >= -1, period: daily|weekly }.`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let origin
|
||||||
|
try {
|
||||||
|
origin = new URL(contract.PUBLIC_SITE_ORIGIN)
|
||||||
|
} catch {
|
||||||
|
fail(`PUBLIC_SITE_ORIGIN is not a URL: ${contract.PUBLIC_SITE_ORIGIN}`)
|
||||||
|
}
|
||||||
|
if (origin.protocol !== 'https:' || origin.origin !== contract.PUBLIC_SITE_ORIGIN) {
|
||||||
|
fail(`PUBLIC_SITE_ORIGIN must be a bare https origin; got ${contract.PUBLIC_SITE_ORIGIN}`)
|
||||||
|
}
|
||||||
|
for (const [key, url] of Object.entries(contract.SITE_URLS ?? {})) {
|
||||||
|
if (!String(url).startsWith(`${contract.PUBLIC_SITE_ORIGIN}/`)) fail(`SITE_URLS.${key} must live under PUBLIC_SITE_ORIGIN.`)
|
||||||
|
}
|
||||||
|
const billing = contract.billingUrl({ tier: 'pro', result: 'success' })
|
||||||
|
if (billing !== `${contract.WEB_APP_URL}/billing?tier=pro&success=1`) {
|
||||||
|
fail(`billingUrl() contract changed unexpectedly: ${billing}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectKeys(label, value, expected) {
|
||||||
|
const actual = value && typeof value === 'object' ? Object.keys(value).sort() : []
|
||||||
|
if (JSON.stringify(actual) !== JSON.stringify([...expected].sort())) {
|
||||||
|
fail(`${label} keys must be exactly: ${expected.join(', ')} (got ${actual.join(', ') || 'none'})`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fail(message) {
|
||||||
|
process.stderr.write(`[contract] ${message}\n`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ import {
|
||||||
createBillingCatalog,
|
createBillingCatalog,
|
||||||
parseStripeCatalogPrice,
|
parseStripeCatalogPrice,
|
||||||
} from './billing-catalog.ts'
|
} from './billing-catalog.ts'
|
||||||
|
import { PLAN_PRICE_KRW } from './core-contract.generated.ts'
|
||||||
|
|
||||||
function assert(condition: unknown, message: string): asserts condition {
|
function assert(condition: unknown, message: string): asserts condition {
|
||||||
if (!condition) throw new Error(message)
|
if (!condition) throw new Error(message)
|
||||||
|
|
@ -46,13 +47,18 @@ Deno.test('Stripe catalog rejects mismatched, inactive, free, malformed, and one
|
||||||
|
|
||||||
Deno.test('catalog exposes only configured provider prices and never invents a fallback', () => {
|
Deno.test('catalog exposes only configured provider prices and never invents a fallback', () => {
|
||||||
const catalog = createBillingCatalog({
|
const catalog = createBillingCatalog({
|
||||||
payple: { pro: 9900, pro_plus: 29900 },
|
payple: { pro: PLAN_PRICE_KRW.pro, pro_plus: PLAN_PRICE_KRW.pro_plus },
|
||||||
stripe: {
|
stripe: {
|
||||||
pro: { unit_amount: 990, currency: 'USD', interval: 'month', interval_count: 1 },
|
pro: { unit_amount: 990, currency: 'USD', interval: 'month', interval_count: 1 },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
assert(catalog.plans[0].prices.length === 2, 'pro must expose two verified providers')
|
assert(catalog.plans[0].prices.length === 2, 'pro must expose two verified providers')
|
||||||
assert(catalog.plans[1].prices.length === 1, 'pro plus must omit unavailable Stripe')
|
assert(catalog.plans[1].prices.length === 1, 'pro plus must omit unavailable Stripe')
|
||||||
|
assert(
|
||||||
|
catalog.plans[0].prices[0].unit_amount === PLAN_PRICE_KRW.pro
|
||||||
|
&& catalog.plans[1].prices[0].unit_amount === PLAN_PRICE_KRW.pro_plus,
|
||||||
|
'Payple prices must come from the core plan catalog',
|
||||||
|
)
|
||||||
|
|
||||||
const unavailable = createBillingCatalog({})
|
const unavailable = createBillingCatalog({})
|
||||||
assert(unavailable.plans.every((plan) => plan.prices.length === 0), 'missing configuration must stay unavailable')
|
assert(unavailable.plans.every((plan) => plan.prices.length === 0), 'missing configuration must stay unavailable')
|
||||||
|
|
|
||||||
134
server/supabase/functions/_shared/core-contract.generated.ts
Normal file
134
server/supabase/functions/_shared/core-contract.generated.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
// 생성 파일 — 직접 수정 금지.
|
||||||
|
// 정본: packages/core/src/plan-catalog.ts, packages/core/src/web-urls.ts
|
||||||
|
// 갱신: npm run contract:sync / 검사: npm run contract:check
|
||||||
|
// Deno Edge Function은 packages/core를 번들할 수 없어서 정본 소스를 그대로 복사한다.
|
||||||
|
|
||||||
|
// ── 원본: packages/core/src/plan-catalog.ts ───────────────────────────
|
||||||
|
|
||||||
|
// packages/core/src/plan-catalog.ts
|
||||||
|
// 요금제 가격 SSOT — 데스크톱·웹·관리자·모바일·랜딩 사이트가 모두 여기서 읽는다.
|
||||||
|
//
|
||||||
|
// Supabase Edge Function(Deno)은 저장소의 이 경로를 번들할 수 없으므로
|
||||||
|
// `npm run contract:sync`가 server/supabase/functions/_shared/core-contract.generated.ts 로
|
||||||
|
// 값을 복사하고, `npm run contract:check`가 CI에서 어긋남을 막는다.
|
||||||
|
// 그래서 이 파일은 import 없이 순수 값만 둔다.
|
||||||
|
|
||||||
|
export type PaidPlanTier = 'pro' | 'pro_plus'
|
||||||
|
export type PlanTier = 'free' | PaidPlanTier
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 월 요금(원). Payple 청구 금액이며, 기존 구독자도 다음 갱신부터 이 값으로 청구된다.
|
||||||
|
* Stripe(USD)·Google Play 가격은 각 콘솔에서 따로 관리한다.
|
||||||
|
*/
|
||||||
|
export const PLAN_PRICE_KRW: Readonly<Record<PlanTier, number>> = {
|
||||||
|
free: 0,
|
||||||
|
pro: 2900,
|
||||||
|
pro_plus: 8900,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 클라우드 사용량 한도 ─────────────────────────────────
|
||||||
|
// 서버(Deno `_shared/quota.ts`)가 실제로 집행하고, 데스크톱·사이트는 표시만 한다.
|
||||||
|
// Free 는 주간, 나머지는 일간. limit: -1=무제한, 0=사용 불가, 양수=한도.
|
||||||
|
|
||||||
|
/** 사용량 한도를 갖는 티어. 결제 티어 외에 팀·엔터프라이즈 계약 티어를 포함한다. */
|
||||||
|
export type PlanQuotaTier = PlanTier | 'team' | 'enterprise'
|
||||||
|
|
||||||
|
/** 쿼터 추적 키 — 서버 daily_usage.feature 값과 같다. */
|
||||||
|
export type PlanQuotaFeature =
|
||||||
|
| 'stt_transcribe'
|
||||||
|
| 'llm_haiku'
|
||||||
|
| 'llm_sonnet'
|
||||||
|
| 'llm_opus'
|
||||||
|
| 'realtime_session'
|
||||||
|
|
||||||
|
export type PlanQuotaPeriod = 'daily' | 'weekly'
|
||||||
|
|
||||||
|
export interface PlanQuota {
|
||||||
|
/** -1=무제한, 0=사용 불가, 양수=한도 */
|
||||||
|
readonly limit: number
|
||||||
|
readonly period: PlanQuotaPeriod
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PLAN_QUOTA: Readonly<Record<PlanQuotaTier, Readonly<Record<PlanQuotaFeature, PlanQuota>>>> = {
|
||||||
|
free: {
|
||||||
|
stt_transcribe: { limit: 250, period: 'weekly' },
|
||||||
|
llm_haiku: { limit: 250, period: 'weekly' },
|
||||||
|
llm_sonnet: { limit: 0, period: 'daily' },
|
||||||
|
llm_opus: { limit: 0, period: 'daily' },
|
||||||
|
realtime_session: { limit: 0, period: 'daily' },
|
||||||
|
},
|
||||||
|
pro: {
|
||||||
|
stt_transcribe: { limit: -1, period: 'daily' },
|
||||||
|
llm_haiku: { limit: 1500, period: 'daily' },
|
||||||
|
llm_sonnet: { limit: 300, period: 'daily' },
|
||||||
|
llm_opus: { limit: 50, period: 'daily' },
|
||||||
|
// 세션 수 기준 (~$0.016/분 mini — 세션당 평균 수 분 가정)
|
||||||
|
realtime_session: { limit: 30, period: 'daily' },
|
||||||
|
},
|
||||||
|
pro_plus: {
|
||||||
|
stt_transcribe: { limit: -1, period: 'daily' },
|
||||||
|
llm_haiku: { limit: -1, period: 'daily' },
|
||||||
|
llm_sonnet: { limit: 1500, period: 'daily' },
|
||||||
|
llm_opus: { limit: 300, period: 'daily' },
|
||||||
|
realtime_session: { limit: 120, period: 'daily' },
|
||||||
|
},
|
||||||
|
team: {
|
||||||
|
stt_transcribe: { limit: -1, period: 'daily' },
|
||||||
|
llm_haiku: { limit: -1, period: 'daily' },
|
||||||
|
llm_sonnet: { limit: 3000, period: 'daily' },
|
||||||
|
llm_opus: { limit: 600, period: 'daily' },
|
||||||
|
realtime_session: { limit: 300, period: 'daily' },
|
||||||
|
},
|
||||||
|
enterprise: {
|
||||||
|
stt_transcribe: { limit: -1, period: 'daily' },
|
||||||
|
llm_haiku: { limit: -1, period: 'daily' },
|
||||||
|
llm_sonnet: { limit: -1, period: 'daily' },
|
||||||
|
llm_opus: { limit: -1, period: 'daily' },
|
||||||
|
realtime_session: { limit: -1, period: 'daily' },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 원본: packages/core/src/web-urls.ts ───────────────────────────────
|
||||||
|
|
||||||
|
// packages/core/src/web-urls.ts
|
||||||
|
// 공개 웹 주소 SSOT — 결제·법률·다운로드·초대 링크는 모두 여기서 만든다.
|
||||||
|
//
|
||||||
|
// 한 도메인 아래 두 앱이 있다.
|
||||||
|
// / 랜딩 사이트(site/, Cloudflare Pages) — 다운로드, 법률 문서, 초대 수락
|
||||||
|
// /app/... 웹앱(apps/web, Next basePath '/app') — 로그인, 결제, 대시보드
|
||||||
|
// 사이트 브리지 워커(server/cloudflare-site-bridge)가 /app 요청을 웹앱으로 보낸다.
|
||||||
|
// Deno 쪽 사본은 plan-catalog.ts 와 같은 방식으로 생성된다. 이 파일도 순수 값만 둔다.
|
||||||
|
|
||||||
|
|
||||||
|
export const PUBLIC_SITE_ORIGIN = 'https://d3ro.chanpaca.net'
|
||||||
|
|
||||||
|
/** apps/web 의 Next basePath. next.config 와 브리지 워커 라우팅이 이 값을 따른다. */
|
||||||
|
export const WEB_APP_BASE_PATH = '/app'
|
||||||
|
|
||||||
|
export const WEB_APP_URL = `${PUBLIC_SITE_ORIGIN}${WEB_APP_BASE_PATH}`
|
||||||
|
|
||||||
|
export const SITE_URLS = {
|
||||||
|
home: `${PUBLIC_SITE_ORIGIN}/`,
|
||||||
|
download: `${PUBLIC_SITE_ORIGIN}/#download`,
|
||||||
|
privacy: `${PUBLIC_SITE_ORIGIN}/privacy/`,
|
||||||
|
terms: `${PUBLIC_SITE_ORIGIN}/terms/`,
|
||||||
|
deleteAccount: `${PUBLIC_SITE_ORIGIN}/delete-account/`,
|
||||||
|
acceptInvite: `${PUBLIC_SITE_ORIGIN}/accept-invite/`,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
/** 결제 결과로 돌아올 때 붙는 쿼리. apps/web 결제 페이지가 읽는 이름과 같다. */
|
||||||
|
export type BillingReturn = 'success' | 'canceled'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 웹 결제 페이지 URL.
|
||||||
|
* - `tier`: 고를 요금제를 미리 선택한다.
|
||||||
|
* - `result`: 외부 결제(Stripe 등)에서 돌아올 때의 결과.
|
||||||
|
*/
|
||||||
|
export function billingUrl(options: { tier?: PaidPlanTier; result?: BillingReturn } = {}): string {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (options.tier) params.set('tier', options.tier)
|
||||||
|
if (options.result === 'success') params.set('success', '1')
|
||||||
|
if (options.result === 'canceled') params.set('canceled', '1')
|
||||||
|
const query = params.toString()
|
||||||
|
return `${WEB_APP_URL}/billing${query ? `?${query}` : ''}`
|
||||||
|
}
|
||||||
|
|
@ -11,7 +11,9 @@ import {
|
||||||
PaypleConfigurationError,
|
PaypleConfigurationError,
|
||||||
PaypleVerificationError,
|
PaypleVerificationError,
|
||||||
resolvePaypleOrderDate,
|
resolvePaypleOrderDate,
|
||||||
|
TIER_PRICE,
|
||||||
} from './payple.ts'
|
} from './payple.ts'
|
||||||
|
import { PLAN_PRICE_KRW } from './core-contract.generated.ts'
|
||||||
|
|
||||||
function assert(condition: boolean, message: string): asserts condition {
|
function assert(condition: boolean, message: string): asserts condition {
|
||||||
if (!condition) throw new Error(message)
|
if (!condition) throw new Error(message)
|
||||||
|
|
@ -90,11 +92,11 @@ Deno.test('Payple synchronous charge and webhook share one provider event identi
|
||||||
orderId: 'D3RO-20260821153045-test-nonce',
|
orderId: 'D3RO-20260821153045-test-nonce',
|
||||||
payerId: 'payer-fixture',
|
payerId: 'payer-fixture',
|
||||||
payType: 'card' as const,
|
payType: 'card' as const,
|
||||||
amount: 9900,
|
amount: TIER_PRICE.pro,
|
||||||
}
|
}
|
||||||
const directDigest = await payplePaymentEventDigest(identity)
|
const directDigest = await payplePaymentEventDigest(identity)
|
||||||
const webhookDigest = await payplePaymentEventDigest({ ...identity })
|
const webhookDigest = await payplePaymentEventDigest({ ...identity })
|
||||||
const differentCharge = await payplePaymentEventDigest({ ...identity, amount: 29900 })
|
const differentCharge = await payplePaymentEventDigest({ ...identity, amount: TIER_PRICE.pro_plus })
|
||||||
assert(payplePaymentEventId(identity.orderId) === `payment:${identity.orderId}`, 'canonical event id')
|
assert(payplePaymentEventId(identity.orderId) === `payment:${identity.orderId}`, 'canonical event id')
|
||||||
assert(directDigest === webhookDigest, 'same external charge must be an exact replay')
|
assert(directDigest === webhookDigest, 'same external charge must be an exact replay')
|
||||||
assert(directDigest !== differentCharge, 'different charge identity must not collide')
|
assert(directDigest !== differentCharge, 'different charge identity must not collide')
|
||||||
|
|
@ -163,7 +165,7 @@ Deno.test('Payple billing separates definitive declines from ambiguous transport
|
||||||
try {
|
try {
|
||||||
await paypleBilling(config, auth, {
|
await paypleBilling(config, auth, {
|
||||||
payerId: 'payer-fixture',
|
payerId: 'payer-fixture',
|
||||||
amount: 9900,
|
amount: TIER_PRICE.pro,
|
||||||
orderId: 'D3RO-20260821153045-test-nonce',
|
orderId: 'D3RO-20260821153045-test-nonce',
|
||||||
goodsName: 'D3RO Voice Pro',
|
goodsName: 'D3RO Voice Pro',
|
||||||
})
|
})
|
||||||
|
|
@ -177,3 +179,9 @@ Deno.test('Payple billing separates definitive declines from ambiguous transport
|
||||||
globalThis.fetch = originalFetch
|
globalThis.fetch = originalFetch
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
Deno.test('Payple charge amounts are the core plan catalog prices', () => {
|
||||||
|
assert(TIER_PRICE.pro === PLAN_PRICE_KRW.pro, 'Pro charge must equal PLAN_PRICE_KRW.pro')
|
||||||
|
assert(TIER_PRICE.pro_plus === PLAN_PRICE_KRW.pro_plus, 'Pro+ charge must equal PLAN_PRICE_KRW.pro_plus')
|
||||||
|
assert(Object.keys(TIER_PRICE).sort().join(',') === 'pro,pro_plus', 'only paid tiers are chargeable')
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
// server/supabase/functions/_shared/payple.ts
|
// server/supabase/functions/_shared/payple.ts
|
||||||
// Payple 결제 API 래퍼 — 파트너 인증, 빌링 결제, 취소, 빌링키 해지
|
// Payple 결제 API 래퍼 — 파트너 인증, 빌링 결제, 취소, 빌링키 해지
|
||||||
|
|
||||||
|
import { PLAN_PRICE_KRW } from './core-contract.generated.ts'
|
||||||
|
|
||||||
// ── 타입 ──────────────────────────────────────────────
|
// ── 타입 ──────────────────────────────────────────────
|
||||||
|
|
||||||
export interface PaypleConfig {
|
export interface PaypleConfig {
|
||||||
|
|
@ -543,9 +545,10 @@ export function calcSubscriptionPeriod(now = new Date()): { start: string; end:
|
||||||
|
|
||||||
// ── 티어별 가격 ──────────────────────────────────────
|
// ── 티어별 가격 ──────────────────────────────────────
|
||||||
|
|
||||||
|
/** Payple 청구 금액(원). 정본은 packages/core/src/plan-catalog.ts (생성 사본 경유). */
|
||||||
export const TIER_PRICE: Record<string, number> = {
|
export const TIER_PRICE: Record<string, number> = {
|
||||||
pro: 9900,
|
pro: PLAN_PRICE_KRW.pro,
|
||||||
pro_plus: 29900,
|
pro_plus: PLAN_PRICE_KRW.pro_plus,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TIER_GOODS_NAME: Record<string, string> = {
|
export const TIER_GOODS_NAME: Record<string, string> = {
|
||||||
|
|
|
||||||
|
|
@ -3,64 +3,25 @@
|
||||||
// Free=Haiku 250/주간, Pro=모델별 일간, Pro+=Haiku 무제한
|
// Free=Haiku 250/주간, Pro=모델별 일간, Pro+=Haiku 무제한
|
||||||
|
|
||||||
import { createClient } from '@supabase/supabase-js'
|
import { createClient } from '@supabase/supabase-js'
|
||||||
|
import {
|
||||||
|
PLAN_QUOTA,
|
||||||
|
type PlanQuota,
|
||||||
|
type PlanQuotaFeature,
|
||||||
|
type PlanQuotaPeriod,
|
||||||
|
type PlanQuotaTier,
|
||||||
|
} from './core-contract.generated.ts'
|
||||||
|
|
||||||
export type Tier = 'free' | 'pro' | 'pro_plus' | 'team' | 'enterprise'
|
export type Tier = PlanQuotaTier
|
||||||
|
|
||||||
/** 쿼터 추적 키 — 모델별 분리 */
|
/** 쿼터 추적 키 — 모델별 분리 */
|
||||||
export type QuotaFeature =
|
export type QuotaFeature = PlanQuotaFeature
|
||||||
| 'stt_transcribe'
|
|
||||||
| 'llm_haiku'
|
|
||||||
| 'llm_sonnet'
|
|
||||||
| 'llm_opus'
|
|
||||||
| 'realtime_session'
|
|
||||||
|
|
||||||
export type QuotaPeriod = 'daily' | 'weekly'
|
export type QuotaPeriod = PlanQuotaPeriod
|
||||||
|
|
||||||
interface ModelQuota {
|
type ModelQuota = PlanQuota
|
||||||
/** -1=무제한, 0=사용불가, 양수=한도 */
|
|
||||||
limit: number
|
|
||||||
period: QuotaPeriod
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 모델별 쿼터 정책 */
|
/** 모델별 쿼터 정책. 정본은 packages/core/src/plan-catalog.ts `PLAN_QUOTA` (생성 사본 경유). */
|
||||||
const MODEL_QUOTA: Record<Tier, Record<QuotaFeature, ModelQuota>> = {
|
const MODEL_QUOTA: Readonly<Record<Tier, Readonly<Record<QuotaFeature, ModelQuota>>>> = PLAN_QUOTA
|
||||||
free: {
|
|
||||||
stt_transcribe: { limit: 250, period: 'weekly' },
|
|
||||||
llm_haiku: { limit: 250, period: 'weekly' },
|
|
||||||
llm_sonnet: { limit: 0, period: 'daily' }, // 사용불가
|
|
||||||
llm_opus: { limit: 0, period: 'daily' }, // 사용불가
|
|
||||||
realtime_session: { limit: 0, period: 'daily' }, // 사용불가
|
|
||||||
},
|
|
||||||
pro: {
|
|
||||||
stt_transcribe: { limit: -1, period: 'daily' },
|
|
||||||
llm_haiku: { limit: 1500, period: 'daily' },
|
|
||||||
llm_sonnet: { limit: 300, period: 'daily' },
|
|
||||||
llm_opus: { limit: 50, period: 'daily' },
|
|
||||||
// 세션 수 기준 (~$0.016/분 mini — 세션당 평균 수 분 가정)
|
|
||||||
realtime_session: { limit: 30, period: 'daily' },
|
|
||||||
},
|
|
||||||
pro_plus: {
|
|
||||||
stt_transcribe: { limit: -1, period: 'daily' },
|
|
||||||
llm_haiku: { limit: -1, period: 'daily' }, // 무제한
|
|
||||||
llm_sonnet: { limit: 1500, period: 'daily' },
|
|
||||||
llm_opus: { limit: 300, period: 'daily' },
|
|
||||||
realtime_session: { limit: 120, period: 'daily' },
|
|
||||||
},
|
|
||||||
team: {
|
|
||||||
stt_transcribe: { limit: -1, period: 'daily' },
|
|
||||||
llm_haiku: { limit: -1, period: 'daily' }, // 무제한
|
|
||||||
llm_sonnet: { limit: 3000, period: 'daily' },
|
|
||||||
llm_opus: { limit: 600, period: 'daily' },
|
|
||||||
realtime_session: { limit: 300, period: 'daily' },
|
|
||||||
},
|
|
||||||
enterprise: {
|
|
||||||
stt_transcribe: { limit: -1, period: 'daily' },
|
|
||||||
llm_haiku: { limit: -1, period: 'daily' }, // 무제한
|
|
||||||
llm_sonnet: { limit: -1, period: 'daily' }, // 무제한
|
|
||||||
llm_opus: { limit: -1, period: 'daily' }, // 무제한
|
|
||||||
realtime_session: { limit: -1, period: 'daily' }, // 무제한
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Anthropic 모델명 → 쿼터 키 매핑 */
|
/** Anthropic 모델명 → 쿼터 키 매핑 */
|
||||||
export function modelToQuotaKey(model: string): QuotaFeature {
|
export function modelToQuotaKey(model: string): QuotaFeature {
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import {
|
||||||
parseTeamInviteInput,
|
parseTeamInviteInput,
|
||||||
TeamContractError,
|
TeamContractError,
|
||||||
} from './team-contract.ts'
|
} from './team-contract.ts'
|
||||||
|
import { PUBLIC_SITE_ORIGIN } from './core-contract.generated.ts'
|
||||||
|
|
||||||
function assert(condition: boolean, message: string): asserts condition {
|
function assert(condition: boolean, message: string): asserts condition {
|
||||||
if (!condition) throw new Error(message)
|
if (!condition) throw new Error(message)
|
||||||
|
|
@ -62,7 +63,7 @@ Deno.test('team accept parser permits only a URL-safe bounded bearer token', ()
|
||||||
|
|
||||||
Deno.test('site URL is fail-closed to the canonical deployed HTTPS origin', () => {
|
Deno.test('site URL is fail-closed to the canonical deployed HTTPS origin', () => {
|
||||||
assert(
|
assert(
|
||||||
normalizeSiteOrigin('https://d3ro.chanpaca.net/app/') === 'https://d3ro.chanpaca.net',
|
normalizeSiteOrigin(`${PUBLIC_SITE_ORIGIN}/app/`) === PUBLIC_SITE_ORIGIN,
|
||||||
'canonical HTTPS origin must normalize',
|
'canonical HTTPS origin must normalize',
|
||||||
)
|
)
|
||||||
assertContractError(() => normalizeSiteOrigin(undefined), 'site_url_not_configured', 503)
|
assertContractError(() => normalizeSiteOrigin(undefined), 'site_url_not_configured', 503)
|
||||||
|
|
@ -70,15 +71,15 @@ Deno.test('site URL is fail-closed to the canonical deployed HTTPS origin', () =
|
||||||
assertContractError(() => normalizeSiteOrigin('https://voice.chanpaca.net'), 'site_url_invalid', 503)
|
assertContractError(() => normalizeSiteOrigin('https://voice.chanpaca.net'), 'site_url_invalid', 503)
|
||||||
assertContractError(() => normalizeSiteOrigin('https://d3ro.dev'), 'site_url_invalid', 503)
|
assertContractError(() => normalizeSiteOrigin('https://d3ro.dev'), 'site_url_invalid', 503)
|
||||||
assertContractError(() => normalizeSiteOrigin('http://attacker.example'), 'site_url_invalid', 503)
|
assertContractError(() => normalizeSiteOrigin('http://attacker.example'), 'site_url_invalid', 503)
|
||||||
assertContractError(() => normalizeSiteOrigin('https://user:pass@d3ro.chanpaca.net'), 'site_url_invalid', 503)
|
assertContractError(() => normalizeSiteOrigin(PUBLIC_SITE_ORIGIN.replace('https://', 'https://user:pass@')), 'site_url_invalid', 503)
|
||||||
assertContractError(() => normalizeSiteOrigin('javascript:alert(1)'), 'site_url_invalid', 503)
|
assertContractError(() => normalizeSiteOrigin('javascript:alert(1)'), 'site_url_invalid', 503)
|
||||||
})
|
})
|
||||||
|
|
||||||
Deno.test('invite URL encodes the token and email HTML escapes every dynamic field', () => {
|
Deno.test('invite URL encodes the token and email HTML escapes every dynamic field', () => {
|
||||||
const token = 'abcdefghijklmnopqrstuvwx_123456'
|
const token = 'abcdefghijklmnopqrstuvwx_123456'
|
||||||
const url = buildInviteUrl('https://d3ro.chanpaca.net', token)
|
const url = buildInviteUrl(PUBLIC_SITE_ORIGIN, token)
|
||||||
assert(
|
assert(
|
||||||
url === `https://d3ro.chanpaca.net/accept-invite/?token=${token}`,
|
url === `${PUBLIC_SITE_ORIGIN}/accept-invite/?token=${token}`,
|
||||||
'canonical invite URL required',
|
'canonical invite URL required',
|
||||||
)
|
)
|
||||||
const html = buildInviteEmailHtml({
|
const html = buildInviteEmailHtml({
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { PUBLIC_SITE_ORIGIN } from './core-contract.generated.ts'
|
||||||
|
|
||||||
export type TeamInviteRole = 'admin' | 'member'
|
export type TeamInviteRole = 'admin' | 'member'
|
||||||
|
|
||||||
export interface TeamInviteInput {
|
export interface TeamInviteInput {
|
||||||
|
|
@ -23,7 +25,8 @@ export class TeamContractError extends Error {
|
||||||
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
||||||
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||||
const INVITE_TOKEN_PATTERN = /^[A-Za-z0-9_-]{20,128}$/
|
const INVITE_TOKEN_PATTERN = /^[A-Za-z0-9_-]{20,128}$/
|
||||||
export const CANONICAL_SITE_ORIGIN = 'https://d3ro.chanpaca.net'
|
/** 초대 링크가 허용하는 유일한 사이트 origin. 정본은 packages/core/src/web-urls.ts. */
|
||||||
|
export const CANONICAL_SITE_ORIGIN = PUBLIC_SITE_ORIGIN
|
||||||
|
|
||||||
function requireRecord(value: unknown, code: string): Record<string, unknown> {
|
function requireRecord(value: unknown, code: string): Record<string, unknown> {
|
||||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,23 @@
|
||||||
// site/src/pricing.ts
|
// site/src/pricing.ts
|
||||||
// 요금제 표시 SSOT. 결제 금액의 정본은 서버 결제 카탈로그
|
// 요금제 카드에 넣을 클라우드 사용량 표시값. 값의 정본은
|
||||||
// (server/supabase/functions/_shared/billing-catalog.ts, payple.ts)이며,
|
// packages/core/src/plan-catalog.ts `PLAN_QUOTA`(서버 quota.ts도 같은 값을 쓴다)이고,
|
||||||
// 여기 값은 그 카탈로그와 같아야 한다. 사용량 한도는
|
// 여기서는 i18n 문구의 {haiku}/{sonnet}/{opus} 자리표시자 이름으로 바꾸기만 한다.
|
||||||
// packages/core/src/constants.ts의 PREMIUM_MODEL_LIMITS와 같은 값이다.
|
// 가격(PLAN_PRICE_KRW)과 결제 링크(billingUrl)는 Pricing.tsx 가 정본에서 직접 읽는다.
|
||||||
|
|
||||||
export type PaidTier = 'pro' | 'pro_plus'
|
import { PLAN_QUOTA, type PlanQuotaFeature, type PlanTier } from '../../packages/core/src/plan-catalog'
|
||||||
|
|
||||||
/** 월 요금(원). */
|
const QUOTA_PLACEHOLDERS: ReadonlyArray<readonly [placeholder: string, feature: PlanQuotaFeature]> = [
|
||||||
export const PLAN_PRICE_KRW = {
|
['haiku', 'llm_haiku'],
|
||||||
free: 0,
|
['sonnet', 'llm_sonnet'],
|
||||||
pro: 2900,
|
['opus', 'llm_opus'],
|
||||||
proPlus: 8900,
|
]
|
||||||
} as const
|
|
||||||
|
|
||||||
/** 클라우드 다듬기 사용량. -1은 무제한. Free는 주간, 유료는 일간. */
|
/** 클라우드 다듬기 사용량. -1은 무제한. Free는 주간, 유료는 일간. 사용 불가(0) 모델은 뺀다. */
|
||||||
export const PLAN_CLOUD_QUOTA = {
|
export function planCloudQuota(tier: PlanTier): Record<string, number> {
|
||||||
free: { haiku: 250 },
|
const quota: Record<string, number> = {}
|
||||||
pro: { haiku: 1500, sonnet: 300, opus: 50 },
|
for (const [placeholder, feature] of QUOTA_PLACEHOLDERS) {
|
||||||
proPlus: { haiku: -1, sonnet: 1500, opus: 300 },
|
const { limit } = PLAN_QUOTA[tier][feature]
|
||||||
} as const
|
if (limit !== 0) quota[placeholder] = limit
|
||||||
|
}
|
||||||
/** 데스크톱 앱 결제 완료 URL과 같은 웹 결제 페이지. */
|
return quota
|
||||||
const BILLING_URL = 'https://d3ro.chanpaca.net/billing'
|
|
||||||
|
|
||||||
export function billingUrl(tier: PaidTier): string {
|
|
||||||
return `${BILLING_URL}?tier=${tier}`
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,9 @@ import { Container } from '../components/Container'
|
||||||
import { SectionHeading } from '../components/SectionHeading'
|
import { SectionHeading } from '../components/SectionHeading'
|
||||||
import { CheckIcon, ExternalIcon } from '../components/icons'
|
import { CheckIcon, ExternalIcon } from '../components/icons'
|
||||||
import { fmt, useI18n, type PlanCopy } from '../i18n'
|
import { fmt, useI18n, type PlanCopy } from '../i18n'
|
||||||
import { PLAN_CLOUD_QUOTA, PLAN_PRICE_KRW, billingUrl } from '../pricing'
|
import { PLAN_PRICE_KRW } from '../../../packages/core/src/plan-catalog'
|
||||||
|
import { billingUrl } from '../../../packages/core/src/web-urls'
|
||||||
|
import { planCloudQuota } from '../pricing'
|
||||||
|
|
||||||
interface PlanView {
|
interface PlanView {
|
||||||
key: 'free' | 'pro' | 'proPlus'
|
key: 'free' | 'pro' | 'proPlus'
|
||||||
|
|
@ -22,9 +24,9 @@ export function Pricing() {
|
||||||
const quotaValue = (n: number) => (n < 0 ? t.pricing.unlimited : count.format(n))
|
const quotaValue = (n: number) => (n < 0 ? t.pricing.unlimited : count.format(n))
|
||||||
|
|
||||||
const plans: PlanView[] = [
|
const plans: PlanView[] = [
|
||||||
{ key: 'free', copy: t.pricing.free, price: PLAN_PRICE_KRW.free, quota: PLAN_CLOUD_QUOTA.free, href: '#download', external: false, emphasized: false },
|
{ key: 'free', copy: t.pricing.free, price: PLAN_PRICE_KRW.free, quota: planCloudQuota('free'), href: '#download', external: false, emphasized: false },
|
||||||
{ key: 'pro', copy: t.pricing.pro, price: PLAN_PRICE_KRW.pro, quota: PLAN_CLOUD_QUOTA.pro, href: billingUrl('pro'), external: true, emphasized: true },
|
{ key: 'pro', copy: t.pricing.pro, price: PLAN_PRICE_KRW.pro, quota: planCloudQuota('pro'), href: billingUrl({ tier: 'pro' }), external: true, emphasized: true },
|
||||||
{ key: 'proPlus', copy: t.pricing.proPlus, price: PLAN_PRICE_KRW.proPlus, quota: PLAN_CLOUD_QUOTA.proPlus, href: billingUrl('pro_plus'), external: true, emphasized: false },
|
{ key: 'proPlus', copy: t.pricing.proPlus, price: PLAN_PRICE_KRW.pro_plus, quota: planCloudQuota('pro_plus'), href: billingUrl({ tier: 'pro_plus' }), external: true, emphasized: false },
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue