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

@ -58,6 +58,14 @@
"types": "./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": {
"types": "./src/supabase-config.ts",
"default": "./src/supabase-config.ts"

View file

@ -1,5 +1,6 @@
// src/shared/constants.ts
import type { LicenseTier } from './types'
import { PLAN_QUOTA, type PlanQuotaFeature, type PlanQuotaPeriod } from './plan-catalog'
/** 타이밍 상수 (Speakly 리버스엔지니어링 기반) */
export const TIMING = {
@ -51,39 +52,34 @@ export const AUDIO_FORMAT = {
BYTES_PER_SAMPLE: 2
} as const
/** Premium 모델별 쿼터 한도 (LicenseService QUOTA_LIMITS, 서버 quota.ts와 동기) */
/** Premium 모델별 쿼터 표시 행. 값의 정본은 plan-catalog.ts `PLAN_QUOTA`(서버 quota.ts도 같은 값을 생성 사본으로 읽는다). */
export interface PremiumModelQuota {
readonly model: string
readonly model: PlanQuotaFeature
readonly i18nKey: string
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[]> = {
free: [
{ model: 'llm_haiku', i18nKey: 'license.modelHaiku', limit: 250, period: 'weekly' },
],
pro: [
{ model: 'llm_haiku', i18nKey: 'license.modelHaiku', limit: 1500, period: 'daily' },
{ 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
free: premiumModelLimits('free'),
pro: premiumModelLimits('pro'),
pro_plus: premiumModelLimits('pro_plus'),
team: premiumModelLimits('team'),
enterprise: premiumModelLimits('enterprise'),
}
/** 윈도우 크기 */
export const WINDOW_SIZE = {

View file

@ -10,6 +10,8 @@ export * from './errors'
export * from './ipc-channels'
export * from './constants'
export * from './entitlement'
export * from './plan-catalog'
export * from './web-urls'
export * from './utils/crypto-license'
export * from './utils/pii-redactor'
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}` : ''}`
}