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
|
|
@ -2,6 +2,7 @@ import {
|
|||
createBillingCatalog,
|
||||
parseStripeCatalogPrice,
|
||||
} from './billing-catalog.ts'
|
||||
import { PLAN_PRICE_KRW } from './core-contract.generated.ts'
|
||||
|
||||
function assert(condition: unknown, message: string): asserts condition {
|
||||
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', () => {
|
||||
const catalog = createBillingCatalog({
|
||||
payple: { pro: 9900, pro_plus: 29900 },
|
||||
payple: { pro: PLAN_PRICE_KRW.pro, pro_plus: PLAN_PRICE_KRW.pro_plus },
|
||||
stripe: {
|
||||
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[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({})
|
||||
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,
|
||||
PaypleVerificationError,
|
||||
resolvePaypleOrderDate,
|
||||
TIER_PRICE,
|
||||
} from './payple.ts'
|
||||
import { PLAN_PRICE_KRW } from './core-contract.generated.ts'
|
||||
|
||||
function assert(condition: boolean, message: string): asserts condition {
|
||||
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',
|
||||
payerId: 'payer-fixture',
|
||||
payType: 'card' as const,
|
||||
amount: 9900,
|
||||
amount: TIER_PRICE.pro,
|
||||
}
|
||||
const directDigest = 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(directDigest === webhookDigest, 'same external charge must be an exact replay')
|
||||
assert(directDigest !== differentCharge, 'different charge identity must not collide')
|
||||
|
|
@ -163,7 +165,7 @@ Deno.test('Payple billing separates definitive declines from ambiguous transport
|
|||
try {
|
||||
await paypleBilling(config, auth, {
|
||||
payerId: 'payer-fixture',
|
||||
amount: 9900,
|
||||
amount: TIER_PRICE.pro,
|
||||
orderId: 'D3RO-20260821153045-test-nonce',
|
||||
goodsName: 'D3RO Voice Pro',
|
||||
})
|
||||
|
|
@ -177,3 +179,9 @@ Deno.test('Payple billing separates definitive declines from ambiguous transport
|
|||
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
|
||||
// Payple 결제 API 래퍼 — 파트너 인증, 빌링 결제, 취소, 빌링키 해지
|
||||
|
||||
import { PLAN_PRICE_KRW } from './core-contract.generated.ts'
|
||||
|
||||
// ── 타입 ──────────────────────────────────────────────
|
||||
|
||||
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> = {
|
||||
pro: 9900,
|
||||
pro_plus: 29900,
|
||||
pro: PLAN_PRICE_KRW.pro,
|
||||
pro_plus: PLAN_PRICE_KRW.pro_plus,
|
||||
}
|
||||
|
||||
export const TIER_GOODS_NAME: Record<string, string> = {
|
||||
|
|
|
|||
|
|
@ -3,64 +3,25 @@
|
|||
// Free=Haiku 250/주간, Pro=모델별 일간, Pro+=Haiku 무제한
|
||||
|
||||
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 =
|
||||
| 'stt_transcribe'
|
||||
| 'llm_haiku'
|
||||
| 'llm_sonnet'
|
||||
| 'llm_opus'
|
||||
| 'realtime_session'
|
||||
export type QuotaFeature = PlanQuotaFeature
|
||||
|
||||
export type QuotaPeriod = 'daily' | 'weekly'
|
||||
export type QuotaPeriod = PlanQuotaPeriod
|
||||
|
||||
interface ModelQuota {
|
||||
/** -1=무제한, 0=사용불가, 양수=한도 */
|
||||
limit: number
|
||||
period: QuotaPeriod
|
||||
}
|
||||
type ModelQuota = PlanQuota
|
||||
|
||||
/** 모델별 쿼터 정책 */
|
||||
const MODEL_QUOTA: Record<Tier, Record<QuotaFeature, ModelQuota>> = {
|
||||
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/plan-catalog.ts `PLAN_QUOTA` (생성 사본 경유). */
|
||||
const MODEL_QUOTA: Readonly<Record<Tier, Readonly<Record<QuotaFeature, ModelQuota>>>> = PLAN_QUOTA
|
||||
|
||||
/** Anthropic 모델명 → 쿼터 키 매핑 */
|
||||
export function modelToQuotaKey(model: string): QuotaFeature {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
parseTeamInviteInput,
|
||||
TeamContractError,
|
||||
} from './team-contract.ts'
|
||||
import { PUBLIC_SITE_ORIGIN } from './core-contract.generated.ts'
|
||||
|
||||
function assert(condition: boolean, message: string): asserts condition {
|
||||
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', () => {
|
||||
assert(
|
||||
normalizeSiteOrigin('https://d3ro.chanpaca.net/app/') === 'https://d3ro.chanpaca.net',
|
||||
normalizeSiteOrigin(`${PUBLIC_SITE_ORIGIN}/app/`) === PUBLIC_SITE_ORIGIN,
|
||||
'canonical HTTPS origin must normalize',
|
||||
)
|
||||
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://d3ro.dev'), '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)
|
||||
})
|
||||
|
||||
Deno.test('invite URL encodes the token and email HTML escapes every dynamic field', () => {
|
||||
const token = 'abcdefghijklmnopqrstuvwx_123456'
|
||||
const url = buildInviteUrl('https://d3ro.chanpaca.net', token)
|
||||
const url = buildInviteUrl(PUBLIC_SITE_ORIGIN, token)
|
||||
assert(
|
||||
url === `https://d3ro.chanpaca.net/accept-invite/?token=${token}`,
|
||||
url === `${PUBLIC_SITE_ORIGIN}/accept-invite/?token=${token}`,
|
||||
'canonical invite URL required',
|
||||
)
|
||||
const html = buildInviteEmailHtml({
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { PUBLIC_SITE_ORIGIN } from './core-contract.generated.ts'
|
||||
|
||||
export type TeamInviteRole = 'admin' | 'member'
|
||||
|
||||
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 EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
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> {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue