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' }}
|
||||
>
|
||||
즉시 전액 환불 승인
|
||||
|
|
|
|||
|
|
@ -120,22 +120,11 @@ test.describe('Launch Readiness E2E & Visual Verification', () => {
|
|||
await window.waitForTimeout(500);
|
||||
});
|
||||
|
||||
test('03. Should open the server-authoritative Stripe CheckoutModal', async () => {
|
||||
// Trigger checkout via "Remove ads with Pro" link in AdBanner
|
||||
test('03. Should expose the web billing upgrade link without an in-app checkout modal', async () => {
|
||||
// "Remove ads with Pro" opens billingUrl({ tier }) in the external browser (LICENSE.OPEN_BILLING).
|
||||
const removeAdsLink = window.locator('text=/Remove ads with Pro/').first();
|
||||
if (await removeAdsLink.isVisible()) {
|
||||
await removeAdsLink.click();
|
||||
await window.waitForTimeout(600);
|
||||
|
||||
// Verify Checkout Modal is visible
|
||||
await expect(window.locator('text=/D3RO Voice Secure Checkout/').first()).toBeVisible();
|
||||
|
||||
// Capture screenshot of CheckoutModal
|
||||
await window.screenshot({ path: path.join(SCREENSHOT_DIR, '04_checkout_modal_stripe.png') });
|
||||
|
||||
// Close modal
|
||||
await window.keyboard.press('Escape');
|
||||
await window.waitForTimeout(400);
|
||||
await expect(window.locator('text=/D3RO Voice Secure Checkout/')).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@ describe('E2E RED — 계열 F: 정산·수익 원장', () => {
|
|||
const stats = engine.getRevenueStats()
|
||||
for (const s of stats.settlements) {
|
||||
expect(['pending', 'processing', 'settled', 'paid']).toContain(s.payoutStatus)
|
||||
expect(['bank_wire_krw', 'paypal', 'stripe_connect']).toContain(s.paymentMethod)
|
||||
expect(['bank_wire_krw', 'paypal']).toContain(s.paymentMethod)
|
||||
}
|
||||
})
|
||||
it(`[${network}] 순지급 = 총수익 × (1-원천세) 환율 적립`, () => {
|
||||
|
|
|
|||
|
|
@ -1,252 +0,0 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { IPCResult } from '@d3ro/core/errors'
|
||||
import type { CheckoutSessionResult, SubscriptionStatusResult } from '@d3ro/core/types'
|
||||
import {
|
||||
CheckoutFlowController,
|
||||
type CheckoutFlowPorts,
|
||||
isTrustedStripeCheckoutUrl
|
||||
} from '../../src/renderer/components/payment/checkout-flow'
|
||||
|
||||
type CreateResult = IPCResult<CheckoutSessionResult>
|
||||
type StatusResult = IPCResult<SubscriptionStatusResult>
|
||||
|
||||
const trustedUrl = 'https://checkout.stripe.com/c/pay/cs_test_renderer_safe#fragment'
|
||||
const checkoutSuccess: CreateResult = {
|
||||
success: true,
|
||||
data: { checkoutUrl: trustedUrl, provider: 'stripe', status: 'pending' }
|
||||
}
|
||||
const openSuccess: IPCResult<void> = { success: true, data: undefined }
|
||||
const activeSubscription: StatusResult = {
|
||||
success: true,
|
||||
data: { tier: 'pro_plus', valid: true, expiresAt: Date.parse('2099-01-01T00:00:00.000Z') }
|
||||
}
|
||||
const ipcFailure = {
|
||||
success: false as const,
|
||||
error: { code: 999, message: 'private provider details' }
|
||||
}
|
||||
|
||||
function deferred<T>(): {
|
||||
promise: Promise<T>
|
||||
resolve: (value: T) => void
|
||||
} {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((done) => {
|
||||
resolve = done
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
describe('renderer checkout flow', () => {
|
||||
let createCheckoutSession: ReturnType<typeof vi.fn>
|
||||
let openExternal: ReturnType<typeof vi.fn>
|
||||
let getSubscriptionStatus: ReturnType<typeof vi.fn>
|
||||
let controller: CheckoutFlowController
|
||||
|
||||
beforeEach(() => {
|
||||
createCheckoutSession = vi.fn().mockResolvedValue(checkoutSuccess)
|
||||
openExternal = vi.fn().mockResolvedValue(openSuccess)
|
||||
getSubscriptionStatus = vi.fn().mockResolvedValue(activeSubscription)
|
||||
const ports: CheckoutFlowPorts = {
|
||||
createCheckoutSession,
|
||||
openExternal,
|
||||
getSubscriptionStatus
|
||||
}
|
||||
controller = new CheckoutFlowController(ports)
|
||||
})
|
||||
|
||||
it('opens only the authenticated IPC checkout result and waits for server confirmation', async () => {
|
||||
const opened = await controller.start('pro')
|
||||
|
||||
expect(opened).toBe(true)
|
||||
expect(createCheckoutSession).toHaveBeenCalledWith({ tier: 'pro', provider: 'stripe' })
|
||||
expect(openExternal).toHaveBeenCalledWith({ url: trustedUrl })
|
||||
expect(getSubscriptionStatus).not.toHaveBeenCalled()
|
||||
expect(controller.getState()).toEqual({
|
||||
phase: 'awaiting',
|
||||
error: null,
|
||||
verifiedTier: null
|
||||
})
|
||||
})
|
||||
|
||||
it('fails closed when checkout IPC returns a provider error', async () => {
|
||||
createCheckoutSession.mockResolvedValue(ipcFailure)
|
||||
|
||||
await controller.start('pro')
|
||||
|
||||
expect(controller.getState()).toMatchObject({ phase: 'idle', error: 'checkout-failed' })
|
||||
expect(openExternal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails closed when checkout IPC throws a network error', async () => {
|
||||
createCheckoutSession.mockRejectedValue(new Error('private network details'))
|
||||
|
||||
await controller.start('pro')
|
||||
|
||||
expect(controller.getState()).toMatchObject({ phase: 'idle', error: 'network-failed' })
|
||||
expect(openExternal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
checkoutUrl: 'http://checkout.stripe.com/c/pay/cs_test_bad',
|
||||
provider: 'stripe',
|
||||
status: 'pending'
|
||||
},
|
||||
{ checkoutUrl: 'https://evil.test/c/pay/cs_test_bad', provider: 'stripe', status: 'pending' },
|
||||
{ checkoutUrl: trustedUrl, provider: 'toss', status: 'pending' },
|
||||
{ checkoutUrl: trustedUrl, provider: 'stripe', status: 'completed' }
|
||||
])('rejects an unsafe or forged checkout response %#', async (data) => {
|
||||
createCheckoutSession.mockResolvedValue({ success: true, data })
|
||||
|
||||
await controller.start('pro')
|
||||
|
||||
expect(controller.getState()).toMatchObject({
|
||||
phase: 'idle',
|
||||
error: 'unsafe-checkout-response'
|
||||
})
|
||||
expect(openExternal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails closed when the OS cannot open the checkout page', async () => {
|
||||
openExternal.mockResolvedValue(ipcFailure)
|
||||
|
||||
await controller.start('pro')
|
||||
|
||||
expect(controller.getState()).toMatchObject({ phase: 'idle', error: 'open-failed' })
|
||||
})
|
||||
|
||||
it('fails closed when opening the checkout page throws', async () => {
|
||||
openExternal.mockRejectedValue(new Error('private shell details'))
|
||||
|
||||
await controller.start('pro')
|
||||
|
||||
expect(controller.getState()).toMatchObject({ phase: 'idle', error: 'open-failed' })
|
||||
})
|
||||
|
||||
it('coalesces duplicate checkout clicks into one IPC request', async () => {
|
||||
const pending = deferred<CreateResult>()
|
||||
createCheckoutSession.mockReturnValue(pending.promise)
|
||||
|
||||
const first = controller.start('pro')
|
||||
const duplicate = controller.start('pro_plus')
|
||||
|
||||
expect(await duplicate).toBe(false)
|
||||
expect(createCheckoutSession).toHaveBeenCalledTimes(1)
|
||||
pending.resolve(checkoutSuccess)
|
||||
expect(await first).toBe(true)
|
||||
expect(openExternal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('invalidates a pending checkout when the user closes the modal', async () => {
|
||||
const pending = deferred<CreateResult>()
|
||||
createCheckoutSession.mockReturnValue(pending.promise)
|
||||
|
||||
const start = controller.start('pro')
|
||||
controller.cancel()
|
||||
pending.resolve(checkoutSuccess)
|
||||
|
||||
expect(await start).toBe(false)
|
||||
expect(openExternal).not.toHaveBeenCalled()
|
||||
expect(controller.getState()).toEqual({ phase: 'idle', error: null, verifiedTier: null })
|
||||
})
|
||||
|
||||
it('does not treat an opened or cancelled provider page as payment success', async () => {
|
||||
await controller.start('pro')
|
||||
getSubscriptionStatus.mockResolvedValue({
|
||||
success: true,
|
||||
data: { tier: 'free', valid: false, expiresAt: null }
|
||||
})
|
||||
|
||||
const tier = await controller.verify()
|
||||
|
||||
expect(tier).toBeNull()
|
||||
expect(controller.getState()).toMatchObject({ phase: 'awaiting', error: 'not-confirmed' })
|
||||
})
|
||||
|
||||
it('fails closed when subscription readback returns an IPC error', async () => {
|
||||
await controller.start('pro')
|
||||
getSubscriptionStatus.mockResolvedValue(ipcFailure)
|
||||
|
||||
const tier = await controller.verify()
|
||||
|
||||
expect(tier).toBeNull()
|
||||
expect(controller.getState()).toMatchObject({
|
||||
phase: 'awaiting',
|
||||
error: 'verification-failed'
|
||||
})
|
||||
})
|
||||
|
||||
it('fails closed when subscription readback throws a network error', async () => {
|
||||
await controller.start('pro')
|
||||
getSubscriptionStatus.mockRejectedValue(new Error('private subscription details'))
|
||||
|
||||
const tier = await controller.verify()
|
||||
|
||||
expect(tier).toBeNull()
|
||||
expect(controller.getState()).toMatchObject({ phase: 'awaiting', error: 'network-failed' })
|
||||
})
|
||||
|
||||
it('rejects an arbitrary paid tier even when a forged response claims it is valid', async () => {
|
||||
await controller.start('pro')
|
||||
getSubscriptionStatus.mockResolvedValue({
|
||||
success: true,
|
||||
data: { tier: 'enterprise', valid: true, expiresAt: null }
|
||||
})
|
||||
|
||||
const tier = await controller.verify()
|
||||
|
||||
expect(tier).toBeNull()
|
||||
expect(controller.getState()).toMatchObject({
|
||||
phase: 'awaiting',
|
||||
error: 'invalid-entitlement'
|
||||
})
|
||||
})
|
||||
|
||||
it('returns and displays only the server-read subscription tier', async () => {
|
||||
await controller.start('pro')
|
||||
|
||||
const tier = await controller.verify()
|
||||
|
||||
expect(tier).toBe('pro_plus')
|
||||
expect(controller.getState()).toEqual({
|
||||
phase: 'complete',
|
||||
error: null,
|
||||
verifiedTier: 'pro_plus'
|
||||
})
|
||||
})
|
||||
|
||||
it('coalesces duplicate subscription verification clicks', async () => {
|
||||
await controller.start('pro')
|
||||
const pending = deferred<StatusResult>()
|
||||
getSubscriptionStatus.mockReturnValue(pending.promise)
|
||||
|
||||
const first = controller.verify()
|
||||
const duplicate = controller.verify()
|
||||
|
||||
expect(await duplicate).toBeNull()
|
||||
expect(getSubscriptionStatus).toHaveBeenCalledTimes(1)
|
||||
pending.resolve(activeSubscription)
|
||||
expect(await first).toBe('pro_plus')
|
||||
})
|
||||
|
||||
it('ignores a successful subscription response after cancellation', async () => {
|
||||
await controller.start('pro')
|
||||
const pending = deferred<StatusResult>()
|
||||
getSubscriptionStatus.mockReturnValue(pending.promise)
|
||||
|
||||
const verification = controller.verify()
|
||||
controller.cancel()
|
||||
pending.resolve(activeSubscription)
|
||||
|
||||
expect(await verification).toBeNull()
|
||||
expect(controller.getState()).toEqual({ phase: 'idle', error: null, verifiedTier: null })
|
||||
})
|
||||
|
||||
it('accepts only the trusted Stripe HTTPS checkout origin and path', () => {
|
||||
expect(isTrustedStripeCheckoutUrl(trustedUrl)).toBe(true)
|
||||
expect(isTrustedStripeCheckoutUrl('https://checkout.stripe.com/portal/cs_test_bad')).toBe(false)
|
||||
expect(
|
||||
isTrustedStripeCheckoutUrl('https://user:pass@checkout.stripe.com/c/pay/cs_test_bad')
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,332 +0,0 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ipcMain } from 'electron'
|
||||
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
|
||||
import type { IPCResult } from '@d3ro/core/errors'
|
||||
import type { CheckoutSessionParams, CheckoutSessionResult } from '@d3ro/core/types'
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const invokeFunction = vi.fn()
|
||||
const activate = vi.fn()
|
||||
const getLicenseService = vi.fn(() => ({ activate }))
|
||||
const cloud = {
|
||||
isAuthenticated: vi.fn(() => true),
|
||||
getUser: vi.fn(() => ({ id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' })),
|
||||
invokeFunction
|
||||
}
|
||||
return { activate, cloud, getLicenseService, invokeFunction }
|
||||
})
|
||||
|
||||
vi.mock('../../src/main/services/CloudSyncService', () => ({
|
||||
getCloudSyncService: () => mocks.cloud
|
||||
}))
|
||||
|
||||
vi.mock('../../src/main/services/LicenseService', () => ({
|
||||
getLicenseService: mocks.getLicenseService
|
||||
}))
|
||||
|
||||
import {
|
||||
PAYMENT_REQUEST_TIMEOUT_MS,
|
||||
registerPaymentHandlers
|
||||
} from '../../src/main/ipc/payment-handlers'
|
||||
|
||||
type CapturedHandler = (...args: unknown[]) => unknown
|
||||
|
||||
const handlers = new Map<string, CapturedHandler>()
|
||||
const validParams: CheckoutSessionParams = {
|
||||
tier: 'pro',
|
||||
provider: 'stripe'
|
||||
}
|
||||
|
||||
async function invokeIpc<T>(channel: string, ...args: unknown[]): Promise<IPCResult<T>> {
|
||||
const handler = handlers.get(channel)
|
||||
if (!handler) throw new Error(`Missing IPC handler: ${channel}`)
|
||||
return (await handler({}, ...args)) as IPCResult<T>
|
||||
}
|
||||
|
||||
function checkoutResponse(url = 'https://checkout.stripe.com/c/pay/cs_test_safe#fragment') {
|
||||
return { data: { url }, error: null }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.clearAllMocks()
|
||||
handlers.clear()
|
||||
mocks.cloud.isAuthenticated.mockReturnValue(true)
|
||||
mocks.cloud.getUser.mockReturnValue({ id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' })
|
||||
vi.mocked(ipcMain.handle).mockImplementation((channel: string, handler: CapturedHandler) => {
|
||||
handlers.set(channel, handler)
|
||||
})
|
||||
registerPaymentHandlers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('desktop payment IPC security boundary', () => {
|
||||
it('uses the authenticated Stripe Edge checkout contract and returns only its trusted URL', async () => {
|
||||
mocks.invokeFunction.mockResolvedValue(checkoutResponse())
|
||||
|
||||
const result = await invokeIpc<CheckoutSessionResult>(
|
||||
IPC_CHANNELS.PAYMENT.CREATE_CHECKOUT_SESSION,
|
||||
validParams
|
||||
)
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
data: {
|
||||
checkoutUrl: 'https://checkout.stripe.com/c/pay/cs_test_safe#fragment',
|
||||
provider: 'stripe',
|
||||
status: 'pending'
|
||||
}
|
||||
})
|
||||
expect(mocks.invokeFunction).toHaveBeenCalledTimes(1)
|
||||
const [name, body, options] = mocks.invokeFunction.mock.calls[0]
|
||||
expect(name).toBe('stripe-checkout')
|
||||
expect(body).toEqual({
|
||||
tier: 'pro',
|
||||
success_url: 'https://d3ro.chanpaca.net/app/billing?success=1',
|
||||
cancel_url: 'https://d3ro.chanpaca.net/app/billing?canceled=1',
|
||||
idempotency_key: expect.stringMatching(/^desktop:stripe-checkout:[0-9a-f-]{36}$/)
|
||||
})
|
||||
expect(options).toMatchObject({ timeoutMs: PAYMENT_REQUEST_TIMEOUT_MS })
|
||||
expect(options.signal).toBeInstanceOf(AbortSignal)
|
||||
})
|
||||
|
||||
it.each(['toss', 'portone', 'arbitrary-provider'])(
|
||||
'rejects unsupported provider %s before network I/O',
|
||||
async (provider) => {
|
||||
const result = await invokeIpc(IPC_CHANNELS.PAYMENT.CREATE_CHECKOUT_SESSION, {
|
||||
...validParams,
|
||||
provider
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(mocks.invokeFunction).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['free', 'team', 'enterprise', 'arbitrary-tier'])(
|
||||
'rejects renderer-selected tier %s before network I/O',
|
||||
async (tier) => {
|
||||
const result = await invokeIpc(IPC_CHANNELS.PAYMENT.CREATE_CHECKOUT_SESSION, {
|
||||
...validParams,
|
||||
tier
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(mocks.invokeFunction).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
'http://checkout.stripe.com/c/pay/cs_test_unsafe',
|
||||
'https://checkout.stripe.com.evil.test/c/pay/cs_test_unsafe',
|
||||
'https://checkout.stripe.com@evil.test/c/pay/cs_test_unsafe',
|
||||
'https://user:pass@checkout.stripe.com/c/pay/cs_test_unsafe',
|
||||
'https://checkout.stripe.com/portal/cs_test_unsafe'
|
||||
])('rejects an untrusted checkout URL: %s', async (url) => {
|
||||
mocks.invokeFunction.mockResolvedValue(checkoutResponse(url))
|
||||
|
||||
const result = await invokeIpc(IPC_CHANNELS.PAYMENT.CREATE_CHECKOUT_SESSION, validParams)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('fails closed on provider errors without exposing provider details', async () => {
|
||||
mocks.invokeFunction.mockResolvedValue({
|
||||
data: null,
|
||||
error: { message: 'sensitive Stripe provider details' }
|
||||
})
|
||||
|
||||
const result = await invokeIpc(IPC_CHANNELS.PAYMENT.CREATE_CHECKOUT_SESSION, validParams)
|
||||
|
||||
expect(result).toMatchObject({
|
||||
success: false,
|
||||
error: { message: 'Checkout service rejected the request' }
|
||||
})
|
||||
expect(JSON.stringify(result)).not.toContain('sensitive Stripe provider details')
|
||||
})
|
||||
|
||||
it('fails closed on network errors without converting them into checkout success', async () => {
|
||||
mocks.invokeFunction.mockRejectedValue(new Error('sensitive network details'))
|
||||
|
||||
const result = await invokeIpc(IPC_CHANNELS.PAYMENT.CREATE_CHECKOUT_SESSION, validParams)
|
||||
|
||||
expect(result).toMatchObject({
|
||||
success: false,
|
||||
error: { message: 'Checkout service is unavailable' }
|
||||
})
|
||||
expect(JSON.stringify(result)).not.toContain('sensitive network details')
|
||||
})
|
||||
|
||||
it('coalesces concurrent and sequential duplicate checkout requests', async () => {
|
||||
let resolveProvider: ((value: ReturnType<typeof checkoutResponse>) => void) | undefined
|
||||
mocks.invokeFunction.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveProvider = resolve
|
||||
})
|
||||
)
|
||||
|
||||
const first = invokeIpc<CheckoutSessionResult>(
|
||||
IPC_CHANNELS.PAYMENT.CREATE_CHECKOUT_SESSION,
|
||||
validParams
|
||||
)
|
||||
const concurrent = invokeIpc<CheckoutSessionResult>(
|
||||
IPC_CHANNELS.PAYMENT.CREATE_CHECKOUT_SESSION,
|
||||
validParams
|
||||
)
|
||||
|
||||
expect(mocks.invokeFunction).toHaveBeenCalledTimes(1)
|
||||
resolveProvider?.(checkoutResponse())
|
||||
const [firstResult, concurrentResult] = await Promise.all([first, concurrent])
|
||||
const sequentialResult = await invokeIpc<CheckoutSessionResult>(
|
||||
IPC_CHANNELS.PAYMENT.CREATE_CHECKOUT_SESSION,
|
||||
validParams
|
||||
)
|
||||
|
||||
expect(firstResult).toEqual(concurrentResult)
|
||||
expect(sequentialResult).toEqual(firstResult)
|
||||
expect(mocks.invokeFunction).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('reuses the server idempotency key when a failed checkout is retried', async () => {
|
||||
mocks.invokeFunction.mockResolvedValue({
|
||||
data: null,
|
||||
error: { message: 'provider unavailable' }
|
||||
})
|
||||
|
||||
const first = await invokeIpc(IPC_CHANNELS.PAYMENT.CREATE_CHECKOUT_SESSION, validParams)
|
||||
const retry = await invokeIpc(IPC_CHANNELS.PAYMENT.CREATE_CHECKOUT_SESSION, validParams)
|
||||
|
||||
expect(first.success).toBe(false)
|
||||
expect(retry.success).toBe(false)
|
||||
expect(mocks.invokeFunction).toHaveBeenCalledTimes(2)
|
||||
expect(mocks.invokeFunction.mock.calls[0][1].idempotency_key).toBe(
|
||||
mocks.invokeFunction.mock.calls[1][1].idempotency_key
|
||||
)
|
||||
})
|
||||
|
||||
it('aborts and fails closed when checkout exceeds the deadline', async () => {
|
||||
vi.useFakeTimers()
|
||||
mocks.invokeFunction.mockReturnValue(new Promise(() => undefined))
|
||||
|
||||
const pending = invokeIpc(IPC_CHANNELS.PAYMENT.CREATE_CHECKOUT_SESSION, validParams)
|
||||
await vi.advanceTimersByTimeAsync(PAYMENT_REQUEST_TIMEOUT_MS)
|
||||
const result = await pending
|
||||
|
||||
expect(result).toMatchObject({
|
||||
success: false,
|
||||
error: { message: 'Payment server request timed out' }
|
||||
})
|
||||
const options = mocks.invokeFunction.mock.calls[0][2]
|
||||
expect(options.signal.aborted).toBe(true)
|
||||
})
|
||||
|
||||
it('uses server subscription readback and never mutates the local license', async () => {
|
||||
mocks.invokeFunction.mockResolvedValue({
|
||||
data: {
|
||||
tier: 'pro_plus',
|
||||
status: 'active',
|
||||
current_period_end: '2099-01-01T00:00:00.000Z'
|
||||
},
|
||||
error: null
|
||||
})
|
||||
|
||||
const result = await invokeIpc<{ success: boolean; activeTier: string }>(
|
||||
IPC_CHANNELS.PAYMENT.VERIFY_PAYMENT,
|
||||
{ tier: 'enterprise' }
|
||||
)
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
data: { success: true, activeTier: 'pro_plus' }
|
||||
})
|
||||
expect(mocks.invokeFunction).toHaveBeenCalledWith(
|
||||
'payple-manage',
|
||||
{ action: 'info' },
|
||||
expect.objectContaining({ timeoutMs: PAYMENT_REQUEST_TIMEOUT_MS })
|
||||
)
|
||||
expect(mocks.getLicenseService).not.toHaveBeenCalled()
|
||||
expect(mocks.activate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not grant an arbitrary renderer tier when the server says free', async () => {
|
||||
mocks.invokeFunction.mockResolvedValue({
|
||||
data: { tier: 'free', status: 'active', current_period_end: null },
|
||||
error: null
|
||||
})
|
||||
|
||||
const result = await invokeIpc<{ success: boolean; activeTier: string }>(
|
||||
IPC_CHANNELS.PAYMENT.VERIFY_PAYMENT,
|
||||
{ tier: 'enterprise' }
|
||||
)
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
data: { success: false, activeTier: 'free' }
|
||||
})
|
||||
expect(mocks.activate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves prepaid cancellation until expiry and fails closed after expiry', async () => {
|
||||
mocks.invokeFunction
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
tier: 'pro',
|
||||
status: 'canceled',
|
||||
current_period_end: '2099-01-01T00:00:00.000Z'
|
||||
},
|
||||
error: null
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
tier: 'pro',
|
||||
status: 'canceled',
|
||||
current_period_end: '2000-01-01T00:00:00.000Z'
|
||||
},
|
||||
error: null
|
||||
})
|
||||
|
||||
const current = await invokeIpc<{
|
||||
tier: string
|
||||
valid: boolean
|
||||
expiresAt: number | null
|
||||
}>(IPC_CHANNELS.PAYMENT.GET_SUBSCRIPTION_STATUS)
|
||||
const expired = await invokeIpc<{
|
||||
tier: string
|
||||
valid: boolean
|
||||
expiresAt: number | null
|
||||
}>(IPC_CHANNELS.PAYMENT.GET_SUBSCRIPTION_STATUS)
|
||||
|
||||
expect(current).toMatchObject({ success: true, data: { tier: 'pro', valid: true } })
|
||||
expect(expired).toMatchObject({ success: true, data: { tier: 'free', valid: false } })
|
||||
})
|
||||
|
||||
it('rejects malformed or arbitrary server entitlement data', async () => {
|
||||
mocks.invokeFunction.mockResolvedValue({
|
||||
data: {
|
||||
tier: 'enterprise',
|
||||
status: 'active',
|
||||
current_period_end: '2099-01-01T00:00:00.000Z'
|
||||
},
|
||||
error: null
|
||||
})
|
||||
|
||||
const result = await invokeIpc(IPC_CHANNELS.PAYMENT.GET_SUBSCRIPTION_STATUS)
|
||||
|
||||
expect(result).toMatchObject({
|
||||
success: false,
|
||||
error: { message: 'Subscription status is unavailable' }
|
||||
})
|
||||
})
|
||||
|
||||
it('requires an authenticated user before invoking payment functions', async () => {
|
||||
mocks.cloud.isAuthenticated.mockReturnValue(false)
|
||||
|
||||
const result = await invokeIpc(IPC_CHANNELS.PAYMENT.CREATE_CHECKOUT_SESSION, validParams)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(mocks.invokeFunction).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
|
@ -12,7 +12,6 @@ const catalogPayload = {
|
|||
tier: 'pro',
|
||||
prices: [
|
||||
{ provider: 'payple', unit_amount: 9900, currency: 'KRW', interval: 'month', interval_count: 1 },
|
||||
{ provider: 'stripe', unit_amount: 999, currency: 'USD', interval: 'month', interval_count: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
@ -29,9 +28,8 @@ test.describe('billing catalog strict client contract', () => {
|
|||
const catalog = parseBillingCatalog(catalogPayload)
|
||||
expect(catalog).not.toBeNull()
|
||||
expect(formatBillingPrice(catalog!.plans.pro[0])).toContain('9,900')
|
||||
expect(formatBillingPrice(catalog!.plans.pro[1])).toContain('9.99')
|
||||
expect(formatPlanCatalogPrice('pro', catalog)).toContain('Payple')
|
||||
expect(formatPlanCatalogPrice('pro', catalog)).toContain('Stripe')
|
||||
expect(formatPlanCatalogPrice('pro', catalog)).toContain('9,900')
|
||||
expect(formatPlanCatalogPrice('pro_plus', catalog)).toContain('29,900')
|
||||
expect(formatPlanCatalogPrice('free', catalog)).toBe('무료')
|
||||
})
|
||||
|
||||
|
|
@ -56,6 +54,10 @@ test.describe('billing catalog strict client contract', () => {
|
|||
...catalogPayload,
|
||||
plans: [{ tier: 'pro', prices: [{ ...catalogPayload.plans[0].prices[0], currency: 'KRW<script>' }] }, catalogPayload.plans[1]],
|
||||
},
|
||||
{
|
||||
...catalogPayload,
|
||||
plans: [{ tier: 'pro', prices: [{ ...catalogPayload.plans[0].prices[0], provider: 'google_play' }] }, catalogPayload.plans[1]],
|
||||
},
|
||||
]
|
||||
for (const value of invalid) expect(parseBillingCatalog(value)).toBeNull()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -138,10 +138,9 @@ test.describe('Billing provider and Payple DOM flow', () => {
|
|||
await expect(proPlan.getByTestId('payple-upgrade-pro')).toBeEnabled()
|
||||
await expect(page.getByTestId('billing-plan-pro_plus')).toBeVisible()
|
||||
|
||||
await proPlan.getByLabel('Stripe 해외 카드').click()
|
||||
await expect(proPlan.getByTestId('stripe-upgrade-pro')).toBeVisible()
|
||||
await proPlan.getByLabel('Payple 국내 카드').click()
|
||||
await expect(proPlan.getByRole('button', { name: /Stripe/ })).toHaveCount(0)
|
||||
|
||||
// 결제 경로가 없어도 과거 provider='stripe' 구독 행은 계속 읽혀야 한다.
|
||||
const stripePeriodEnd = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString()
|
||||
const { error: stripeFixtureError } = await admin.from('subscriptions').update({
|
||||
tier: 'pro',
|
||||
|
|
@ -156,7 +155,7 @@ test.describe('Billing provider and Payple DOM flow', () => {
|
|||
if (stripeFixtureError) throw stripeFixtureError
|
||||
await page.reload()
|
||||
await expect(page.getByTestId('billing-current-provider')).toContainText('Stripe')
|
||||
await expect(page.getByTestId('stripe-portal-open')).toBeVisible()
|
||||
await expect(page.getByTestId('stripe-portal-open')).toHaveCount(0)
|
||||
await expect(page.getByTestId('billing-checkout-pro')).toHaveCount(0)
|
||||
|
||||
const { error: freeFixtureError } = await admin.from('subscriptions').update({
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ test.describe('Payple payer number and fail-closed checkout', () => {
|
|||
|
||||
test('rejects missing or tampered catalog price before opening Payple', async () => {
|
||||
for (const catalogPrice of [
|
||||
{ provider: 'stripe', unitAmount: 9900, currency: 'KRW', interval: 'month', intervalCount: 1 },
|
||||
{ provider: 'google_play', unitAmount: 9900, currency: 'KRW', interval: 'month', intervalCount: 1 },
|
||||
{ provider: 'payple', unitAmount: 1, currency: 'USD', interval: 'month', intervalCount: 1 },
|
||||
{ provider: 'payple', unitAmount: 9900, currency: 'KRW', interval: 'year', intervalCount: 1 },
|
||||
{ provider: 'payple', unitAmount: 0, currency: 'KRW', interval: 'month', intervalCount: 1 }
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import { MetalCard, TactileBadge } from '@d3ro/ui/components/ds'
|
|||
import { d3roFontMono } from '@d3ro/ui/theme'
|
||||
import { BillingCheckoutOptions } from '@/components/billing/billing-checkout-options'
|
||||
import { PaypleManageButton } from '@/components/billing/payple-manage-button'
|
||||
import { PortalButton } from '@/components/billing/portal-button'
|
||||
import { getSupabaseServerClient } from '@/lib/supabase-server'
|
||||
import {
|
||||
formatPlanCatalogPrice,
|
||||
|
|
@ -45,6 +44,7 @@ interface Plan {
|
|||
}
|
||||
|
||||
const VALID_TIERS = new Set<SubscriptionTier>(['free', 'pro', 'pro_plus'])
|
||||
// 'stripe' 는 결제 경로가 제거된 뒤에도 과거 구독 행(provider='stripe')을 읽기 위해 남긴다.
|
||||
const VALID_PROVIDERS = new Set<BillingProvider>([
|
||||
'none',
|
||||
'stripe',
|
||||
|
|
@ -96,7 +96,7 @@ function providerLabel(provider: BillingProvider): string {
|
|||
const labels: Record<BillingProvider, string> = {
|
||||
none: '없음',
|
||||
payple: 'Payple',
|
||||
stripe: 'Stripe',
|
||||
stripe: 'Stripe (종료)',
|
||||
google_play: 'Google Play',
|
||||
app_store: 'App Store',
|
||||
admin: '관리자 부여'
|
||||
|
|
@ -164,8 +164,6 @@ export default async function BillingPage({ searchParams }: BillingPageProps): P
|
|||
const canPurchase = !isPaid && subscription.provider === 'none'
|
||||
const cancellationDate = formatDate(subscription.cancel_at)
|
||||
const periodEnd = formatDate(subscription.current_period_end)
|
||||
const stripeCanceled = params['canceled'] === '1'
|
||||
const stripeReturned = params['success'] === '1'
|
||||
const selectedTier = selectedTierFrom(params['tier'])
|
||||
|
||||
return (
|
||||
|
|
@ -182,19 +180,6 @@ export default async function BillingPage({ searchParams }: BillingPageProps): P
|
|||
</Typography>
|
||||
</Box>
|
||||
|
||||
{stripeCanceled && (
|
||||
<Alert severity="info" sx={{ mb: 2 }} data-testid="stripe-canceled-message">
|
||||
Stripe Checkout을 취소했습니다. 결제나 구독 변경은 발생하지 않았습니다.
|
||||
</Alert>
|
||||
)}
|
||||
{stripeReturned && (
|
||||
<Alert severity={isPaid ? 'success' : 'warning'} sx={{ mb: 2 }} data-testid="stripe-return-message">
|
||||
{isPaid
|
||||
? 'Stripe 결제가 확인되어 구독 정보가 갱신되었습니다.'
|
||||
: 'Stripe 결제 결과를 확인 중입니다. 잠시 후 이 페이지를 새로고침해 주세요.'}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<MetalCard sx={{ p: { xs: 2.5, md: 3.5 }, mb: 4 }}>
|
||||
<Stack direction={{ xs: 'column', md: 'row' }} justifyContent="space-between" gap={3}>
|
||||
<Box>
|
||||
|
|
@ -257,7 +242,7 @@ export default async function BillingPage({ searchParams }: BillingPageProps): P
|
|||
</Box>
|
||||
|
||||
<Typography sx={{ mt: 4, textAlign: 'center', color: 'var(--d3-text-label)', fontSize: 11 }}>
|
||||
국내 카드는 Payple, 해외 카드는 Stripe가 처리합니다. 활성 구독이 있으면 같은 provider에서만 관리할 수 있습니다.
|
||||
웹 결제는 Payple(원화 카드)이 처리합니다. 모바일 앱 구독은 Google Play에서 관리합니다.
|
||||
</Typography>
|
||||
</Box>
|
||||
)
|
||||
|
|
@ -350,7 +335,9 @@ function SubscriptionManagement({ subscription }: { subscription: BillingSubscri
|
|||
return <Typography sx={{ color: 'var(--d3-status-warning)', fontSize: 12 }}>자동 갱신이 해지되었습니다.</Typography>
|
||||
}
|
||||
if (subscription.provider === 'payple') return <PaypleManageButton />
|
||||
if (subscription.provider === 'stripe') return <PortalButton />
|
||||
if (subscription.provider === 'stripe') {
|
||||
return <Alert severity="info">종료된 Stripe 결제로 만든 구독입니다. 변경·해지는 고객센터에 문의해 주세요.</Alert>
|
||||
}
|
||||
if (subscription.provider === 'google_play') {
|
||||
return <Alert severity="info">Google Play 앱에서 구독을 관리해 주세요.</Alert>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ function tierLabel(tier: DashboardSnapshot['subscription']['tier']): string {
|
|||
function providerLabel(provider: DashboardSnapshot['subscription']['provider']): string {
|
||||
const labels: Record<DashboardSnapshot['subscription']['provider'], string> = {
|
||||
none: '미연결',
|
||||
stripe: 'Stripe',
|
||||
stripe: 'Stripe (종료)',
|
||||
payple: '페이플',
|
||||
google_play: 'Google Play',
|
||||
app_store: 'App Store',
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
'use client'
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Alert, Box, ToggleButton, ToggleButtonGroup, Typography } from '@mui/material'
|
||||
import { CheckoutButton } from './checkout-button'
|
||||
import { Alert, Box } from '@mui/material'
|
||||
import { PaypleCheckoutButton } from './payple-checkout-button'
|
||||
import type { PaypleTier } from './payple-client'
|
||||
import type { BillingCatalogPrice, WebBillingProvider } from '@/lib/billing-catalog'
|
||||
import type { BillingCatalogPrice } from '@/lib/billing-catalog'
|
||||
|
||||
/** 웹 결제 진입점. 결제사는 Payple(KRW) 하나이며 가격은 billing-catalog 응답을 따른다. */
|
||||
export function BillingCheckoutOptions({
|
||||
tier,
|
||||
prices
|
||||
|
|
@ -14,48 +13,12 @@ export function BillingCheckoutOptions({
|
|||
tier: PaypleTier
|
||||
prices: BillingCatalogPrice[]
|
||||
}): React.ReactElement {
|
||||
const availableProviders = useMemo(() => new Set(prices.map((price) => price.provider)), [prices])
|
||||
const initialProvider: WebBillingProvider = availableProviders.has('payple') ? 'payple' : 'stripe'
|
||||
const [provider, setProvider] = useState<WebBillingProvider>(initialProvider)
|
||||
const payplePrice = prices.find((price): price is BillingCatalogPrice & { provider: 'payple' } => (
|
||||
price.provider === 'payple'
|
||||
))
|
||||
const payplePrice = prices.find((price) => price.provider === 'payple')
|
||||
|
||||
return (
|
||||
<Box data-testid={`billing-checkout-${tier}`}>
|
||||
<Typography sx={{ mb: 1, color: 'var(--d3-text-label)', fontSize: 11 }}>
|
||||
결제 수단을 선택해 주세요. 한 구독에는 하나의 결제사만 사용할 수 있습니다.
|
||||
</Typography>
|
||||
<ToggleButtonGroup
|
||||
exclusive
|
||||
fullWidth
|
||||
size="small"
|
||||
value={provider}
|
||||
onChange={(_event, value: WebBillingProvider | null) => {
|
||||
if (value !== null && availableProviders.has(value)) setProvider(value)
|
||||
}}
|
||||
aria-label="결제사 선택"
|
||||
sx={{ mb: 1.5 }}
|
||||
>
|
||||
<ToggleButton value="payple" aria-label="Payple 국내 카드" disabled={!availableProviders.has('payple')}>
|
||||
국내 카드 · Payple
|
||||
</ToggleButton>
|
||||
<ToggleButton value="stripe" aria-label="Stripe 해외 카드" disabled={!availableProviders.has('stripe')}>
|
||||
해외 카드 · Stripe
|
||||
</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
|
||||
{provider === 'payple' ? (
|
||||
payplePrice ? <PaypleCheckoutButton tier={tier} catalogPrice={payplePrice} /> : (
|
||||
<Alert severity="error">Payple 가격을 확인할 수 없습니다.</Alert>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<Alert severity="info" variant="outlined" sx={{ mb: 1, fontSize: 11 }}>
|
||||
Stripe Checkout으로 이동해 해외 발급 카드를 결제합니다.
|
||||
</Alert>
|
||||
<CheckoutButton tier={tier} />
|
||||
</>
|
||||
{payplePrice ? <PaypleCheckoutButton tier={tier} catalogPrice={payplePrice} /> : (
|
||||
<Alert severity="error">Payple 가격을 확인할 수 없습니다.</Alert>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,91 +0,0 @@
|
|||
'use client'
|
||||
|
||||
// apps/web/src/components/billing/checkout-button.tsx
|
||||
// Stripe Checkout 시작 — Edge Function stripe-checkout 호출
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Button, CircularProgress, Alert, Box } from '@mui/material'
|
||||
import { getSupabaseBrowserClient } from '@/lib/supabase-browser'
|
||||
import { browserBillingUrl } from '@/lib/web-app-url'
|
||||
import type { PaypleTier } from './payple-client'
|
||||
|
||||
interface CheckoutButtonProps {
|
||||
tier: PaypleTier
|
||||
}
|
||||
|
||||
export function CheckoutButton({ tier }: CheckoutButtonProps): React.ReactElement {
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function handleCheckout(): Promise<void> {
|
||||
setError(null)
|
||||
setBusy(true)
|
||||
try {
|
||||
const supabase = getSupabaseBrowserClient()
|
||||
const {
|
||||
data: { session },
|
||||
error: sessionError
|
||||
} = await supabase.auth.getSession()
|
||||
|
||||
if (sessionError || !session) {
|
||||
setError('로그인이 필요합니다')
|
||||
return
|
||||
}
|
||||
|
||||
const baseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL?.trim()
|
||||
if (!baseUrl) throw new Error('결제 서버 설정이 완료되지 않았습니다')
|
||||
const endpoint = new URL('/functions/v1/stripe-checkout', baseUrl).toString()
|
||||
|
||||
const response = await fetch(
|
||||
endpoint,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.access_token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
tier,
|
||||
idempotency_key: `stripe-checkout:${crypto.randomUUID()}`,
|
||||
success_url: browserBillingUrl('success'),
|
||||
cancel_url: browserBillingUrl('canceled')
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
const data = (await response.json().catch(() => null)) as { url?: unknown } | null
|
||||
if (!response.ok) throw new Error('Stripe Checkout을 시작하지 못했습니다')
|
||||
if (typeof data?.url !== 'string') throw new Error('Checkout URL을 받지 못했습니다')
|
||||
const checkoutUrl = new URL(data.url)
|
||||
if (checkoutUrl.protocol !== 'https:' || checkoutUrl.hostname !== 'checkout.stripe.com') {
|
||||
throw new Error('Checkout URL을 신뢰할 수 없습니다')
|
||||
}
|
||||
window.location.assign(checkoutUrl.toString())
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Unknown error')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
size="large"
|
||||
data-testid={`stripe-upgrade-${tier}`}
|
||||
onClick={() => void handleCheckout()}
|
||||
disabled={busy}
|
||||
startIcon={busy ? <CircularProgress size={16} color="inherit" /> : null}
|
||||
>
|
||||
{tier === 'pro_plus' ? 'PRO+' : 'PRO'} Stripe로 업그레이드
|
||||
</Button>
|
||||
{error && (
|
||||
<Alert severity="error" variant="outlined" sx={{ mt: 1, fontSize: 11 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
'use client'
|
||||
|
||||
// apps/web/src/components/billing/portal-button.tsx
|
||||
// Stripe Customer Portal — 활성 구독 사용자가 결제 수단/취소 등을 관리
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Button, CircularProgress, Alert, Box } from '@mui/material'
|
||||
import SettingsIcon from '@mui/icons-material/Settings'
|
||||
import { getSupabaseBrowserClient } from '@/lib/supabase-browser'
|
||||
import { browserBillingUrl } from '@/lib/web-app-url'
|
||||
|
||||
export function PortalButton(): React.ReactElement {
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function handleOpenPortal(): Promise<void> {
|
||||
setError(null)
|
||||
setBusy(true)
|
||||
try {
|
||||
const supabase = getSupabaseBrowserClient()
|
||||
const {
|
||||
data: { session },
|
||||
error: sessionError
|
||||
} = await supabase.auth.getSession()
|
||||
|
||||
if (sessionError || !session) {
|
||||
setError('로그인이 필요합니다')
|
||||
return
|
||||
}
|
||||
|
||||
const baseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL?.trim()
|
||||
if (!baseUrl) throw new Error('결제 서버 설정이 완료되지 않았습니다')
|
||||
|
||||
const response = await fetch(
|
||||
new URL('/functions/v1/stripe-portal', baseUrl).toString(),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.access_token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
return_url: browserBillingUrl()
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
const data = await response.json().catch(() => null) as { url?: unknown } | null
|
||||
if (!response.ok) throw new Error('Stripe 구독 관리 페이지를 열지 못했습니다')
|
||||
if (typeof data?.url !== 'string') throw new Error('구독 관리 URL을 받지 못했습니다')
|
||||
const portalUrl = new URL(data.url)
|
||||
if (portalUrl.protocol !== 'https:' || portalUrl.hostname !== 'billing.stripe.com') {
|
||||
throw new Error('구독 관리 URL을 신뢰할 수 없습니다')
|
||||
}
|
||||
window.location.assign(portalUrl.toString())
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Unknown error')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
data-testid="stripe-portal-open"
|
||||
startIcon={busy ? <CircularProgress size={14} /> : <SettingsIcon />}
|
||||
onClick={() => void handleOpenPortal()}
|
||||
disabled={busy}
|
||||
>
|
||||
구독 관리
|
||||
</Button>
|
||||
{error && (
|
||||
<Alert severity="error" variant="outlined" sx={{ mt: 1, fontSize: 11 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import type { SubscriptionTier } from '@d3ro/api-client'
|
||||
|
||||
export type WebBillingProvider = 'payple' | 'stripe'
|
||||
/** 웹 결제사는 Payple(KRW) 하나다. Stripe는 2026-09-26 제거됐다. */
|
||||
export type WebBillingProvider = 'payple'
|
||||
export type BillingInterval = 'day' | 'week' | 'month' | 'year'
|
||||
|
||||
export interface BillingCatalogPrice {
|
||||
|
|
@ -15,7 +16,7 @@ export interface BillingCatalog {
|
|||
plans: Record<'pro' | 'pro_plus', BillingCatalogPrice[]>
|
||||
}
|
||||
|
||||
const PROVIDERS = new Set<WebBillingProvider>(['payple', 'stripe'])
|
||||
const PROVIDERS = new Set<WebBillingProvider>(['payple'])
|
||||
const INTERVALS = new Set<BillingInterval>(['day', 'week', 'month', 'year'])
|
||||
const PAID_TIERS = new Set(['pro', 'pro_plus'])
|
||||
|
||||
|
|
@ -30,7 +31,7 @@ export function parseBillingCatalog(value: unknown): BillingCatalog | null {
|
|||
if (!rawPlan || typeof rawPlan !== 'object' || Array.isArray(rawPlan)) return null
|
||||
const plan = rawPlan as { tier?: unknown; prices?: unknown }
|
||||
if (typeof plan.tier !== 'string' || !PAID_TIERS.has(plan.tier) || seenTiers.has(plan.tier)) return null
|
||||
if (!Array.isArray(plan.prices) || plan.prices.length > 2) return null
|
||||
if (!Array.isArray(plan.prices) || plan.prices.length > PROVIDERS.size) return null
|
||||
seenTiers.add(plan.tier)
|
||||
const seenProviders = new Set<string>()
|
||||
for (const rawPrice of plan.prices) {
|
||||
|
|
@ -91,7 +92,5 @@ export function formatPlanCatalogPrice(
|
|||
if (tier === 'free') return '무료'
|
||||
if (!catalog) return null
|
||||
const prices = catalog.plans[tier]
|
||||
if (prices.length === 0) return null
|
||||
if (prices.length === 1) return formatBillingPrice(prices[0])
|
||||
return prices.map((price) => `${price.provider === 'payple' ? 'Payple' : 'Stripe'} ${formatBillingPrice(price)}`).join(' · ')
|
||||
return prices.length === 0 ? null : formatBillingPrice(prices[0])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,10 @@
|
|||
// apps/web/src/lib/web-app-url.ts
|
||||
// 웹앱 안에서 쓰는 절대 URL·복귀 경로 헬퍼. 주소 정본은 @d3ro/core/web-urls 다.
|
||||
//
|
||||
// 결제사(Stripe·Payple)와 OAuth 는 절대 URL 을 요구한다. 운영은 d3ro.chanpaca.net/app,
|
||||
// 결제사(Payple)와 OAuth 는 절대 URL 을 요구한다. 운영은 d3ro.chanpaca.net/app,
|
||||
// 로컬 개발은 localhost:3000/app 이므로 origin 만 현재 브라우저 것으로 바꿔 끼운다.
|
||||
|
||||
import {
|
||||
billingUrl,
|
||||
PUBLIC_SITE_ORIGIN,
|
||||
WEB_APP_BASE_PATH,
|
||||
type BillingReturn
|
||||
} from '@d3ro/core/web-urls'
|
||||
import { billingUrl, PUBLIC_SITE_ORIGIN, WEB_APP_BASE_PATH } from '@d3ro/core/web-urls'
|
||||
|
||||
/** 로그인 뒤 돌아갈 경로를 proxy 가 (app) 레이아웃에 넘길 때 쓰는 요청 헤더. */
|
||||
export const RETURN_PATH_HEADER = 'x-d3ro-return-path'
|
||||
|
|
@ -39,7 +34,7 @@ export function appUrlFor(origin: string, path: string): string {
|
|||
}
|
||||
|
||||
/** 브라우저에서 결제 페이지 절대 URL. 쿼리 형식은 core billingUrl() 을 그대로 따른다. */
|
||||
export function browserBillingUrl(result?: BillingReturn): string {
|
||||
const canonical = billingUrl(result ? { result } : {})
|
||||
export function browserBillingUrl(): string {
|
||||
const canonical = billingUrl()
|
||||
return `${window.location.origin}${canonical.slice(PUBLIC_SITE_ORIGIN.length)}`
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue