From 36031acda9cdd50c3fb55971789c30e3b1759ec6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=EC=B0=AC?= Date: Sun, 12 Apr 2026 20:15:40 +0900 Subject: [PATCH] =?UTF-8?q?refactor(desktop):=20LemonSqueezy=20API=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=99=84=EC=A0=84=20=EC=A0=9C=EA=B1=B0=20?= =?UTF-8?q?(~150=EC=A4=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LicenseService: LS API 상수/인터페이스/메서드 4개 삭제 - activate(): 로컬 키 검증 전용으로 단순화 - deactivate(): LS API 호출 제거 - initialize(): _tryPeriodicValidation 제거 - site/ taxNote: LemonSqueezy → Payple (10 locale) --- .../src/main/services/LicenseService.ts | 291 +----------------- site/src/i18n/locales/de.ts | 2 +- site/src/i18n/locales/en.ts | 2 +- site/src/i18n/locales/es.ts | 2 +- site/src/i18n/locales/fr.ts | 2 +- site/src/i18n/locales/ja.ts | 2 +- site/src/i18n/locales/ko.ts | 2 +- site/src/i18n/locales/pt.ts | 2 +- site/src/i18n/locales/ru.ts | 2 +- site/src/i18n/locales/vi.ts | 2 +- site/src/i18n/locales/zh.ts | 2 +- 11 files changed, 20 insertions(+), 291 deletions(-) diff --git a/apps/desktop/src/main/services/LicenseService.ts b/apps/desktop/src/main/services/LicenseService.ts index cf873db..1d0c7dc 100644 --- a/apps/desktop/src/main/services/LicenseService.ts +++ b/apps/desktop/src/main/services/LicenseService.ts @@ -1,5 +1,5 @@ // src/main/services/LicenseService.ts -// Phase 11: Freemium 라이센스 관리 — Feature Gating + 사용량 추적 + LemonSqueezy 연동 +// Phase 11: Freemium 라이센스 관리 — Feature Gating + 사용량 추적 import { EventEmitter } from 'events' import { createHash } from 'crypto' @@ -104,9 +104,6 @@ const OFFLINE_GRACE_PERIOD_MS = 30 * 24 * 60 * 60 * 1000 /** 온라인 재검증 주기: 30일 */ const REVERIFY_INTERVAL_MS = 30 * 24 * 60 * 60 * 1000 -/** LemonSqueezy API base URL */ -const LEMONSQUEEZY_API = 'https://api.lemonsqueezy.com/v1/licenses' - function tierAtLeast(current: LicenseTier, required: LicenseTier): boolean { return TIER_ORDER[current] >= TIER_ORDER[required] } @@ -126,70 +123,10 @@ function getTomorrowMidnight(): string { return tomorrow.toISOString() } -/** LemonSqueezy activate 응답 타입 */ -interface LemonSqueezyActivateResponse { - activated: boolean - error?: string - license_key?: { - id: number - status: string - key: string - activation_limit: number - activation_usage: number - } - instance?: { - id: string - name: string - } - meta?: { - store_id: number - order_id: number - product_id: number - product_name: string - variant_id: number - variant_name: string - } -} - -/** LemonSqueezy validate 응답 타입 */ -interface LemonSqueezyValidateResponse { - valid: boolean - error?: string - license_key?: { - id: number - status: string - key: string - activation_limit: number - activation_usage: number - } - meta?: { - store_id: number - order_id: number - product_id: number - product_name: string - variant_id: number - variant_name: string - } -} - -/** variant_name → LicenseTier 매핑 */ -function variantNameToTier(variantName: string): LicenseTier { - const lower = variantName.toLowerCase() - if (lower.includes('pro_plus') || lower.includes('pro+') || lower.includes('proplus')) { - return 'pro_plus' - } - if (lower.includes('pro')) { - return 'pro' - } - return 'free' -} - // ── LicenseService 싱글톤 ────────────────────────────────── class LicenseService extends EventEmitter { private _info: LicenseInfo private _initialized = false - /** LemonSqueezy instance_id (디바이스별, 비활성화에 필요) */ - private _instanceId: string | null = null constructor() { super() @@ -226,7 +163,6 @@ class LicenseService extends EventEmitter { const storedActivatedAt = this._readStoredField('licenseActivatedAt') const storedLastVerified = this._readStoredField('licenseLastVerifiedAt') const storedGrace = this._readStoredField('licenseOfflineGraceUntil') - this._instanceId = this._readStoredField('licenseInstanceId') if (storedTier && storedTier !== 'free' && storedKey) { this._info.tier = storedTier @@ -239,9 +175,6 @@ class LicenseService extends EventEmitter { if (this._info.offlineGraceUntil && Date.now() > this._info.offlineGraceUntil) { logger.warn('Offline grace period expired, downgrading to free') this._downgradeToFree() - } else { - // 온라인 재검증이 필요한 경우 비동기로 시도 - this._tryPeriodicValidation() } } @@ -419,9 +352,9 @@ class LicenseService extends EventEmitter { } /** - * 라이센스 키 활성화. - * 1. LemonSqueezy API로 활성화 시도 - * 2. API 실패 시 로컬 키 검증으로 폴백 (개발/오프라인) + * 라이센스 키 활성화 (로컬 키 검증 전용). + * D3RO-PRO-XXXX-XXXX / D3RO-PLUS-XXXX-XXXX 패턴으로 개발/테스트용. + * 프로덕션 결제는 Payple 웹 결제 → CloudSync 티어 갱신 경로 사용. */ async activate(key: string): Promise { const trimmedKey = key.trim() @@ -429,67 +362,29 @@ class LicenseService extends EventEmitter { return { success: false, tier: 'free', message: 'License key is empty' } } - // 1. 로컬 키 검증 먼저 (개발/테스트용 D3RO-PRO-*, D3RO-PLUS-* 패턴) const localTier = this._validateKeyLocally(trimmedKey) - if (localTier) { - logger.info(`Local key validated: tier=${localTier}`) - const now = Date.now() - this._info = { - ...this._info, - tier: localTier, - licenseKey: trimmedKey, - activatedAt: now, - lastVerifiedAt: now, - offlineGraceUntil: now + OFFLINE_GRACE_PERIOD_MS, - } - this._persistLicenseInfo() - this._sendToRenderer(IPC_CHANNELS.LICENSE.TIER_CHANGED, this._info) - return { success: true, tier: localTier, message: `Activated ${localTier} license (local)` } - } - - // 2. LemonSqueezy API 시도 - const apiResult = await this._activateViaLemonSqueezy(trimmedKey) - if (apiResult) { - return apiResult - } - - // 3. 둘 다 실패 - const tier = null as LicenseTier | null - if (!tier) { + if (!localTier) { return { success: false, tier: 'free', message: 'Invalid license key' } } + logger.info(`Local key validated: tier=${localTier}`) const now = Date.now() this._info = { ...this._info, - tier, + tier: localTier, licenseKey: trimmedKey, activatedAt: now, lastVerifiedAt: now, offlineGraceUntil: now + OFFLINE_GRACE_PERIOD_MS, } - this._persistLicenseInfo() - this.emit('tier-changed', this.getInfo()) - logger.info(`License activated (local): tier=${tier}`) - - return { - success: true, - tier, - message: `Successfully activated ${tier} license (offline mode)`, - } + this._sendToRenderer(IPC_CHANNELS.LICENSE.TIER_CHANGED, this._info) + return { success: true, tier: localTier, message: `Activated ${localTier} license (local)` } } /** 라이센스 비활성화 (Free로 복귀) */ async deactivate(): Promise { - // LemonSqueezy API로 비활성화 시도 - if (this._info.licenseKey && this._instanceId) { - await this._deactivateViaLemonSqueezy(this._info.licenseKey, this._instanceId) - } - this._downgradeToFree() - this._instanceId = null - this._writeStoredField('licenseInstanceId', null) this.emit('tier-changed', this.getInfo()) logger.info('License deactivated, reverted to free') } @@ -636,176 +531,10 @@ class LicenseService extends EventEmitter { ] } - // ── LemonSqueezy API ─────────────────────────────────── - - /** - * LemonSqueezy API를 통한 라이센스 활성화. - * API 실패 시 null 반환 (폴백 처리를 호출자에게 위임). - */ - private async _activateViaLemonSqueezy(key: string): Promise { - try { - const response = await fetch(`${LEMONSQUEEZY_API}/activate`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, - body: JSON.stringify({ - license_key: key, - instance_name: this._info.machineId, - }), - signal: AbortSignal.timeout(10000), - }) - - const data = await response.json() as LemonSqueezyActivateResponse - - if (!data.activated || !data.license_key) { - const errorMsg = data.error ?? 'Activation rejected by server' - logger.warn(`LemonSqueezy activation failed: ${errorMsg}`) - return { success: false, tier: 'free', message: errorMsg } - } - - // 키 상태 체크 - if (data.license_key.status === 'expired') { - return { success: false, tier: 'free', message: 'License key has expired' } - } - - // variant_name으로 티어 결정 - const tier = data.meta?.variant_name - ? variantNameToTier(data.meta.variant_name) - : 'pro' - - const now = Date.now() - this._info = { - ...this._info, - tier, - licenseKey: key, - activatedAt: now, - lastVerifiedAt: now, - offlineGraceUntil: now + OFFLINE_GRACE_PERIOD_MS, - } - - // instance_id 저장 (비활성화에 필요) - if (data.instance?.id) { - this._instanceId = data.instance.id - this._writeStoredField('licenseInstanceId', this._instanceId) - } - - this._persistLicenseInfo() - this.emit('tier-changed', this.getInfo()) - logger.info(`License activated via LemonSqueezy: tier=${tier}`) - - return { - success: true, - tier, - message: `Successfully activated ${tier} license`, - } - } catch (err) { - // 네트워크 에러, 타임아웃 등 — null 반환하여 로컬 폴백 - logger.warn(`LemonSqueezy API unreachable: ${err instanceof Error ? err.message : String(err)}`) - return null - } - } - - /** - * LemonSqueezy API를 통한 라이센스 비활성화. - * 실패해도 로컬 상태는 변경 (best-effort). - */ - private async _deactivateViaLemonSqueezy(key: string, instanceId: string): Promise { - try { - await fetch(`${LEMONSQUEEZY_API}/deactivate`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, - body: JSON.stringify({ - license_key: key, - instance_id: instanceId, - }), - signal: AbortSignal.timeout(10000), - }) - logger.info('License deactivated via LemonSqueezy API') - } catch (err) { - logger.warn(`LemonSqueezy deactivation failed (best-effort): ${err instanceof Error ? err.message : String(err)}`) - } - } - - /** - * LemonSqueezy API를 통한 온라인 검증. - * @returns true if license is valid, false otherwise - */ - private async _validateOnline(): Promise { - if (!this._info.licenseKey) return false - - try { - const response = await fetch(`${LEMONSQUEEZY_API}/validate`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, - body: JSON.stringify({ - license_key: this._info.licenseKey, - instance_name: this._info.machineId, - }), - signal: AbortSignal.timeout(10000), - }) - - const data = await response.json() as LemonSqueezyValidateResponse - - if (!data.valid) { - logger.warn(`Online validation failed: ${data.error ?? 'invalid'}`) - - // 키가 expired인 경우 다운그레이드 - if (data.license_key?.status === 'expired') { - logger.warn('License expired, downgrading to free') - this._downgradeToFree() - this.emit('tier-changed', this.getInfo()) - return false - } - - return false - } - - // 검증 성공 — 타임스탬프 갱신 - const now = Date.now() - this._info.lastVerifiedAt = now - this._info.offlineGraceUntil = now + OFFLINE_GRACE_PERIOD_MS - - // variant가 바뀌었을 수 있음 (업/다운그레이드) - if (data.meta?.variant_name) { - const newTier = variantNameToTier(data.meta.variant_name) - if (newTier !== this._info.tier) { - logger.info(`Tier changed via validation: ${this._info.tier} -> ${newTier}`) - this._info.tier = newTier - this.emit('tier-changed', this.getInfo()) - } - } - - this._persistLicenseInfo() - logger.info('Online license validation successful') - return true - } catch (err) { - // 네트워크 에러 — 오프라인 유예 기간 유지 - logger.warn(`Online validation failed (network): ${err instanceof Error ? err.message : String(err)}`) - return false - } - } - - /** - * 주기적 온라인 검증 (초기화 시 호출). - * lastVerifiedAt으로부터 REVERIFY_INTERVAL_MS 이상 경과했으면 비동기 검증. - */ - private _tryPeriodicValidation(): void { - if (!this._info.lastVerifiedAt) return - - const elapsed = Date.now() - this._info.lastVerifiedAt - if (elapsed > REVERIFY_INTERVAL_MS) { - logger.info('Periodic license re-validation needed, attempting...') - // 비동기 실행 (결과를 기다리지 않음 — 초기화 차단 방지) - this._validateOnline().catch((err) => { - logger.warn(`Periodic validation error: ${err instanceof Error ? err.message : String(err)}`) - }) - } - } - // ── Private helpers ──────────────────────────────────── /** - * 로컬 키 검증 (개발/테스트 + 오프라인 패턴). - * 프로덕션에서는 LemonSqueezy API가 우선 사용됨. + * 로컬 키 검증 (개발/테스트용). * * 키 포맷 규칙: * D3RO-PRO-XXXX-XXXX -> pro diff --git a/site/src/i18n/locales/de.ts b/site/src/i18n/locales/de.ts index 5bcd77d..43b3199 100644 --- a/site/src/i18n/locales/de.ts +++ b/site/src/i18n/locales/de.ts @@ -97,7 +97,7 @@ export const de: Translations = { downloadFree: 'Gratis herunterladen', getPro: 'Pro holen', getProPlus: 'Pro+ holen', - taxNote: 'Alle Preise zzgl. MwSt. Sichere Zahlung uber LemonSqueezy.', + taxNote: 'Alle Preise zzgl. MwSt. Sichere Zahlung uber Payple.', rows: [ { feature: 'Sprachdiktat', free: '15/Tag', pro: true, proPlus: true }, { feature: 'KI-Textkorrektur', free: '3/Tag', pro: true, proPlus: true }, diff --git a/site/src/i18n/locales/en.ts b/site/src/i18n/locales/en.ts index 2327c59..4b402f3 100644 --- a/site/src/i18n/locales/en.ts +++ b/site/src/i18n/locales/en.ts @@ -97,7 +97,7 @@ export const en: Translations = { downloadFree: 'Download Free', getPro: 'Get Pro', getProPlus: 'Get Pro+', - taxNote: 'All prices exclude tax. Secure payment via LemonSqueezy.', + taxNote: 'All prices exclude tax. Secure payment via Payple.', rows: [ { feature: 'Voice Dictation', free: '15/day', pro: true, proPlus: true }, { feature: 'AI Text Polish', free: '3/day', pro: true, proPlus: true }, diff --git a/site/src/i18n/locales/es.ts b/site/src/i18n/locales/es.ts index 009f33a..2a8009d 100644 --- a/site/src/i18n/locales/es.ts +++ b/site/src/i18n/locales/es.ts @@ -97,7 +97,7 @@ export const es: Translations = { downloadFree: 'Descargar gratis', getPro: 'Obtener Pro', getProPlus: 'Obtener Pro+', - taxNote: 'Todos los precios sin impuestos. Pago seguro via LemonSqueezy.', + taxNote: 'Todos los precios sin impuestos. Pago seguro via Payple.', rows: [ { feature: 'Dictado por voz', free: '15/dia', pro: true, proPlus: true }, { feature: 'Pulido con IA', free: '3/dia', pro: true, proPlus: true }, diff --git a/site/src/i18n/locales/fr.ts b/site/src/i18n/locales/fr.ts index 79eaa73..da4f322 100644 --- a/site/src/i18n/locales/fr.ts +++ b/site/src/i18n/locales/fr.ts @@ -97,7 +97,7 @@ export const fr: Translations = { downloadFree: 'Telecharger gratuit', getPro: 'Obtenir Pro', getProPlus: 'Obtenir Pro+', - taxNote: 'Tous les prix hors taxes. Paiement securise via LemonSqueezy.', + taxNote: 'Tous les prix hors taxes. Paiement securise via Payple.', rows: [ { feature: 'Dictee vocale', free: '15/jour', pro: true, proPlus: true }, { feature: 'Correction IA', free: '3/jour', pro: true, proPlus: true }, diff --git a/site/src/i18n/locales/ja.ts b/site/src/i18n/locales/ja.ts index e39cb68..c7633e3 100644 --- a/site/src/i18n/locales/ja.ts +++ b/site/src/i18n/locales/ja.ts @@ -97,7 +97,7 @@ export const ja: Translations = { downloadFree: '無料ダウンロード', getPro: 'Proを購入', getProPlus: 'Pro+を購入', - taxNote: '表示価格は税抜きです。LemonSqueezyによる安全な決済。', + taxNote: '表示価格は税抜きです。Paypleによる安全な決済。', rows: [ { feature: '音声ディクテーション', free: '15回/日', pro: true, proPlus: true }, { feature: 'AIテキスト校正', free: '3回/日', pro: true, proPlus: true }, diff --git a/site/src/i18n/locales/ko.ts b/site/src/i18n/locales/ko.ts index c5e74df..513ac1c 100644 --- a/site/src/i18n/locales/ko.ts +++ b/site/src/i18n/locales/ko.ts @@ -97,7 +97,7 @@ export const ko: Translations = { downloadFree: '무료 다운로드', getPro: 'Pro 구매', getProPlus: 'Pro+ 구매', - taxNote: '모든 가격은 세금 별도입니다. LemonSqueezy를 통한 안전한 결제.', + taxNote: '모든 가격은 세금 별도입니다. Payple를 통한 안전한 결제.', rows: [ { feature: '음성 받아쓰기', free: '15회/일', pro: true, proPlus: true }, { feature: 'AI 텍스트 다듬기', free: '3회/일', pro: true, proPlus: true }, diff --git a/site/src/i18n/locales/pt.ts b/site/src/i18n/locales/pt.ts index 5ecfa0d..9a07512 100644 --- a/site/src/i18n/locales/pt.ts +++ b/site/src/i18n/locales/pt.ts @@ -97,7 +97,7 @@ export const pt: Translations = { downloadFree: 'Baixar gratis', getPro: 'Obter Pro', getProPlus: 'Obter Pro+', - taxNote: 'Todos os precos excluem impostos. Pagamento seguro via LemonSqueezy.', + taxNote: 'Todos os precos excluem impostos. Pagamento seguro via Payple.', rows: [ { feature: 'Ditado por voz', free: '15/dia', pro: true, proPlus: true }, { feature: 'Polimento com IA', free: '3/dia', pro: true, proPlus: true }, diff --git a/site/src/i18n/locales/ru.ts b/site/src/i18n/locales/ru.ts index bad7045..b52507c 100644 --- a/site/src/i18n/locales/ru.ts +++ b/site/src/i18n/locales/ru.ts @@ -97,7 +97,7 @@ export const ru: Translations = { downloadFree: 'Скачать бесплатно', getPro: 'Получить Pro', getProPlus: 'Получить Pro+', - taxNote: 'Все цены без учета налогов. Безопасная оплата через LemonSqueezy.', + taxNote: 'Все цены без учета налогов. Безопасная оплата через Payple.', rows: [ { feature: 'Голосовая диктовка', free: '15/день', pro: true, proPlus: true }, { feature: 'ИИ-корректировка текста', free: '3/день', pro: true, proPlus: true }, diff --git a/site/src/i18n/locales/vi.ts b/site/src/i18n/locales/vi.ts index 7f2c4cb..29e4d06 100644 --- a/site/src/i18n/locales/vi.ts +++ b/site/src/i18n/locales/vi.ts @@ -97,7 +97,7 @@ export const vi: Translations = { downloadFree: 'Tai mien phi', getPro: 'Mua Pro', getProPlus: 'Mua Pro+', - taxNote: 'Tat ca gia chua bao gom thue. Thanh toan an toan qua LemonSqueezy.', + taxNote: 'Tat ca gia chua bao gom thue. Thanh toan an toan qua Payple.', rows: [ { feature: 'Chinh ta giong noi', free: '15/ngay', pro: true, proPlus: true }, { feature: 'Tinh chinh van ban AI', free: '3/ngay', pro: true, proPlus: true }, diff --git a/site/src/i18n/locales/zh.ts b/site/src/i18n/locales/zh.ts index eb8b960..7a8b5ff 100644 --- a/site/src/i18n/locales/zh.ts +++ b/site/src/i18n/locales/zh.ts @@ -97,7 +97,7 @@ export const zh: Translations = { downloadFree: '免费下载', getPro: '获取 Pro', getProPlus: '获取 Pro+', - taxNote: '所有价格不含税。通过 LemonSqueezy 安全支付。', + taxNote: '所有价格不含税。通过 Payple 安全支付。', rows: [ { feature: '语音听写', free: '15次/天', pro: true, proPlus: true }, { feature: 'AI文本润色', free: '3次/天', pro: true, proPlus: true },