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:
윤찬 2026-04-12 20:15:40 +09:00
parent 1f2843c383
commit 36031acda9
11 changed files with 20 additions and 291 deletions

View file

@ -1,5 +1,5 @@
// src/main/services/LicenseService.ts // src/main/services/LicenseService.ts
// Phase 11: Freemium 라이센스 관리 — Feature Gating + 사용량 추적 + LemonSqueezy 연동 // Phase 11: Freemium 라이센스 관리 — Feature Gating + 사용량 추적
import { EventEmitter } from 'events' import { EventEmitter } from 'events'
import { createHash } from 'crypto' import { createHash } from 'crypto'
@ -104,9 +104,6 @@ const OFFLINE_GRACE_PERIOD_MS = 30 * 24 * 60 * 60 * 1000
/** 온라인 재검증 주기: 30일 */ /** 온라인 재검증 주기: 30일 */
const REVERIFY_INTERVAL_MS = 30 * 24 * 60 * 60 * 1000 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 { function tierAtLeast(current: LicenseTier, required: LicenseTier): boolean {
return TIER_ORDER[current] >= TIER_ORDER[required] return TIER_ORDER[current] >= TIER_ORDER[required]
} }
@ -126,70 +123,10 @@ function getTomorrowMidnight(): string {
return tomorrow.toISOString() 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 싱글톤 ────────────────────────────────── // ── LicenseService 싱글톤 ──────────────────────────────────
class LicenseService extends EventEmitter { class LicenseService extends EventEmitter {
private _info: LicenseInfo private _info: LicenseInfo
private _initialized = false private _initialized = false
/** LemonSqueezy instance_id (디바이스별, 비활성화에 필요) */
private _instanceId: string | null = null
constructor() { constructor() {
super() super()
@ -226,7 +163,6 @@ class LicenseService extends EventEmitter {
const storedActivatedAt = this._readStoredField<number>('licenseActivatedAt') const storedActivatedAt = this._readStoredField<number>('licenseActivatedAt')
const storedLastVerified = this._readStoredField<number>('licenseLastVerifiedAt') const storedLastVerified = this._readStoredField<number>('licenseLastVerifiedAt')
const storedGrace = this._readStoredField<number>('licenseOfflineGraceUntil') const storedGrace = this._readStoredField<number>('licenseOfflineGraceUntil')
this._instanceId = this._readStoredField<string>('licenseInstanceId')
if (storedTier && storedTier !== 'free' && storedKey) { if (storedTier && storedTier !== 'free' && storedKey) {
this._info.tier = storedTier this._info.tier = storedTier
@ -239,9 +175,6 @@ class LicenseService extends EventEmitter {
if (this._info.offlineGraceUntil && Date.now() > this._info.offlineGraceUntil) { if (this._info.offlineGraceUntil && Date.now() > this._info.offlineGraceUntil) {
logger.warn('Offline grace period expired, downgrading to free') logger.warn('Offline grace period expired, downgrading to free')
this._downgradeToFree() this._downgradeToFree()
} else {
// 온라인 재검증이 필요한 경우 비동기로 시도
this._tryPeriodicValidation()
} }
} }
@ -419,9 +352,9 @@ class LicenseService extends EventEmitter {
} }
/** /**
* . * ( ).
* 1. LemonSqueezy API로 * D3RO-PRO-XXXX-XXXX / D3RO-PLUS-XXXX-XXXX /.
* 2. API (/) * Payple CloudSync .
*/ */
async activate(key: string): Promise<ActivateLicenseResult> { async activate(key: string): Promise<ActivateLicenseResult> {
const trimmedKey = key.trim() const trimmedKey = key.trim()
@ -429,67 +362,29 @@ class LicenseService extends EventEmitter {
return { success: false, tier: 'free', message: 'License key is empty' } return { success: false, tier: 'free', message: 'License key is empty' }
} }
// 1. 로컬 키 검증 먼저 (개발/테스트용 D3RO-PRO-*, D3RO-PLUS-* 패턴)
const localTier = this._validateKeyLocally(trimmedKey) const localTier = this._validateKeyLocally(trimmedKey)
if (localTier) { 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) {
return { success: false, tier: 'free', message: 'Invalid license key' } return { success: false, tier: 'free', message: 'Invalid license key' }
} }
logger.info(`Local key validated: tier=${localTier}`)
const now = Date.now() const now = Date.now()
this._info = { this._info = {
...this._info, ...this._info,
tier, tier: localTier,
licenseKey: trimmedKey, licenseKey: trimmedKey,
activatedAt: now, activatedAt: now,
lastVerifiedAt: now, lastVerifiedAt: now,
offlineGraceUntil: now + OFFLINE_GRACE_PERIOD_MS, offlineGraceUntil: now + OFFLINE_GRACE_PERIOD_MS,
} }
this._persistLicenseInfo() this._persistLicenseInfo()
this.emit('tier-changed', this.getInfo()) this._sendToRenderer(IPC_CHANNELS.LICENSE.TIER_CHANGED, this._info)
logger.info(`License activated (local): tier=${tier}`) return { success: true, tier: localTier, message: `Activated ${localTier} license (local)` }
return {
success: true,
tier,
message: `Successfully activated ${tier} license (offline mode)`,
}
} }
/** 라이센스 비활성화 (Free로 복귀) */ /** 라이센스 비활성화 (Free로 복귀) */
async deactivate(): Promise<void> { async deactivate(): Promise<void> {
// LemonSqueezy API로 비활성화 시도
if (this._info.licenseKey && this._instanceId) {
await this._deactivateViaLemonSqueezy(this._info.licenseKey, this._instanceId)
}
this._downgradeToFree() this._downgradeToFree()
this._instanceId = null
this._writeStoredField('licenseInstanceId', null)
this.emit('tier-changed', this.getInfo()) this.emit('tier-changed', this.getInfo())
logger.info('License deactivated, reverted to free') 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 ──────────────────────────────────── // ── Private helpers ────────────────────────────────────
/** /**
* (/ + ). * (/).
* LemonSqueezy API가 .
* *
* : * :
* D3RO-PRO-XXXX-XXXX -> pro * D3RO-PRO-XXXX-XXXX -> pro

View file

@ -97,7 +97,7 @@ export const de: Translations = {
downloadFree: 'Gratis herunterladen', downloadFree: 'Gratis herunterladen',
getPro: 'Pro holen', getPro: 'Pro holen',
getProPlus: 'Pro+ holen', getProPlus: 'Pro+ holen',
taxNote: 'Alle Preise zzgl. MwSt. Sichere Zahlung uber LemonSqueezy.', taxNote: 'Alle Preise zzgl. MwSt. Sichere Zahlung uber Payple.',
rows: [ rows: [
{ feature: 'Sprachdiktat', free: '15/Tag', pro: true, proPlus: true }, { feature: 'Sprachdiktat', free: '15/Tag', pro: true, proPlus: true },
{ feature: 'KI-Textkorrektur', free: '3/Tag', pro: true, proPlus: true }, { feature: 'KI-Textkorrektur', free: '3/Tag', pro: true, proPlus: true },

View file

@ -97,7 +97,7 @@ export const en: Translations = {
downloadFree: 'Download Free', downloadFree: 'Download Free',
getPro: 'Get Pro', getPro: 'Get Pro',
getProPlus: 'Get Pro+', getProPlus: 'Get Pro+',
taxNote: 'All prices exclude tax. Secure payment via LemonSqueezy.', taxNote: 'All prices exclude tax. Secure payment via Payple.',
rows: [ rows: [
{ feature: 'Voice Dictation', free: '15/day', pro: true, proPlus: true }, { feature: 'Voice Dictation', free: '15/day', pro: true, proPlus: true },
{ feature: 'AI Text Polish', free: '3/day', pro: true, proPlus: true }, { feature: 'AI Text Polish', free: '3/day', pro: true, proPlus: true },

View file

@ -97,7 +97,7 @@ export const es: Translations = {
downloadFree: 'Descargar gratis', downloadFree: 'Descargar gratis',
getPro: 'Obtener Pro', getPro: 'Obtener Pro',
getProPlus: '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: [ rows: [
{ feature: 'Dictado por voz', free: '15/dia', pro: true, proPlus: true }, { feature: 'Dictado por voz', free: '15/dia', pro: true, proPlus: true },
{ feature: 'Pulido con IA', free: '3/dia', pro: true, proPlus: true }, { feature: 'Pulido con IA', free: '3/dia', pro: true, proPlus: true },

View file

@ -97,7 +97,7 @@ export const fr: Translations = {
downloadFree: 'Telecharger gratuit', downloadFree: 'Telecharger gratuit',
getPro: 'Obtenir Pro', getPro: 'Obtenir Pro',
getProPlus: '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: [ rows: [
{ feature: 'Dictee vocale', free: '15/jour', pro: true, proPlus: true }, { feature: 'Dictee vocale', free: '15/jour', pro: true, proPlus: true },
{ feature: 'Correction IA', free: '3/jour', pro: true, proPlus: true }, { feature: 'Correction IA', free: '3/jour', pro: true, proPlus: true },

View file

@ -97,7 +97,7 @@ export const ja: Translations = {
downloadFree: '無料ダウンロード', downloadFree: '無料ダウンロード',
getPro: 'Proを購入', getPro: 'Proを購入',
getProPlus: 'Pro+を購入', getProPlus: 'Pro+を購入',
taxNote: '表示価格は税抜きです。LemonSqueezyによる安全な決済。', taxNote: '表示価格は税抜きです。Paypleによる安全な決済。',
rows: [ rows: [
{ feature: '音声ディクテーション', free: '15回/日', pro: true, proPlus: true }, { feature: '音声ディクテーション', free: '15回/日', pro: true, proPlus: true },
{ feature: 'AIテキスト校正', free: '3回/日', pro: true, proPlus: true }, { feature: 'AIテキスト校正', free: '3回/日', pro: true, proPlus: true },

View file

@ -97,7 +97,7 @@ export const ko: Translations = {
downloadFree: '무료 다운로드', downloadFree: '무료 다운로드',
getPro: 'Pro 구매', getPro: 'Pro 구매',
getProPlus: 'Pro+ 구매', getProPlus: 'Pro+ 구매',
taxNote: '모든 가격은 세금 별도입니다. LemonSqueezy를 통한 안전한 결제.', taxNote: '모든 가격은 세금 별도입니다. Payple를 통한 안전한 결제.',
rows: [ rows: [
{ feature: '음성 받아쓰기', free: '15회/일', pro: true, proPlus: true }, { feature: '음성 받아쓰기', free: '15회/일', pro: true, proPlus: true },
{ feature: 'AI 텍스트 다듬기', free: '3회/일', pro: true, proPlus: true }, { feature: 'AI 텍스트 다듬기', free: '3회/일', pro: true, proPlus: true },

View file

@ -97,7 +97,7 @@ export const pt: Translations = {
downloadFree: 'Baixar gratis', downloadFree: 'Baixar gratis',
getPro: 'Obter Pro', getPro: 'Obter Pro',
getProPlus: '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: [ rows: [
{ feature: 'Ditado por voz', free: '15/dia', pro: true, proPlus: true }, { feature: 'Ditado por voz', free: '15/dia', pro: true, proPlus: true },
{ feature: 'Polimento com IA', free: '3/dia', pro: true, proPlus: true }, { feature: 'Polimento com IA', free: '3/dia', pro: true, proPlus: true },

View file

@ -97,7 +97,7 @@ export const ru: Translations = {
downloadFree: 'Скачать бесплатно', downloadFree: 'Скачать бесплатно',
getPro: 'Получить Pro', getPro: 'Получить Pro',
getProPlus: 'Получить Pro+', getProPlus: 'Получить Pro+',
taxNote: 'Все цены без учета налогов. Безопасная оплата через LemonSqueezy.', taxNote: 'Все цены без учета налогов. Безопасная оплата через Payple.',
rows: [ rows: [
{ feature: 'Голосовая диктовка', free: '15/день', pro: true, proPlus: true }, { feature: 'Голосовая диктовка', free: '15/день', pro: true, proPlus: true },
{ feature: 'ИИ-корректировка текста', free: '3/день', pro: true, proPlus: true }, { feature: 'ИИ-корректировка текста', free: '3/день', pro: true, proPlus: true },

View file

@ -97,7 +97,7 @@ export const vi: Translations = {
downloadFree: 'Tai mien phi', downloadFree: 'Tai mien phi',
getPro: 'Mua Pro', getPro: 'Mua Pro',
getProPlus: '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: [ rows: [
{ feature: 'Chinh ta giong noi', free: '15/ngay', pro: true, proPlus: true }, { 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 }, { feature: 'Tinh chinh van ban AI', free: '3/ngay', pro: true, proPlus: true },

View file

@ -97,7 +97,7 @@ export const zh: Translations = {
downloadFree: '免费下载', downloadFree: '免费下载',
getPro: '获取 Pro', getPro: '获取 Pro',
getProPlus: '获取 Pro+', getProPlus: '获取 Pro+',
taxNote: '所有价格不含税。通过 LemonSqueezy 安全支付。', taxNote: '所有价格不含税。通过 Payple 安全支付。',
rows: [ rows: [
{ feature: '语音听写', free: '15次/天', pro: true, proPlus: true }, { feature: '语音听写', free: '15次/天', pro: true, proPlus: true },
{ feature: 'AI文本润色', free: '3次/天', pro: true, proPlus: true }, { feature: 'AI文本润色', free: '3次/天', pro: true, proPlus: true },