refactor(billing): remove Stripe; payments are Payple (web) and Google Play (mobile)
Some checks failed
ci / 정본·보안·린트·타입·테스트 (push) Failing after 1m13s
ci / 워크스페이스 빌드 검증 (push) Has been skipped
ci / 모바일 린트·타입·Jest (push) Failing after 1m4s
ci / Supabase Edge Functions + Cloudflare Worker (push) Successful in 37s
ci / .NET API 서버 테스트 (push) Successful in 27s
deploy-site / deploy (push) Failing after 20s
Some checks failed
ci / 정본·보안·린트·타입·테스트 (push) Failing after 1m13s
ci / 워크스페이스 빌드 검증 (push) Has been skipped
ci / 모바일 린트·타입·Jest (push) Failing after 1m4s
ci / Supabase Edge Functions + Cloudflare Worker (push) Successful in 37s
ci / .NET API 서버 테스트 (push) Successful in 27s
deploy-site / deploy (push) Failing after 20s
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.
This commit is contained in:
parent
7224e43bfb
commit
eedd127ea7
50 changed files with 97 additions and 2600 deletions
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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<typeof getCloudSyncService>
|
||||
|
||||
interface CheckoutAttempt {
|
||||
idempotencyKey: string
|
||||
expiresAt: number
|
||||
inFlight?: Promise<IPCResult<CheckoutSessionResult>>
|
||||
result?: CheckoutSessionResult
|
||||
}
|
||||
|
||||
interface SubscriptionStatus {
|
||||
tier: 'free' | PaidTier
|
||||
valid: boolean
|
||||
expiresAt: number | null
|
||||
}
|
||||
|
||||
class PaymentTimeoutError extends Error {}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
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<string, unknown>
|
||||
): Promise<{ data: unknown; error: { message: string } | null }> {
|
||||
const controller = new AbortController()
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined
|
||||
const deadline = new Promise<never>((_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<SubscriptionStatus> {
|
||||
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<T>(error: unknown, fallback: string): IPCResult<T> {
|
||||
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<string, CheckoutAttempt>()
|
||||
|
||||
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<IPCResult<CheckoutSessionResult>> => {
|
||||
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')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -875,16 +875,6 @@ const electronAPI = {
|
|||
checkRefund: () =>
|
||||
invoke<import('@d3ro/core/types').RefundEligibilityResult>(IPC_CHANNELS.SUPPORT.CHECK_REFUND),
|
||||
},
|
||||
// ── Multi-PG Payment & Billing ─────────────────────────
|
||||
payment: {
|
||||
createCheckoutSession: (params: import('@d3ro/core/types').CheckoutSessionParams) =>
|
||||
invoke<import('@d3ro/core/types').CheckoutSessionResult>(IPC_CHANNELS.PAYMENT.CREATE_CHECKOUT_SESSION, params),
|
||||
verifyPayment: () =>
|
||||
invoke<import('@d3ro/core/types').VerifyPaymentResult>(IPC_CHANNELS.PAYMENT.VERIFY_PAYMENT),
|
||||
getSubscriptionStatus: () =>
|
||||
invoke<import('@d3ro/core/types').SubscriptionStatusResult>(IPC_CHANNELS.PAYMENT.GET_SUBSCRIPTION_STATUS),
|
||||
},
|
||||
|
||||
// ── Input telemetry (입력 수집 동의 · 리포트) ───────────
|
||||
inputTelemetry: {
|
||||
getState: () =>
|
||||
|
|
|
|||
|
|
@ -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<LicenseTier>('free')
|
||||
const [fallbackMsg, setFallbackMsg] = useState<string | null>(null)
|
||||
|
|
@ -382,7 +380,7 @@ export function AppLayout(): React.ReactElement {
|
|||
<Box sx={{ px: 2, pb: 1, pt: 0.5, flexShrink: 0 }}>
|
||||
<AdBanner
|
||||
tier={currentTier}
|
||||
onOpenUpgradeModal={() => setCheckoutModalOpen(true)}
|
||||
onUpgrade={() => void window.electronAPI.license.openBilling({ tier: 'pro' })}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
|
@ -394,13 +392,6 @@ export function AppLayout(): React.ReactElement {
|
|||
<LicenseModal open={licenseModalOpen} onClose={() => setLicenseModalOpen(false)} />
|
||||
<OnboardingModal open={onboardingOpen} onClose={() => setOnboardingOpen(false)} />
|
||||
<SupportModal open={supportModalOpen} onClose={() => setSupportModalOpen(false)} />
|
||||
<CheckoutModal
|
||||
open={checkoutModalOpen}
|
||||
onClose={() => setCheckoutModalOpen(false)}
|
||||
onSuccess={(newTier) => {
|
||||
setCurrentTier(newTier)
|
||||
}}
|
||||
/>
|
||||
<RewardedQuotaModal
|
||||
open={rewardedModalOpen}
|
||||
onClose={() => setRewardedModalOpen(false)}
|
||||
|
|
|
|||
|
|
@ -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<AdCreativePayload | null>(null)
|
||||
|
||||
// Fetch highest bidding ad creative via mediation auction
|
||||
|
|
@ -245,7 +246,7 @@ export function AdBanner({ tier, onOpenUpgradeModal }: AdBannerProps): React.Rea
|
|||
</Button>
|
||||
|
||||
<Typography
|
||||
onClick={onOpenUpgradeModal}
|
||||
onClick={onUpgrade}
|
||||
sx={{
|
||||
fontFamily: d3roFontSans,
|
||||
fontSize: '10px',
|
||||
|
|
|
|||
|
|
@ -1,309 +0,0 @@
|
|||
// src/renderer/components/payment/CheckoutModal.tsx
|
||||
// Server-authoritative Stripe checkout and subscription confirmation modal.
|
||||
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { Alert, Box, Button, CircularProgress, IconButton, Modal, Typography } from '@mui/material'
|
||||
import { CheckCircle2, CreditCard, ExternalLink, Lock, RefreshCw, Shield, X } from 'lucide-react'
|
||||
import { d3roPalette, d3roFontSans, d3roFontMono, d3roRadius, d3roShadow } from '@d3ro/ui/theme'
|
||||
import type { CheckoutTier, LicenseTier } from '@d3ro/core/types'
|
||||
import {
|
||||
CheckoutFlowController,
|
||||
INITIAL_CHECKOUT_FLOW_STATE,
|
||||
checkoutErrorMessage
|
||||
} from './checkout-flow'
|
||||
|
||||
interface CheckoutModalProps {
|
||||
open: boolean
|
||||
initialTier?: LicenseTier
|
||||
onClose: () => 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<CheckoutTier>(() => normalizeInitialTier(initialTier))
|
||||
const [flowState, setFlowState] = useState(INITIAL_CHECKOUT_FLOW_STATE)
|
||||
const controllerRef = useRef<CheckoutFlowController | null>(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<void> => {
|
||||
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 (
|
||||
<Modal open={open} onClose={handleClose} aria-labelledby="checkout-modal-title">
|
||||
<Box
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: { xs: '90%', sm: 520 },
|
||||
bgcolor: d3roPalette.bg.card,
|
||||
backdropFilter: 'blur(28px)',
|
||||
border: `1px solid ${d3roPalette.glass.hairline}`,
|
||||
borderRadius: d3roRadius.outer,
|
||||
boxShadow: d3roShadow.dialog,
|
||||
p: 3,
|
||||
outline: 'none',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 2
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
borderBottom: `1px solid ${d3roPalette.glass.hairline}`,
|
||||
pb: 1.5
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.25 }}>
|
||||
<CreditCard size={18} color={d3roPalette.accent.light} />
|
||||
<Typography
|
||||
id="checkout-modal-title"
|
||||
sx={{
|
||||
fontFamily: d3roFontSans,
|
||||
fontSize: '16px',
|
||||
fontWeight: 600,
|
||||
color: d3roPalette.text.primary
|
||||
}}
|
||||
>
|
||||
D3RO Voice Secure Checkout
|
||||
</Typography>
|
||||
</Box>
|
||||
<IconButton
|
||||
onClick={handleClose}
|
||||
aria-label="결제 창 닫기"
|
||||
sx={{
|
||||
color: d3roPalette.text.dimLabel,
|
||||
'&:hover': { color: d3roPalette.text.primary }
|
||||
}}
|
||||
>
|
||||
<X size={18} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
{flowState.phase === 'complete' && flowState.verifiedTier ? (
|
||||
<Box
|
||||
sx={{
|
||||
textAlign: 'center',
|
||||
py: 4,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 1.5
|
||||
}}
|
||||
>
|
||||
<CheckCircle2 size={48} color={d3roPalette.tag.green} />
|
||||
<Typography
|
||||
sx={{ fontFamily: d3roFontSans, fontSize: '18px', fontWeight: 600, color: 'var(--d3-text-inverse)' }}
|
||||
>
|
||||
서버에서 구독 활성화를 확인했습니다.
|
||||
</Typography>
|
||||
<Typography
|
||||
sx={{ fontFamily: d3roFontMono, fontSize: '12px', color: d3roPalette.accent.light }}
|
||||
>
|
||||
Plan: {flowState.verifiedTier.toUpperCase()}
|
||||
</Typography>
|
||||
<Button
|
||||
onClick={handleClose}
|
||||
sx={{
|
||||
mt: 2,
|
||||
bgcolor: d3roPalette.accent.main,
|
||||
color: 'var(--d3-text-inverse)',
|
||||
px: 3,
|
||||
borderRadius: '8px'
|
||||
}}
|
||||
>
|
||||
완료
|
||||
</Button>
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<Typography
|
||||
sx={{ fontFamily: d3roFontSans, fontSize: '12px', color: d3roPalette.text.secondary }}
|
||||
>
|
||||
결제 세션과 최종 금액은 로그인된 계정의 Stripe 서버에서 생성됩니다.
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1 }}>
|
||||
{(['pro', 'pro_plus'] as const).map((candidate) => (
|
||||
<Button
|
||||
key={candidate}
|
||||
onClick={() => setTier(candidate)}
|
||||
disabled={flowState.phase !== 'idle' || busy}
|
||||
aria-pressed={tier === candidate}
|
||||
sx={{
|
||||
p: 1.5,
|
||||
borderRadius: d3roRadius.inner,
|
||||
border: `1px solid ${tier === candidate ? d3roPalette.accent.main : d3roPalette.glass.hairline}`,
|
||||
bgcolor:
|
||||
tier === candidate ? 'var(--d3-accent-glow)' : 'var(--d3-overlay-strong)',
|
||||
color: 'var(--d3-text-inverse)',
|
||||
fontFamily: d3roFontSans,
|
||||
fontWeight: 500,
|
||||
textTransform: 'none'
|
||||
}}
|
||||
>
|
||||
{candidate === 'pro' ? 'Pro' : 'Pro+'}
|
||||
</Button>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: d3roRadius.inner,
|
||||
bgcolor: 'var(--d3-bg-card-soft)',
|
||||
border: `1px solid ${d3roPalette.accent.dim}`,
|
||||
display: 'flex',
|
||||
gap: 1.25,
|
||||
alignItems: 'flex-start'
|
||||
}}
|
||||
>
|
||||
<Shield size={19} color={d3roPalette.accent.light} />
|
||||
<Box>
|
||||
<Typography
|
||||
sx={{
|
||||
fontFamily: d3roFontSans,
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
color: 'var(--d3-text-inverse)'
|
||||
}}
|
||||
>
|
||||
Stripe 보안 결제
|
||||
</Typography>
|
||||
<Typography
|
||||
sx={{
|
||||
mt: 0.5,
|
||||
fontFamily: d3roFontSans,
|
||||
fontSize: '11px',
|
||||
color: d3roPalette.text.secondary
|
||||
}}
|
||||
>
|
||||
실제 금액, 통화, 세금과 결제 수단은 Stripe 결제 페이지에서 확인해 주세요.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{awaitingConfirmation && (
|
||||
<Alert severity="info" icon={<ExternalLink size={18} />}>
|
||||
브라우저에서 결제를 완료하거나 취소한 뒤, 아래에서 서버 구독 상태를 확인해 주세요.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{flowState.error && (
|
||||
<Alert severity="error" role="alert">
|
||||
{checkoutErrorMessage(flowState.error)}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box aria-live="polite">
|
||||
{flowState.phase === 'idle' || flowState.phase === 'creating' ? (
|
||||
<Button
|
||||
fullWidth
|
||||
disabled={busy}
|
||||
onClick={handleCheckout}
|
||||
sx={{
|
||||
py: 1.25,
|
||||
bgcolor: d3roPalette.accent.main,
|
||||
color: 'var(--d3-text-inverse)',
|
||||
fontWeight: 500,
|
||||
fontSize: '13px',
|
||||
borderRadius: '10px',
|
||||
boxShadow: d3roShadow.glowAccent,
|
||||
'&:hover': { bgcolor: d3roPalette.accent.light }
|
||||
}}
|
||||
>
|
||||
{flowState.phase === 'creating' ? (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<CircularProgress size={14} color="inherit" /> 결제 세션 생성 중...
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Lock size={14} /> Stripe에서 결제 계속하기
|
||||
</Box>
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Button
|
||||
fullWidth
|
||||
disabled={busy}
|
||||
onClick={() => void handleVerification()}
|
||||
sx={{
|
||||
py: 1.25,
|
||||
bgcolor: d3roPalette.accent.main,
|
||||
color: 'var(--d3-text-inverse)',
|
||||
fontWeight: 500,
|
||||
fontSize: '13px',
|
||||
borderRadius: '10px'
|
||||
}}
|
||||
>
|
||||
{flowState.phase === 'verifying' ? (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<CircularProgress size={14} color="inherit" /> 서버 구독 확인 중...
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<RefreshCw size={14} /> 결제 상태 확인
|
||||
</Box>
|
||||
)}
|
||||
</Button>
|
||||
<Button onClick={handleClose} sx={{ color: d3roPalette.text.secondary }}>
|
||||
닫기 — 구독 등급 변경 안 함
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
|
@ -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<IPCResult<CheckoutSessionResult>>
|
||||
openExternal: (params: { url: string }) => Promise<IPCResult<void>>
|
||||
getSubscriptionStatus: () => Promise<IPCResult<SubscriptionStatusResult>>
|
||||
}
|
||||
|
||||
export const INITIAL_CHECKOUT_FLOW_STATE: CheckoutFlowState = {
|
||||
phase: 'idle',
|
||||
error: null,
|
||||
verifiedTier: null
|
||||
}
|
||||
|
||||
const ERROR_MESSAGES: Record<CheckoutErrorCode, string> = {
|
||||
'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<boolean> {
|
||||
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<CheckoutTier | null> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -397,7 +397,7 @@ export function SupportModal({ open, onClose }: SupportModalProps): React.ReactE
|
|||
• 클라우드 사용량: 0% (자격 충족)
|
||||
</Typography>
|
||||
<Button
|
||||
onClick={() => alert('토스페이먼츠 / Stripe 결제 취소가 접수되었습니다. 영업일 기준 2~3일 내 카드사 취소 처리됩니다.')}
|
||||
onClick={() => alert('Payple 결제 취소가 접수되었습니다. 영업일 기준 2~3일 내 카드사 취소 처리됩니다.')}
|
||||
sx={{ mt: 1, bgcolor: d3roPalette.tag.green, color: 'var(--d3-scrim)', fontWeight: 500, fontSize: '12px', borderRadius: '8px' }}
|
||||
>
|
||||
즉시 전액 환불 승인
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue