From eedd127ea7a1d612f1e42187f61800f4c4948d75 Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Sat, 26 Sep 2026 20:56:18 +0900 Subject: [PATCH] refactor(billing): remove Stripe; payments are Payple (web) and Google Play (mobile) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stripe is not used. Keeping its checkout, portal and webhook paths meant a second payment provider, a second return-URL format and dead UI. - Delete the stripe-checkout, stripe-portal and stripe-webhook functions and their config; billing-catalog serves Payple prices only, and the web parser rejects a catalog that still mixes in Stripe prices. - Web: drop the Stripe checkout/portal buttons, provider toggle and return notices; billing shows Payple only. Past rows with provider='stripe' are still displayed ("Stripe (종료)") with a support contact instead of a portal. - Desktop: delete the Stripe checkout modal, payment IPC channels, preload namespace and their types; "Remove ads with Pro" opens the web billing page via license.openBilling. Support/refund copy names Payple. - billingUrl() loses the Stripe-only success/canceled result option; the Deno contract is regenerated. - Migrations and the DB's accepted provider values are untouched (history). - Docs and the backlog record the removal (MON-04, EXT-STRIPE-01, GAP-BILL-03). Verified: typecheck (desktop/web/admin/api-client/mobile), contract:check, deno check all functions, deno test 80/80, desktop 1478/1480 on the Electron runtime (2 known environment failures), web and admin builds, release metadata and mobile boundary self-tests, eslint on changed files. --- apps/desktop/src/main/ipc/index.ts | 2 - apps/desktop/src/main/ipc/payment-handlers.ts | 247 ------------- apps/desktop/src/main/ipc/support-handlers.ts | 2 +- apps/desktop/src/preload/index.ts | 10 - .../src/renderer/components/AppLayout.tsx | 11 +- .../src/renderer/components/ads/AdBanner.tsx | 7 +- .../components/payment/CheckoutModal.tsx | 309 ---------------- .../components/payment/checkout-flow.ts | 203 ----------- .../components/support/SupportModal.tsx | 2 +- .../tests/e2e/launch_readiness.spec.ts | 17 +- .../tests/red/ads/ad-settlement.e2e.test.ts | 2 +- apps/desktop/tests/unit/checkout-flow.spec.ts | 252 ------------- .../tests/unit/payment-handlers.spec.ts | 332 ------------------ apps/web/e2e/billing-catalog.spec.ts | 10 +- apps/web/e2e/billing.spec.ts | 7 +- apps/web/e2e/payple-checkout.spec.ts | 2 +- apps/web/src/app/(app)/billing/page.tsx | 25 +- apps/web/src/app/(app)/dashboard/page.tsx | 2 +- .../billing/billing-checkout-options.tsx | 49 +-- .../components/billing/checkout-button.tsx | 91 ----- .../src/components/billing/portal-button.tsx | 82 ----- apps/web/src/lib/billing-catalog.ts | 11 +- apps/web/src/lib/web-app-url.ts | 13 +- docs/REFACTOR_POLICY.md | 2 +- docs/map/01-system-overview.md | 4 +- docs/map/02-infrastructure.md | 4 +- docs/map/03-shared-packages.md | 2 +- docs/map/04-desktop-app.md | 5 +- docs/map/05-web-app.md | 8 +- docs/map/09-supabase-backend.md | 5 +- docs/map/10-feature-catalog.md | 4 +- docs/map/11-gap-backlog.md | 5 +- docs/monetization-plan.md | 4 +- docs/v3/MOBILE_APP_COMPLETION_SSOT.md | 4 +- packages/api-client/src/types.ts | 5 +- packages/core/src/ipc-channels.ts | 8 - packages/core/src/plan-catalog.ts | 2 +- packages/core/src/types.ts | 31 +- packages/core/src/web-urls.ts | 10 +- scripts/capture-desktop-actual-app.js | 15 +- scripts/ci/sync-core-contract.mjs | 4 +- server/supabase/config.toml | 9 - .../functions/_shared/billing-catalog.test.ts | 62 +--- .../functions/_shared/billing-catalog.ts | 51 +-- .../_shared/core-contract.generated.ts | 12 +- .../functions/billing-catalog/index.ts | 35 +- .../functions/stripe-checkout/index.ts | 202 ----------- .../supabase/functions/stripe-portal/index.ts | 114 ------ .../functions/stripe-webhook/index.test.ts | 82 ----- .../functions/stripe-webhook/index.ts | 320 ----------------- 50 files changed, 97 insertions(+), 2600 deletions(-) delete mode 100644 apps/desktop/src/main/ipc/payment-handlers.ts delete mode 100644 apps/desktop/src/renderer/components/payment/CheckoutModal.tsx delete mode 100644 apps/desktop/src/renderer/components/payment/checkout-flow.ts delete mode 100644 apps/desktop/tests/unit/checkout-flow.spec.ts delete mode 100644 apps/desktop/tests/unit/payment-handlers.spec.ts delete mode 100644 apps/web/src/components/billing/checkout-button.tsx delete mode 100644 apps/web/src/components/billing/portal-button.tsx delete mode 100644 server/supabase/functions/stripe-checkout/index.ts delete mode 100644 server/supabase/functions/stripe-portal/index.ts delete mode 100644 server/supabase/functions/stripe-webhook/index.test.ts delete mode 100644 server/supabase/functions/stripe-webhook/index.ts diff --git a/apps/desktop/src/main/ipc/index.ts b/apps/desktop/src/main/ipc/index.ts index 809deb4..d1b4e3b 100644 --- a/apps/desktop/src/main/ipc/index.ts +++ b/apps/desktop/src/main/ipc/index.ts @@ -28,7 +28,6 @@ import { registerMeetingDocTemplateHandlers } from './meeting-doc-template-handl import { registerCloudSyncHandlers } from './cloud-sync-handlers' import { registerAdsHandlers } from './ads-handlers' import { registerSupportHandlers } from './support-handlers' -import { registerPaymentHandlers } from './payment-handlers' import { registerInputTelemetryHandlers } from './input-telemetry-handlers' import { registerSuggestionHandlers } from './suggestion-handlers' import { getLogger } from '../services/LoggerService' @@ -64,7 +63,6 @@ export function registerAllIpcHandlers(): void { registerCloudSyncHandlers() registerAdsHandlers() registerSupportHandlers() - registerPaymentHandlers() registerInputTelemetryHandlers() registerSuggestionHandlers() logger.info('All IPC handlers registered') diff --git a/apps/desktop/src/main/ipc/payment-handlers.ts b/apps/desktop/src/main/ipc/payment-handlers.ts deleted file mode 100644 index 3579644..0000000 --- a/apps/desktop/src/main/ipc/payment-handlers.ts +++ /dev/null @@ -1,247 +0,0 @@ -// apps/desktop/src/main/ipc/payment-handlers.ts -// Authenticated, server-authoritative checkout and subscription IPC handlers. - -import { randomUUID } from 'node:crypto' -import { ipcMain } from 'electron' -import { IPC_CHANNELS } from '@d3ro/core/ipc-channels' -import { ErrorCode, ipcError, ok, type IPCResult } from '@d3ro/core/errors' -import type { CheckoutSessionParams, CheckoutSessionResult } from '@d3ro/core/types' -import { billingUrl } from '@d3ro/core/web-urls' -import { getCloudSyncService } from '../services/CloudSyncService' - -const CHECKOUT_FUNCTION = 'stripe-checkout' -const SUBSCRIPTION_FUNCTION = 'payple-manage' -const CHECKOUT_SUCCESS_URL = billingUrl({ result: 'success' }) -const CHECKOUT_CANCEL_URL = billingUrl({ result: 'canceled' }) -const STRIPE_CHECKOUT_ORIGIN = 'https://checkout.stripe.com' -const STRIPE_CHECKOUT_PATH_PREFIX = '/c/pay/' -const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i -const SERVER_ENTITLED_STATUSES = new Set([ - 'active', - 'trialing', - 'past_due', - 'canceled', - 'cancelled' -]) - -export const PAYMENT_REQUEST_TIMEOUT_MS = 15_000 -const CHECKOUT_IDEMPOTENCY_TTL_MS = 5 * 60_000 - -type PaidTier = 'pro' | 'pro_plus' -type CloudSyncService = ReturnType - -interface CheckoutAttempt { - idempotencyKey: string - expiresAt: number - inFlight?: Promise> - result?: CheckoutSessionResult -} - -interface SubscriptionStatus { - tier: 'free' | PaidTier - valid: boolean - expiresAt: number | null -} - -class PaymentTimeoutError extends Error {} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} - -function parseCheckoutParams(value: unknown): { tier: PaidTier } | null { - if (!isRecord(value)) return null - if (value.provider !== 'stripe') return null - if (value.tier !== 'pro' && value.tier !== 'pro_plus') return null - return { tier: value.tier } -} - -function getAuthenticatedCloud(): { cloud: CloudSyncService; userId: string } | null { - const cloud = getCloudSyncService() - const user = cloud.getUser() - if (!cloud.isAuthenticated() || !user || !UUID_PATTERN.test(user.id)) return null - return { cloud, userId: user.id } -} - -async function invokeWithDeadline( - cloud: CloudSyncService, - name: string, - body: Record -): Promise<{ data: unknown; error: { message: string } | null }> { - const controller = new AbortController() - let timeout: ReturnType | undefined - const deadline = new Promise((_resolve, reject) => { - timeout = setTimeout(() => { - controller.abort() - reject(new PaymentTimeoutError('Payment server request timed out')) - }, PAYMENT_REQUEST_TIMEOUT_MS) - }) - - try { - return await Promise.race([ - cloud.invokeFunction(name, body, { - signal: controller.signal, - timeoutMs: PAYMENT_REQUEST_TIMEOUT_MS - }), - deadline - ]) - } finally { - if (timeout !== undefined) clearTimeout(timeout) - } -} - -function parseTrustedCheckoutUrl(value: unknown): string | null { - if (!isRecord(value) || typeof value.url !== 'string') return null - try { - const url = new URL(value.url) - if ( - url.protocol !== 'https:' || - url.origin !== STRIPE_CHECKOUT_ORIGIN || - url.username !== '' || - url.password !== '' || - !url.pathname.startsWith(STRIPE_CHECKOUT_PATH_PREFIX) || - url.pathname.length <= STRIPE_CHECKOUT_PATH_PREFIX.length - ) - return null - return url.toString() - } catch { - return null - } -} - -function parseServerSubscription(value: unknown, now = Date.now()): SubscriptionStatus | null { - if (!isRecord(value)) return null - if (value.tier !== 'free' && value.tier !== 'pro' && value.tier !== 'pro_plus') return null - if (typeof value.status !== 'string' || value.status.length === 0) return null - - let expiresAt: number | null = null - if (value.current_period_end !== null && value.current_period_end !== undefined) { - if (typeof value.current_period_end !== 'string') return null - const parsed = Date.parse(value.current_period_end) - if (!Number.isFinite(parsed)) return null - expiresAt = parsed - } - - const paidTier = value.tier === 'pro' || value.tier === 'pro_plus' - const valid = - paidTier && - SERVER_ENTITLED_STATUSES.has(value.status) && - (expiresAt === null || expiresAt > now) - - return { - tier: valid ? value.tier : 'free', - valid, - expiresAt - } -} - -async function readServerSubscription(cloud: CloudSyncService): Promise { - const response = await invokeWithDeadline(cloud, SUBSCRIPTION_FUNCTION, { action: 'info' }) - if (response.error) throw new Error('subscription_provider_error') - const status = parseServerSubscription(response.data) - if (!status) throw new Error('subscription_response_invalid') - return status -} - -function paymentFailure(error: unknown, fallback: string): IPCResult { - if (error instanceof PaymentTimeoutError) { - return ipcError(ErrorCode.UnknownError, 'Payment server request timed out') - } - return ipcError(ErrorCode.UnknownError, fallback) -} - -export function registerPaymentHandlers(): void { - const attempts = new Map() - - ipcMain.handle( - IPC_CHANNELS.PAYMENT.CREATE_CHECKOUT_SESSION, - async (_event, rawParams: CheckoutSessionParams) => { - const params = parseCheckoutParams(rawParams) - if (!params) { - return ipcError(ErrorCode.UnknownError, 'Invalid checkout request') - } - - const authenticated = getAuthenticatedCloud() - if (!authenticated) { - return ipcError(ErrorCode.UnknownError, 'Sign in is required for checkout') - } - - const now = Date.now() - for (const [key, attempt] of attempts) { - if (!attempt.inFlight && attempt.expiresAt <= now) attempts.delete(key) - } - - const fingerprint = `${authenticated.userId}:stripe:${params.tier}` - let attempt = attempts.get(fingerprint) - if (!attempt || attempt.expiresAt <= now) { - attempt = { - idempotencyKey: `desktop:stripe-checkout:${randomUUID()}`, - expiresAt: now + CHECKOUT_IDEMPOTENCY_TTL_MS - } - attempts.set(fingerprint, attempt) - } - - if (attempt.result) return ok(attempt.result) - if (attempt.inFlight) return attempt.inFlight - - const currentAttempt = attempt - const operation = (async (): Promise> => { - try { - const response = await invokeWithDeadline(authenticated.cloud, CHECKOUT_FUNCTION, { - tier: params.tier, - success_url: CHECKOUT_SUCCESS_URL, - cancel_url: CHECKOUT_CANCEL_URL, - idempotency_key: currentAttempt.idempotencyKey - }) - if (response.error) { - return ipcError(ErrorCode.UnknownError, 'Checkout service rejected the request') - } - - const checkoutUrl = parseTrustedCheckoutUrl(response.data) - if (!checkoutUrl) { - return ipcError(ErrorCode.UnknownError, 'Checkout service returned an invalid URL') - } - - const result: CheckoutSessionResult = { - checkoutUrl, - provider: 'stripe', - status: 'pending' - } - currentAttempt.result = result - return ok(result) - } catch (error) { - return paymentFailure(error, 'Checkout service is unavailable') - } finally { - currentAttempt.inFlight = undefined - } - })() - currentAttempt.inFlight = operation - return operation - } - ) - - ipcMain.handle(IPC_CHANNELS.PAYMENT.VERIFY_PAYMENT, async () => { - const authenticated = getAuthenticatedCloud() - if (!authenticated) { - return ipcError(ErrorCode.UnknownError, 'Sign in is required to verify a subscription') - } - try { - const status = await readServerSubscription(authenticated.cloud) - return ok({ success: status.valid, activeTier: status.tier }) - } catch (error) { - return paymentFailure(error, 'Subscription status is unavailable') - } - }) - - ipcMain.handle(IPC_CHANNELS.PAYMENT.GET_SUBSCRIPTION_STATUS, async () => { - const authenticated = getAuthenticatedCloud() - if (!authenticated) { - return ipcError(ErrorCode.UnknownError, 'Sign in is required to read a subscription') - } - try { - return ok(await readServerSubscription(authenticated.cloud)) - } catch (error) { - return paymentFailure(error, 'Subscription status is unavailable') - } - }) -} diff --git a/apps/desktop/src/main/ipc/support-handlers.ts b/apps/desktop/src/main/ipc/support-handlers.ts index 9e38f90..e763ab7 100644 --- a/apps/desktop/src/main/ipc/support-handlers.ts +++ b/apps/desktop/src/main/ipc/support-handlers.ts @@ -93,7 +93,7 @@ export function registerSupportHandlers(): void { const result: RefundEligibilityResult = { eligible: true, reason: '결제일로부터 7일 이내이며 클라우드 AI 정제 쿼터를 10% 미만 사용하셨습니다. (100% 전액 환불 가능)', - refundMethod: 'Toss Payments / Stripe 결제 즉시 취소', + refundMethod: 'Payple 카드 결제 즉시 취소', estimatedRefundKrw: 229000, } return ok(result) diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index f048229..cf2207b 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -875,16 +875,6 @@ const electronAPI = { checkRefund: () => invoke(IPC_CHANNELS.SUPPORT.CHECK_REFUND), }, - // ── Multi-PG Payment & Billing ───────────────────────── - payment: { - createCheckoutSession: (params: import('@d3ro/core/types').CheckoutSessionParams) => - invoke(IPC_CHANNELS.PAYMENT.CREATE_CHECKOUT_SESSION, params), - verifyPayment: () => - invoke(IPC_CHANNELS.PAYMENT.VERIFY_PAYMENT), - getSubscriptionStatus: () => - invoke(IPC_CHANNELS.PAYMENT.GET_SUBSCRIPTION_STATUS), - }, - // ── Input telemetry (입력 수집 동의 · 리포트) ─────────── inputTelemetry: { getState: () => diff --git a/apps/desktop/src/renderer/components/AppLayout.tsx b/apps/desktop/src/renderer/components/AppLayout.tsx index d74f2c8..16e34fa 100644 --- a/apps/desktop/src/renderer/components/AppLayout.tsx +++ b/apps/desktop/src/renderer/components/AppLayout.tsx @@ -29,7 +29,6 @@ import { OnboardingModal } from './OnboardingModal' import { AdBanner } from './ads/AdBanner' import { RewardedQuotaModal } from './ads/RewardedQuotaModal' import { SupportModal } from './support/SupportModal' -import { CheckoutModal } from './payment/CheckoutModal' import { StatusBar } from './StatusBar' import { TitleBar } from './TitleBar' import { d3roPalette, d3roFontSans, d3roFontMono, d3roTypo, d3roRadius, d3roShadow } from '@d3ro/ui/theme' @@ -79,7 +78,6 @@ export function AppLayout(): React.ReactElement { const [onboardingOpen, setOnboardingOpen] = useState(false) const [licenseModalOpen, setLicenseModalOpen] = useState(false) const [supportModalOpen, setSupportModalOpen] = useState(false) - const [checkoutModalOpen, setCheckoutModalOpen] = useState(false) const [rewardedModalOpen, setRewardedModalOpen] = useState(false) const [currentTier, setCurrentTier] = useState('free') const [fallbackMsg, setFallbackMsg] = useState(null) @@ -382,7 +380,7 @@ export function AppLayout(): React.ReactElement { setCheckoutModalOpen(true)} + onUpgrade={() => void window.electronAPI.license.openBilling({ tier: 'pro' })} /> )} @@ -394,13 +392,6 @@ export function AppLayout(): React.ReactElement { setLicenseModalOpen(false)} /> setOnboardingOpen(false)} /> setSupportModalOpen(false)} /> - setCheckoutModalOpen(false)} - onSuccess={(newTier) => { - setCurrentTier(newTier) - }} - /> setRewardedModalOpen(false)} diff --git a/apps/desktop/src/renderer/components/ads/AdBanner.tsx b/apps/desktop/src/renderer/components/ads/AdBanner.tsx index 9d1a6c3..b227cd7 100644 --- a/apps/desktop/src/renderer/components/ads/AdBanner.tsx +++ b/apps/desktop/src/renderer/components/ads/AdBanner.tsx @@ -9,10 +9,11 @@ import type { LicenseTier, AdCreativePayload } from '@d3ro/core/types' interface AdBannerProps { tier: LicenseTier - onOpenUpgradeModal: () => void + /** 웹 결제 페이지(billingUrl)로 보낸다. 등급 변경은 license:tierChanged 로 돌아온다. */ + onUpgrade: () => void } -export function AdBanner({ tier, onOpenUpgradeModal }: AdBannerProps): React.ReactElement | null { +export function AdBanner({ tier, onUpgrade }: AdBannerProps): React.ReactElement | null { const [creative, setCreative] = useState(null) // Fetch highest bidding ad creative via mediation auction @@ -245,7 +246,7 @@ export function AdBanner({ tier, onOpenUpgradeModal }: AdBannerProps): React.Rea void - onSuccess: (tier: CheckoutTier) => void -} - -function normalizeInitialTier(tier: LicenseTier): CheckoutTier { - return tier === 'pro' ? 'pro' : 'pro_plus' -} - -export function CheckoutModal({ - open, - initialTier = 'pro_plus', - onClose, - onSuccess -}: CheckoutModalProps): React.ReactElement { - const [tier, setTier] = useState(() => normalizeInitialTier(initialTier)) - const [flowState, setFlowState] = useState(INITIAL_CHECKOUT_FLOW_STATE) - const controllerRef = useRef(null) - - if (controllerRef.current === null) { - controllerRef.current = new CheckoutFlowController({ - createCheckoutSession: (params) => window.electronAPI.payment.createCheckoutSession(params), - openExternal: (params) => window.electronAPI.system.openExternal(params), - getSubscriptionStatus: () => window.electronAPI.payment.getSubscriptionStatus() - }) - } - const controller = controllerRef.current - - useEffect(() => controller.subscribe(setFlowState), [controller]) - - useEffect(() => { - if (open) { - setTier(normalizeInitialTier(initialTier)) - controller.reset() - } else { - controller.cancel() - } - }, [controller, initialTier, open]) - - const handleClose = (): void => { - controller.cancel() - onClose() - } - - const handleCheckout = (): void => { - void controller.start(tier) - } - - const handleVerification = async (): Promise => { - const verifiedTier = await controller.verify() - if (verifiedTier) onSuccess(verifiedTier) - } - - const busy = flowState.phase === 'creating' || flowState.phase === 'verifying' - const awaitingConfirmation = flowState.phase === 'awaiting' || flowState.phase === 'verifying' - - return ( - - - - - - - D3RO Voice Secure Checkout - - - - - - - - {flowState.phase === 'complete' && flowState.verifiedTier ? ( - - - - 서버에서 구독 활성화를 확인했습니다. - - - Plan: {flowState.verifiedTier.toUpperCase()} - - - - ) : ( - - - 결제 세션과 최종 금액은 로그인된 계정의 Stripe 서버에서 생성됩니다. - - - - {(['pro', 'pro_plus'] as const).map((candidate) => ( - - ))} - - - - - - - Stripe 보안 결제 - - - 실제 금액, 통화, 세금과 결제 수단은 Stripe 결제 페이지에서 확인해 주세요. - - - - - {awaitingConfirmation && ( - }> - 브라우저에서 결제를 완료하거나 취소한 뒤, 아래에서 서버 구독 상태를 확인해 주세요. - - )} - - {flowState.error && ( - - {checkoutErrorMessage(flowState.error)} - - )} - - - {flowState.phase === 'idle' || flowState.phase === 'creating' ? ( - - ) : ( - - - - - )} - - - )} - - - ) -} diff --git a/apps/desktop/src/renderer/components/payment/checkout-flow.ts b/apps/desktop/src/renderer/components/payment/checkout-flow.ts deleted file mode 100644 index 4c6837f..0000000 --- a/apps/desktop/src/renderer/components/payment/checkout-flow.ts +++ /dev/null @@ -1,203 +0,0 @@ -import type { IPCResult } from '@d3ro/core/errors' -import type { - CheckoutSessionParams, - CheckoutSessionResult, - CheckoutTier, - SubscriptionStatusResult -} from '@d3ro/core/types' - -const STRIPE_CHECKOUT_ORIGIN = 'https://checkout.stripe.com' -const STRIPE_CHECKOUT_PATH_PREFIX = '/c/pay/' - -export type CheckoutPhase = 'idle' | 'creating' | 'awaiting' | 'verifying' | 'complete' - -export type CheckoutErrorCode = - | 'checkout-failed' - | 'unsafe-checkout-response' - | 'open-failed' - | 'network-failed' - | 'verification-failed' - | 'not-confirmed' - | 'invalid-entitlement' - -export interface CheckoutFlowState { - phase: CheckoutPhase - error: CheckoutErrorCode | null - verifiedTier: CheckoutTier | null -} - -export interface CheckoutFlowPorts { - createCheckoutSession: ( - params: CheckoutSessionParams - ) => Promise> - openExternal: (params: { url: string }) => Promise> - getSubscriptionStatus: () => Promise> -} - -export const INITIAL_CHECKOUT_FLOW_STATE: CheckoutFlowState = { - phase: 'idle', - error: null, - verifiedTier: null -} - -const ERROR_MESSAGES: Record = { - 'checkout-failed': '결제 세션을 만들지 못했습니다. 잠시 후 다시 시도해 주세요.', - 'unsafe-checkout-response': '안전한 Stripe 결제 페이지를 확인하지 못했습니다.', - 'open-failed': '결제 페이지를 열지 못했습니다. 잠시 후 다시 시도해 주세요.', - 'network-failed': '결제 서비스에 연결하지 못했습니다. 네트워크 상태를 확인해 주세요.', - 'verification-failed': '서버 구독 상태를 확인하지 못했습니다. 잠시 후 다시 시도해 주세요.', - 'not-confirmed': '서버에서 결제 완료를 아직 확인하지 못했습니다. 결제 후 다시 확인해 주세요.', - 'invalid-entitlement': '서버가 유효한 구독 등급을 반환하지 않았습니다.' -} - -export function checkoutErrorMessage(error: CheckoutErrorCode): string { - return ERROR_MESSAGES[error] -} - -export function isTrustedStripeCheckoutUrl(value: unknown): value is string { - if (typeof value !== 'string') return false - try { - const url = new URL(value) - return ( - url.protocol === 'https:' && - url.origin === STRIPE_CHECKOUT_ORIGIN && - url.username === '' && - url.password === '' && - url.pathname.startsWith(STRIPE_CHECKOUT_PATH_PREFIX) && - url.pathname.length > STRIPE_CHECKOUT_PATH_PREFIX.length - ) - } catch { - return false - } -} - -function isPaidTier(value: unknown): value is CheckoutTier { - return value === 'pro' || value === 'pro_plus' -} - -export class CheckoutFlowController { - private state: CheckoutFlowState = INITIAL_CHECKOUT_FLOW_STATE - private listener: ((state: CheckoutFlowState) => void) | null = null - private inFlight = false - private generation = 0 - - constructor(private readonly ports: CheckoutFlowPorts) {} - - subscribe(listener: (state: CheckoutFlowState) => void): () => void { - this.listener = listener - listener(this.state) - return () => { - if (this.listener === listener) this.listener = null - } - } - - getState(): CheckoutFlowState { - return this.state - } - - reset(): void { - this.generation += 1 - this.inFlight = false - this.update(INITIAL_CHECKOUT_FLOW_STATE) - } - - cancel(): void { - this.reset() - } - - async start(tier: CheckoutTier): Promise { - if (this.inFlight || this.state.phase === 'awaiting' || this.state.phase === 'complete') { - return false - } - - this.inFlight = true - const generation = ++this.generation - let stage: 'checkout' | 'open' = 'checkout' - this.update({ phase: 'creating', error: null, verifiedTier: null }) - - try { - const session = await this.ports.createCheckoutSession({ tier, provider: 'stripe' }) - if (!this.isCurrent(generation)) return false - if (!session.success) { - this.fail('idle', 'checkout-failed') - return false - } - if ( - session.data.provider !== 'stripe' || - session.data.status !== 'pending' || - !isTrustedStripeCheckoutUrl(session.data.checkoutUrl) - ) { - this.fail('idle', 'unsafe-checkout-response') - return false - } - - stage = 'open' - const opened = await this.ports.openExternal({ url: session.data.checkoutUrl }) - if (!this.isCurrent(generation)) return false - if (!opened.success) { - this.fail('idle', 'open-failed') - return false - } - - this.update({ phase: 'awaiting', error: null, verifiedTier: null }) - return true - } catch { - if (this.isCurrent(generation)) { - this.fail('idle', stage === 'open' ? 'open-failed' : 'network-failed') - } - return false - } finally { - if (this.isCurrent(generation)) this.inFlight = false - } - } - - async verify(): Promise { - if (this.inFlight || this.state.phase !== 'awaiting') return null - - this.inFlight = true - const generation = ++this.generation - this.update({ phase: 'verifying', error: null, verifiedTier: null }) - - try { - const subscription = await this.ports.getSubscriptionStatus() - if (!this.isCurrent(generation)) return null - if (!subscription.success) { - this.fail('awaiting', 'verification-failed') - return null - } - if (!subscription.data.valid) { - this.fail('awaiting', 'not-confirmed') - return null - } - if (!isPaidTier(subscription.data.tier)) { - this.fail('awaiting', 'invalid-entitlement') - return null - } - - this.update({ - phase: 'complete', - error: null, - verifiedTier: subscription.data.tier - }) - return subscription.data.tier - } catch { - if (this.isCurrent(generation)) this.fail('awaiting', 'network-failed') - return null - } finally { - if (this.isCurrent(generation)) this.inFlight = false - } - } - - private isCurrent(generation: number): boolean { - return generation === this.generation - } - - private fail(phase: 'idle' | 'awaiting', error: CheckoutErrorCode): void { - this.update({ phase, error, verifiedTier: null }) - } - - private update(state: CheckoutFlowState): void { - this.state = state - this.listener?.(state) - } -} diff --git a/apps/desktop/src/renderer/components/support/SupportModal.tsx b/apps/desktop/src/renderer/components/support/SupportModal.tsx index 976b9a1..bcbabb2 100644 --- a/apps/desktop/src/renderer/components/support/SupportModal.tsx +++ b/apps/desktop/src/renderer/components/support/SupportModal.tsx @@ -397,7 +397,7 @@ export function SupportModal({ open, onClose }: SupportModalProps): React.ReactE • 클라우드 사용량: 0% (자격 충족)