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

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:
Yun Chan 2026-09-26 20:56:18 +09:00
parent 7224e43bfb
commit eedd127ea7
50 changed files with 97 additions and 2600 deletions

View file

@ -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')

View file

@ -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')
}
})
}

View file

@ -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)

View file

@ -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: () =>

View file

@ -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)}

View file

@ -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',

View file

@ -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>
)
}

View file

@ -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)
}
}

View file

@ -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' }}
>
즉시 전액 환불 승인

View file

@ -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);
}
});
});

View file

@ -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-원천세) 환율 적립`, () => {

View file

@ -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)
})
})

View file

@ -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()
})
})