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:
Yun Chan 2026-09-26 15:48:18 +09:00
parent e689683b72
commit 88f24d84a1
21 changed files with 568 additions and 152 deletions

View file

@ -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}

View file

@ -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={{

View file

@ -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>

View file

@ -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 }
} }

View file

@ -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.

View file

@ -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",

View file

@ -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"

View file

@ -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 = {

View file

@ -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'

View 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' },
},
}

View 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}` : ''}`
}

View 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)
}

View file

@ -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')

View 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}` : ''}`
}

View file

@ -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')
})

View file

@ -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> = {

View file

@ -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 {

View file

@ -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({

View file

@ -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)) {

View file

@ -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}`
} }

View file

@ -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 (