refactor(desktop): LemonSqueezy API 코드 완전 제거 (~150줄)
- LicenseService: LS API 상수/인터페이스/메서드 4개 삭제 - activate(): 로컬 키 검증 전용으로 단순화 - deactivate(): LS API 호출 제거 - initialize(): _tryPeriodicValidation 제거 - site/ taxNote: LemonSqueezy → Payple (10 locale)
This commit is contained in:
parent
1f2843c383
commit
36031acda9
11 changed files with 20 additions and 291 deletions
|
|
@ -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<number>('licenseActivatedAt')
|
||||
const storedLastVerified = this._readStoredField<number>('licenseLastVerifiedAt')
|
||||
const storedGrace = this._readStoredField<number>('licenseOfflineGraceUntil')
|
||||
this._instanceId = this._readStoredField<string>('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<ActivateLicenseResult> {
|
||||
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<void> {
|
||||
// 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<ActivateLicenseResult | null> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue