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 { registerCloudSyncHandlers } from './cloud-sync-handlers'
|
||||||
import { registerAdsHandlers } from './ads-handlers'
|
import { registerAdsHandlers } from './ads-handlers'
|
||||||
import { registerSupportHandlers } from './support-handlers'
|
import { registerSupportHandlers } from './support-handlers'
|
||||||
import { registerPaymentHandlers } from './payment-handlers'
|
|
||||||
import { registerInputTelemetryHandlers } from './input-telemetry-handlers'
|
import { registerInputTelemetryHandlers } from './input-telemetry-handlers'
|
||||||
import { registerSuggestionHandlers } from './suggestion-handlers'
|
import { registerSuggestionHandlers } from './suggestion-handlers'
|
||||||
import { getLogger } from '../services/LoggerService'
|
import { getLogger } from '../services/LoggerService'
|
||||||
|
|
@ -64,7 +63,6 @@ export function registerAllIpcHandlers(): void {
|
||||||
registerCloudSyncHandlers()
|
registerCloudSyncHandlers()
|
||||||
registerAdsHandlers()
|
registerAdsHandlers()
|
||||||
registerSupportHandlers()
|
registerSupportHandlers()
|
||||||
registerPaymentHandlers()
|
|
||||||
registerInputTelemetryHandlers()
|
registerInputTelemetryHandlers()
|
||||||
registerSuggestionHandlers()
|
registerSuggestionHandlers()
|
||||||
logger.info('All IPC handlers registered')
|
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 = {
|
const result: RefundEligibilityResult = {
|
||||||
eligible: true,
|
eligible: true,
|
||||||
reason: '결제일로부터 7일 이내이며 클라우드 AI 정제 쿼터를 10% 미만 사용하셨습니다. (100% 전액 환불 가능)',
|
reason: '결제일로부터 7일 이내이며 클라우드 AI 정제 쿼터를 10% 미만 사용하셨습니다. (100% 전액 환불 가능)',
|
||||||
refundMethod: 'Toss Payments / Stripe 결제 즉시 취소',
|
refundMethod: 'Payple 카드 결제 즉시 취소',
|
||||||
estimatedRefundKrw: 229000,
|
estimatedRefundKrw: 229000,
|
||||||
}
|
}
|
||||||
return ok(result)
|
return ok(result)
|
||||||
|
|
|
||||||
|
|
@ -875,16 +875,6 @@ const electronAPI = {
|
||||||
checkRefund: () =>
|
checkRefund: () =>
|
||||||
invoke<import('@d3ro/core/types').RefundEligibilityResult>(IPC_CHANNELS.SUPPORT.CHECK_REFUND),
|
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 (입력 수집 동의 · 리포트) ───────────
|
// ── Input telemetry (입력 수집 동의 · 리포트) ───────────
|
||||||
inputTelemetry: {
|
inputTelemetry: {
|
||||||
getState: () =>
|
getState: () =>
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,6 @@ import { OnboardingModal } from './OnboardingModal'
|
||||||
import { AdBanner } from './ads/AdBanner'
|
import { AdBanner } from './ads/AdBanner'
|
||||||
import { RewardedQuotaModal } from './ads/RewardedQuotaModal'
|
import { RewardedQuotaModal } from './ads/RewardedQuotaModal'
|
||||||
import { SupportModal } from './support/SupportModal'
|
import { SupportModal } from './support/SupportModal'
|
||||||
import { CheckoutModal } from './payment/CheckoutModal'
|
|
||||||
import { StatusBar } from './StatusBar'
|
import { StatusBar } from './StatusBar'
|
||||||
import { TitleBar } from './TitleBar'
|
import { TitleBar } from './TitleBar'
|
||||||
import { d3roPalette, d3roFontSans, d3roFontMono, d3roTypo, d3roRadius, d3roShadow } from '@d3ro/ui/theme'
|
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 [onboardingOpen, setOnboardingOpen] = useState(false)
|
||||||
const [licenseModalOpen, setLicenseModalOpen] = useState(false)
|
const [licenseModalOpen, setLicenseModalOpen] = useState(false)
|
||||||
const [supportModalOpen, setSupportModalOpen] = useState(false)
|
const [supportModalOpen, setSupportModalOpen] = useState(false)
|
||||||
const [checkoutModalOpen, setCheckoutModalOpen] = useState(false)
|
|
||||||
const [rewardedModalOpen, setRewardedModalOpen] = useState(false)
|
const [rewardedModalOpen, setRewardedModalOpen] = useState(false)
|
||||||
const [currentTier, setCurrentTier] = useState<LicenseTier>('free')
|
const [currentTier, setCurrentTier] = useState<LicenseTier>('free')
|
||||||
const [fallbackMsg, setFallbackMsg] = useState<string | null>(null)
|
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 }}>
|
<Box sx={{ px: 2, pb: 1, pt: 0.5, flexShrink: 0 }}>
|
||||||
<AdBanner
|
<AdBanner
|
||||||
tier={currentTier}
|
tier={currentTier}
|
||||||
onOpenUpgradeModal={() => setCheckoutModalOpen(true)}
|
onUpgrade={() => void window.electronAPI.license.openBilling({ tier: 'pro' })}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
@ -394,13 +392,6 @@ export function AppLayout(): React.ReactElement {
|
||||||
<LicenseModal open={licenseModalOpen} onClose={() => setLicenseModalOpen(false)} />
|
<LicenseModal open={licenseModalOpen} onClose={() => setLicenseModalOpen(false)} />
|
||||||
<OnboardingModal open={onboardingOpen} onClose={() => setOnboardingOpen(false)} />
|
<OnboardingModal open={onboardingOpen} onClose={() => setOnboardingOpen(false)} />
|
||||||
<SupportModal open={supportModalOpen} onClose={() => setSupportModalOpen(false)} />
|
<SupportModal open={supportModalOpen} onClose={() => setSupportModalOpen(false)} />
|
||||||
<CheckoutModal
|
|
||||||
open={checkoutModalOpen}
|
|
||||||
onClose={() => setCheckoutModalOpen(false)}
|
|
||||||
onSuccess={(newTier) => {
|
|
||||||
setCurrentTier(newTier)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<RewardedQuotaModal
|
<RewardedQuotaModal
|
||||||
open={rewardedModalOpen}
|
open={rewardedModalOpen}
|
||||||
onClose={() => setRewardedModalOpen(false)}
|
onClose={() => setRewardedModalOpen(false)}
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,11 @@ import type { LicenseTier, AdCreativePayload } from '@d3ro/core/types'
|
||||||
|
|
||||||
interface AdBannerProps {
|
interface AdBannerProps {
|
||||||
tier: LicenseTier
|
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)
|
const [creative, setCreative] = useState<AdCreativePayload | null>(null)
|
||||||
|
|
||||||
// Fetch highest bidding ad creative via mediation auction
|
// Fetch highest bidding ad creative via mediation auction
|
||||||
|
|
@ -245,7 +246,7 @@ export function AdBanner({ tier, onOpenUpgradeModal }: AdBannerProps): React.Rea
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Typography
|
<Typography
|
||||||
onClick={onOpenUpgradeModal}
|
onClick={onUpgrade}
|
||||||
sx={{
|
sx={{
|
||||||
fontFamily: d3roFontSans,
|
fontFamily: d3roFontSans,
|
||||||
fontSize: '10px',
|
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% (자격 충족)
|
• 클라우드 사용량: 0% (자격 충족)
|
||||||
</Typography>
|
</Typography>
|
||||||
<Button
|
<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' }}
|
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);
|
await window.waitForTimeout(500);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('03. Should open the server-authoritative Stripe CheckoutModal', async () => {
|
test('03. Should expose the web billing upgrade link without an in-app checkout modal', async () => {
|
||||||
// Trigger checkout via "Remove ads with Pro" link in AdBanner
|
// "Remove ads with Pro" opens billingUrl({ tier }) in the external browser (LICENSE.OPEN_BILLING).
|
||||||
const removeAdsLink = window.locator('text=/Remove ads with Pro/').first();
|
const removeAdsLink = window.locator('text=/Remove ads with Pro/').first();
|
||||||
if (await removeAdsLink.isVisible()) {
|
if (await removeAdsLink.isVisible()) {
|
||||||
await removeAdsLink.click();
|
await expect(window.locator('text=/D3RO Voice Secure Checkout/')).toHaveCount(0);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -101,7 +101,7 @@ describe('E2E RED — 계열 F: 정산·수익 원장', () => {
|
||||||
const stats = engine.getRevenueStats()
|
const stats = engine.getRevenueStats()
|
||||||
for (const s of stats.settlements) {
|
for (const s of stats.settlements) {
|
||||||
expect(['pending', 'processing', 'settled', 'paid']).toContain(s.payoutStatus)
|
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-원천세) 환율 적립`, () => {
|
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',
|
tier: 'pro',
|
||||||
prices: [
|
prices: [
|
||||||
{ provider: 'payple', unit_amount: 9900, currency: 'KRW', interval: 'month', interval_count: 1 },
|
{ 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)
|
const catalog = parseBillingCatalog(catalogPayload)
|
||||||
expect(catalog).not.toBeNull()
|
expect(catalog).not.toBeNull()
|
||||||
expect(formatBillingPrice(catalog!.plans.pro[0])).toContain('9,900')
|
expect(formatBillingPrice(catalog!.plans.pro[0])).toContain('9,900')
|
||||||
expect(formatBillingPrice(catalog!.plans.pro[1])).toContain('9.99')
|
expect(formatPlanCatalogPrice('pro', catalog)).toContain('9,900')
|
||||||
expect(formatPlanCatalogPrice('pro', catalog)).toContain('Payple')
|
expect(formatPlanCatalogPrice('pro_plus', catalog)).toContain('29,900')
|
||||||
expect(formatPlanCatalogPrice('pro', catalog)).toContain('Stripe')
|
|
||||||
expect(formatPlanCatalogPrice('free', catalog)).toBe('무료')
|
expect(formatPlanCatalogPrice('free', catalog)).toBe('무료')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -56,6 +54,10 @@ test.describe('billing catalog strict client contract', () => {
|
||||||
...catalogPayload,
|
...catalogPayload,
|
||||||
plans: [{ tier: 'pro', prices: [{ ...catalogPayload.plans[0].prices[0], currency: 'KRW<script>' }] }, catalogPayload.plans[1]],
|
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()
|
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(proPlan.getByTestId('payple-upgrade-pro')).toBeEnabled()
|
||||||
await expect(page.getByTestId('billing-plan-pro_plus')).toBeVisible()
|
await expect(page.getByTestId('billing-plan-pro_plus')).toBeVisible()
|
||||||
|
|
||||||
await proPlan.getByLabel('Stripe 해외 카드').click()
|
await expect(proPlan.getByRole('button', { name: /Stripe/ })).toHaveCount(0)
|
||||||
await expect(proPlan.getByTestId('stripe-upgrade-pro')).toBeVisible()
|
|
||||||
await proPlan.getByLabel('Payple 국내 카드').click()
|
|
||||||
|
|
||||||
|
// 결제 경로가 없어도 과거 provider='stripe' 구독 행은 계속 읽혀야 한다.
|
||||||
const stripePeriodEnd = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString()
|
const stripePeriodEnd = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString()
|
||||||
const { error: stripeFixtureError } = await admin.from('subscriptions').update({
|
const { error: stripeFixtureError } = await admin.from('subscriptions').update({
|
||||||
tier: 'pro',
|
tier: 'pro',
|
||||||
|
|
@ -156,7 +155,7 @@ test.describe('Billing provider and Payple DOM flow', () => {
|
||||||
if (stripeFixtureError) throw stripeFixtureError
|
if (stripeFixtureError) throw stripeFixtureError
|
||||||
await page.reload()
|
await page.reload()
|
||||||
await expect(page.getByTestId('billing-current-provider')).toContainText('Stripe')
|
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)
|
await expect(page.getByTestId('billing-checkout-pro')).toHaveCount(0)
|
||||||
|
|
||||||
const { error: freeFixtureError } = await admin.from('subscriptions').update({
|
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 () => {
|
test('rejects missing or tampered catalog price before opening Payple', async () => {
|
||||||
for (const catalogPrice of [
|
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: 1, currency: 'USD', interval: 'month', intervalCount: 1 },
|
||||||
{ provider: 'payple', unitAmount: 9900, currency: 'KRW', interval: 'year', intervalCount: 1 },
|
{ provider: 'payple', unitAmount: 9900, currency: 'KRW', interval: 'year', intervalCount: 1 },
|
||||||
{ provider: 'payple', unitAmount: 0, currency: 'KRW', interval: 'month', 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 { d3roFontMono } from '@d3ro/ui/theme'
|
||||||
import { BillingCheckoutOptions } from '@/components/billing/billing-checkout-options'
|
import { BillingCheckoutOptions } from '@/components/billing/billing-checkout-options'
|
||||||
import { PaypleManageButton } from '@/components/billing/payple-manage-button'
|
import { PaypleManageButton } from '@/components/billing/payple-manage-button'
|
||||||
import { PortalButton } from '@/components/billing/portal-button'
|
|
||||||
import { getSupabaseServerClient } from '@/lib/supabase-server'
|
import { getSupabaseServerClient } from '@/lib/supabase-server'
|
||||||
import {
|
import {
|
||||||
formatPlanCatalogPrice,
|
formatPlanCatalogPrice,
|
||||||
|
|
@ -45,6 +44,7 @@ interface Plan {
|
||||||
}
|
}
|
||||||
|
|
||||||
const VALID_TIERS = new Set<SubscriptionTier>(['free', 'pro', 'pro_plus'])
|
const VALID_TIERS = new Set<SubscriptionTier>(['free', 'pro', 'pro_plus'])
|
||||||
|
// 'stripe' 는 결제 경로가 제거된 뒤에도 과거 구독 행(provider='stripe')을 읽기 위해 남긴다.
|
||||||
const VALID_PROVIDERS = new Set<BillingProvider>([
|
const VALID_PROVIDERS = new Set<BillingProvider>([
|
||||||
'none',
|
'none',
|
||||||
'stripe',
|
'stripe',
|
||||||
|
|
@ -96,7 +96,7 @@ function providerLabel(provider: BillingProvider): string {
|
||||||
const labels: Record<BillingProvider, string> = {
|
const labels: Record<BillingProvider, string> = {
|
||||||
none: '없음',
|
none: '없음',
|
||||||
payple: 'Payple',
|
payple: 'Payple',
|
||||||
stripe: 'Stripe',
|
stripe: 'Stripe (종료)',
|
||||||
google_play: 'Google Play',
|
google_play: 'Google Play',
|
||||||
app_store: 'App Store',
|
app_store: 'App Store',
|
||||||
admin: '관리자 부여'
|
admin: '관리자 부여'
|
||||||
|
|
@ -164,8 +164,6 @@ export default async function BillingPage({ searchParams }: BillingPageProps): P
|
||||||
const canPurchase = !isPaid && subscription.provider === 'none'
|
const canPurchase = !isPaid && subscription.provider === 'none'
|
||||||
const cancellationDate = formatDate(subscription.cancel_at)
|
const cancellationDate = formatDate(subscription.cancel_at)
|
||||||
const periodEnd = formatDate(subscription.current_period_end)
|
const periodEnd = formatDate(subscription.current_period_end)
|
||||||
const stripeCanceled = params['canceled'] === '1'
|
|
||||||
const stripeReturned = params['success'] === '1'
|
|
||||||
const selectedTier = selectedTierFrom(params['tier'])
|
const selectedTier = selectedTierFrom(params['tier'])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -182,19 +180,6 @@ export default async function BillingPage({ searchParams }: BillingPageProps): P
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</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 }}>
|
<MetalCard sx={{ p: { xs: 2.5, md: 3.5 }, mb: 4 }}>
|
||||||
<Stack direction={{ xs: 'column', md: 'row' }} justifyContent="space-between" gap={3}>
|
<Stack direction={{ xs: 'column', md: 'row' }} justifyContent="space-between" gap={3}>
|
||||||
<Box>
|
<Box>
|
||||||
|
|
@ -257,7 +242,7 @@ export default async function BillingPage({ searchParams }: BillingPageProps): P
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Typography sx={{ mt: 4, textAlign: 'center', color: 'var(--d3-text-label)', fontSize: 11 }}>
|
<Typography sx={{ mt: 4, textAlign: 'center', color: 'var(--d3-text-label)', fontSize: 11 }}>
|
||||||
국내 카드는 Payple, 해외 카드는 Stripe가 처리합니다. 활성 구독이 있으면 같은 provider에서만 관리할 수 있습니다.
|
웹 결제는 Payple(원화 카드)이 처리합니다. 모바일 앱 구독은 Google Play에서 관리합니다.
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
|
|
@ -350,7 +335,9 @@ function SubscriptionManagement({ subscription }: { subscription: BillingSubscri
|
||||||
return <Typography sx={{ color: 'var(--d3-status-warning)', fontSize: 12 }}>자동 갱신이 해지되었습니다.</Typography>
|
return <Typography sx={{ color: 'var(--d3-status-warning)', fontSize: 12 }}>자동 갱신이 해지되었습니다.</Typography>
|
||||||
}
|
}
|
||||||
if (subscription.provider === 'payple') return <PaypleManageButton />
|
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') {
|
if (subscription.provider === 'google_play') {
|
||||||
return <Alert severity="info">Google Play 앱에서 구독을 관리해 주세요.</Alert>
|
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 {
|
function providerLabel(provider: DashboardSnapshot['subscription']['provider']): string {
|
||||||
const labels: Record<DashboardSnapshot['subscription']['provider'], string> = {
|
const labels: Record<DashboardSnapshot['subscription']['provider'], string> = {
|
||||||
none: '미연결',
|
none: '미연결',
|
||||||
stripe: 'Stripe',
|
stripe: 'Stripe (종료)',
|
||||||
payple: '페이플',
|
payple: '페이플',
|
||||||
google_play: 'Google Play',
|
google_play: 'Google Play',
|
||||||
app_store: 'App Store',
|
app_store: 'App Store',
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,11 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useMemo, useState } from 'react'
|
import { Alert, Box } from '@mui/material'
|
||||||
import { Alert, Box, ToggleButton, ToggleButtonGroup, Typography } from '@mui/material'
|
|
||||||
import { CheckoutButton } from './checkout-button'
|
|
||||||
import { PaypleCheckoutButton } from './payple-checkout-button'
|
import { PaypleCheckoutButton } from './payple-checkout-button'
|
||||||
import type { PaypleTier } from './payple-client'
|
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({
|
export function BillingCheckoutOptions({
|
||||||
tier,
|
tier,
|
||||||
prices
|
prices
|
||||||
|
|
@ -14,48 +13,12 @@ export function BillingCheckoutOptions({
|
||||||
tier: PaypleTier
|
tier: PaypleTier
|
||||||
prices: BillingCatalogPrice[]
|
prices: BillingCatalogPrice[]
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
const availableProviders = useMemo(() => new Set(prices.map((price) => price.provider)), [prices])
|
const payplePrice = prices.find((price) => price.provider === 'payple')
|
||||||
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'
|
|
||||||
))
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box data-testid={`billing-checkout-${tier}`}>
|
<Box data-testid={`billing-checkout-${tier}`}>
|
||||||
<Typography sx={{ mb: 1, color: 'var(--d3-text-label)', fontSize: 11 }}>
|
{payplePrice ? <PaypleCheckoutButton tier={tier} catalogPrice={payplePrice} /> : (
|
||||||
결제 수단을 선택해 주세요. 한 구독에는 하나의 결제사만 사용할 수 있습니다.
|
|
||||||
</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="error">Payple 가격을 확인할 수 없습니다.</Alert>
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Alert severity="info" variant="outlined" sx={{ mb: 1, fontSize: 11 }}>
|
|
||||||
Stripe Checkout으로 이동해 해외 발급 카드를 결제합니다.
|
|
||||||
</Alert>
|
|
||||||
<CheckoutButton tier={tier} />
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</Box>
|
</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'
|
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 type BillingInterval = 'day' | 'week' | 'month' | 'year'
|
||||||
|
|
||||||
export interface BillingCatalogPrice {
|
export interface BillingCatalogPrice {
|
||||||
|
|
@ -15,7 +16,7 @@ export interface BillingCatalog {
|
||||||
plans: Record<'pro' | 'pro_plus', BillingCatalogPrice[]>
|
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 INTERVALS = new Set<BillingInterval>(['day', 'week', 'month', 'year'])
|
||||||
const PAID_TIERS = new Set(['pro', 'pro_plus'])
|
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
|
if (!rawPlan || typeof rawPlan !== 'object' || Array.isArray(rawPlan)) return null
|
||||||
const plan = rawPlan as { tier?: unknown; prices?: unknown }
|
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 (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)
|
seenTiers.add(plan.tier)
|
||||||
const seenProviders = new Set<string>()
|
const seenProviders = new Set<string>()
|
||||||
for (const rawPrice of plan.prices) {
|
for (const rawPrice of plan.prices) {
|
||||||
|
|
@ -91,7 +92,5 @@ export function formatPlanCatalogPrice(
|
||||||
if (tier === 'free') return '무료'
|
if (tier === 'free') return '무료'
|
||||||
if (!catalog) return null
|
if (!catalog) return null
|
||||||
const prices = catalog.plans[tier]
|
const prices = catalog.plans[tier]
|
||||||
if (prices.length === 0) return null
|
return prices.length === 0 ? null : formatBillingPrice(prices[0])
|
||||||
if (prices.length === 1) return formatBillingPrice(prices[0])
|
|
||||||
return prices.map((price) => `${price.provider === 'payple' ? 'Payple' : 'Stripe'} ${formatBillingPrice(price)}`).join(' · ')
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,10 @@
|
||||||
// apps/web/src/lib/web-app-url.ts
|
// apps/web/src/lib/web-app-url.ts
|
||||||
// 웹앱 안에서 쓰는 절대 URL·복귀 경로 헬퍼. 주소 정본은 @d3ro/core/web-urls 다.
|
// 웹앱 안에서 쓰는 절대 URL·복귀 경로 헬퍼. 주소 정본은 @d3ro/core/web-urls 다.
|
||||||
//
|
//
|
||||||
// 결제사(Stripe·Payple)와 OAuth 는 절대 URL 을 요구한다. 운영은 d3ro.chanpaca.net/app,
|
// 결제사(Payple)와 OAuth 는 절대 URL 을 요구한다. 운영은 d3ro.chanpaca.net/app,
|
||||||
// 로컬 개발은 localhost:3000/app 이므로 origin 만 현재 브라우저 것으로 바꿔 끼운다.
|
// 로컬 개발은 localhost:3000/app 이므로 origin 만 현재 브라우저 것으로 바꿔 끼운다.
|
||||||
|
|
||||||
import {
|
import { billingUrl, PUBLIC_SITE_ORIGIN, WEB_APP_BASE_PATH } from '@d3ro/core/web-urls'
|
||||||
billingUrl,
|
|
||||||
PUBLIC_SITE_ORIGIN,
|
|
||||||
WEB_APP_BASE_PATH,
|
|
||||||
type BillingReturn
|
|
||||||
} from '@d3ro/core/web-urls'
|
|
||||||
|
|
||||||
/** 로그인 뒤 돌아갈 경로를 proxy 가 (app) 레이아웃에 넘길 때 쓰는 요청 헤더. */
|
/** 로그인 뒤 돌아갈 경로를 proxy 가 (app) 레이아웃에 넘길 때 쓰는 요청 헤더. */
|
||||||
export const RETURN_PATH_HEADER = 'x-d3ro-return-path'
|
export const RETURN_PATH_HEADER = 'x-d3ro-return-path'
|
||||||
|
|
@ -39,7 +34,7 @@ export function appUrlFor(origin: string, path: string): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 브라우저에서 결제 페이지 절대 URL. 쿼리 형식은 core billingUrl() 을 그대로 따른다. */
|
/** 브라우저에서 결제 페이지 절대 URL. 쿼리 형식은 core billingUrl() 을 그대로 따른다. */
|
||||||
export function browserBillingUrl(result?: BillingReturn): string {
|
export function browserBillingUrl(): string {
|
||||||
const canonical = billingUrl(result ? { result } : {})
|
const canonical = billingUrl()
|
||||||
return `${window.location.origin}${canonical.slice(PUBLIC_SITE_ORIGIN.length)}`
|
return `${window.location.origin}${canonical.slice(PUBLIC_SITE_ORIGIN.length)}`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -114,7 +114,7 @@ D3RO는 이미 3계층 강제 규칙 체계를 가진다. 본 정책은 이를 *
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| W3-1 | 요금 | `packages/core/src/plan-catalog.ts` `PLAN_PRICE_KRW` (Free 0 / Pro 2,900 / Pro+ 8,900, 월). 기존 구독자도 다음 갱신부터 적용 | Deno `_shared/core-contract.generated.ts` (`npm run contract:sync`, CI `contract:check`) |
|
| W3-1 | 요금 | `packages/core/src/plan-catalog.ts` `PLAN_PRICE_KRW` (Free 0 / Pro 2,900 / Pro+ 8,900, 월). 기존 구독자도 다음 갱신부터 적용 | Deno `_shared/core-contract.generated.ts` (`npm run contract:sync`, CI `contract:check`) |
|
||||||
| W3-2 | 공개 URL | `packages/core/src/web-urls.ts` — 사이트 `/`, 웹앱 `/app`(Next basePath), `billingUrl()`, `SITE_URLS` | 같은 생성 파일 |
|
| W3-2 | 공개 URL | `packages/core/src/web-urls.ts` — 사이트 `/`, 웹앱 `/app`(Next basePath), `billingUrl()`, `SITE_URLS` | 같은 생성 파일 |
|
||||||
| W3-3 | 결제 진입 | 웹앱 `/app/billing` 하나. 데스크톱·모바일·사이트·Stripe 복귀는 모두 `billingUrl()`. 복귀 쿼리는 `success=1`/`canceled=1` | — |
|
| W3-3 | 결제 진입 | 웹앱 `/app/billing` 하나. 데스크톱·모바일·사이트는 모두 `billingUrl()`. 결제는 페이지 안 Payple 창에서 끝나므로 복귀 쿼리는 없다(Stripe와 `success=1`/`canceled=1` 복귀 쿼리는 2026-09-26 제거) | — |
|
||||||
| W3-4 | 웹앱 배포 | NAS Docker(`d3ro_voice_web`) → Cloudflare Tunnel 호스트 → 사이트 브리지 워커가 `/app/*`를 전달 | — |
|
| W3-4 | 웹앱 배포 | NAS Docker(`d3ro_voice_web`) → Cloudflare Tunnel 호스트 → 사이트 브리지 워커가 `/app/*`를 전달 | — |
|
||||||
| W3-5 | 랜딩·다운로드·법률·초대·assetlinks | `site/` 한 벌. apps/web·api-server wwwroot 사본 삭제, 웹앱 `/download`는 사이트로 리다이렉트 | — |
|
| W3-5 | 랜딩·다운로드·법률·초대·assetlinks | `site/` 한 벌. apps/web·api-server wwwroot 사본 삭제, 웹앱 `/download`는 사이트로 리다이렉트 | — |
|
||||||
| W3-6 | 설치 파일 | Forgejo feed만. 저장소에 추적된 바이너리 사본 삭제 | — |
|
| W3-6 | 설치 파일 | Forgejo feed만. 저장소에 추적된 바이너리 사본 삭제 | — |
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,7 @@ D3RO Voice
|
||||||
├── Monetization
|
├── Monetization
|
||||||
│ ├── Tiers: Free / Pro / Pro+ / Team / Enterprise
|
│ ├── Tiers: Free / Pro / Pro+ / Team / Enterprise
|
||||||
│ ├── Desktop licenses (Ed25519, offline)
|
│ ├── Desktop licenses (Ed25519, offline)
|
||||||
│ ├── Web billing (Stripe + Payple)
|
│ ├── Web billing (Payple, KRW — Stripe removed 2026-09-26)
|
||||||
│ ├── Mobile IAP (Google Play / App Store)
|
│ ├── Mobile IAP (Google Play / App Store)
|
||||||
│ └── Free-tier ads (AdMob rewarded + banner, mediation roster)
|
│ └── Free-tier ads (AdMob rewarded + banner, mediation roster)
|
||||||
├── Platform Shell
|
├── Platform Shell
|
||||||
|
|
@ -120,7 +120,7 @@ STT providers supported by the desktop dispatcher (`apps/desktop/src/main/servic
|
||||||
| Tier | Notes |
|
| Tier | Notes |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Free | Quotas on STT/LLM; free-tier ads (desktop/mobile). |
|
| Free | Quotas on STT/LLM; free-tier ads (desktop/mobile). |
|
||||||
| Pro / Pro+ | Paid subscriptions. Desktop: offline Ed25519 license. Web: Stripe/Payple. Mobile: Google Play Billing. |
|
| Pro / Pro+ | Paid subscriptions. Desktop: offline Ed25519 license. Web: Payple (KRW). Mobile: Google Play Billing. Stripe removed 2026-09-26. |
|
||||||
| Team / Enterprise | Teams, shared meetings, admin roles. |
|
| Team / Enterprise | Teams, shared meetings, admin roles. |
|
||||||
|
|
||||||
Desktop license verification is Ed25519 (public key in `release/desktop-license-public.pem`); the private key was rotated out of the repo. Mobile release evidence uses a separate Ed25519 keypair.
|
Desktop license verification is Ed25519 (public key in `release/desktop-license-public.pem`); the private key was rotated out of the repo. Mobile release evidence uses a separate Ed25519 keypair.
|
||||||
|
|
|
||||||
|
|
@ -165,8 +165,8 @@ Public endpoints (production): `https://d3ro.chanpaca.net` — **랜딩/다운
|
||||||
## 7. `server/supabase` (backend)
|
## 7. `server/supabase` (backend)
|
||||||
|
|
||||||
- `config.toml` — project `d3ro-voice`, ports 55321-55324, DB major 17, auth redirects (localhost, `d3ro.chanpaca.net`, `d3ro-voice://auth-callback`), providers Google/GitHub/Apple.
|
- `config.toml` — project `d3ro-voice`, ports 55321-55324, DB major 17, auth redirects (localhost, `d3ro.chanpaca.net`, `d3ro-voice://auth-callback`), providers Google/GitHub/Apple.
|
||||||
- `migrations/` — **63 SQL migrations** (schema, RLS, auth triggers, storage, team invites, knowledge/pgvector, push outbox, Payple/Stripe billing, admin roles, mobile platform/monetization, atomic command reorder, device revocation, content reporting, audit log, meeting documents, STT quota reservations, ad reward replay protection, team activity feed).
|
- `migrations/` — **63 SQL migrations** (schema, RLS, auth triggers, storage, team invites, knowledge/pgvector, push outbox, Payple billing (legacy Stripe columns/provider values kept for history), admin roles, mobile platform/monetization, atomic command reorder, device revocation, content reporting, audit log, meeting documents, STT quota reservations, ad reward replay protection, team activity feed).
|
||||||
- `functions/` — **~27 Deno Edge Functions** (`stt-proxy`, `llm-proxy`, `content-report`, `generate-meeting-document`, `embed-chunks`, `search-knowledge`, `realtime-token`, `team-invite`, `team-accept`, `send-push`, `account-delete`, `admin-users`, `admin-subscriptions`, `admin-payments`, `admin-audit-log`, billing `billing-catalog`/`stripe-checkout`/`stripe-portal`/`stripe-webhook`/`payple-checkout`/`payple-manage`/`payple-renew`/`payple-webhook`, `iap-verify`, `admob-ssv`, `google-play-rtdn`). Shared contracts in `functions/_shared/` — push transports now include `webpush.ts` (VAPID + RFC 8291) and `apns.ts` (.p8 token) alongside FCM. CI (`edge-functions-quality`) runs `deno check` + `deno test` and also the Cloudflare worker drain test.
|
- `functions/` — **~24 Deno Edge Functions** (`stt-proxy`, `llm-proxy`, `content-report`, `generate-meeting-document`, `embed-chunks`, `search-knowledge`, `realtime-token`, `team-invite`, `team-accept`, `send-push`, `account-delete`, `admin-users`, `admin-subscriptions`, `admin-payments`, `admin-audit-log`, billing `billing-catalog`/`payple-checkout`/`payple-manage`/`payple-renew`/`payple-webhook`, `iap-verify`, `admob-ssv`, `google-play-rtdn`; Stripe functions removed 2026-09-26). Shared contracts in `functions/_shared/` — push transports now include `webpush.ts` (VAPID + RFC 8291) and `apns.ts` (.p8 token) alongside FCM. CI (`edge-functions-quality`) runs `deno check` + `deno test` and also the Cloudflare worker drain test.
|
||||||
- `tests/` — integration/E2E for content report, mobile platform/recording/reward-race, payments, mobile release preflight, push, team push security, STT quota.
|
- `tests/` — integration/E2E for content report, mobile platform/recording/reward-race, payments, mobile release preflight, push, team push security, STT quota.
|
||||||
|
|
||||||
Full detail: [`09-supabase-backend.md`](./09-supabase-backend.md).
|
Full detail: [`09-supabase-backend.md`](./09-supabase-backend.md).
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ The canonical place for types and cross-surface logic. Both desktop and web/mobi
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Types | `./types` | Domain types shared across surfaces |
|
| Types | `./types` | Domain types shared across surfaces |
|
||||||
| Errors | `./errors` | `D3ROError`, `ErrorCode` |
|
| Errors | `./errors` | `D3ROError`, `ErrorCode` |
|
||||||
| IPC channels | `./ipc-channels` | `IPC_CHANNELS` object + `IPCChannel` union. **SSOT** for every desktop IPC channel (VOICE, AUDIO, STT, TTS, LLM, KEYBINDING, CONFIG, HISTORY, DICTIONARY, WINDOW, SYSTEM, STATS, MEMO, VOICE_COMMAND, CONTEXT, CHAIN, CAPTION, FILE_TRANSCRIPTION, MEETING_SUMMARY, DICTATION_TEMPLATE, VOICE_CONVERSATION, RAG, VOICE_ACTION, MEETING_MODE, MEETING_DOC_TEMPLATE, MEETING_CHAT, LICENSE, CLOUD_SYNC, INSTRUCTION, SYSTEM_AUDIO, POPUP_RESULT, POPUP_HISTORY, POPUP_COMMAND, POPUP_CAPTION, VOICE_PARTIAL, CLIPBOARD, APP, ONLINE_AUTH, ADS, SUPPORT, PAYMENT, INPUT_TELEMETRY, SUGGESTION, POPUP_SUGGESTION) |
|
| IPC channels | `./ipc-channels` | `IPC_CHANNELS` object + `IPCChannel` union. **SSOT** for every desktop IPC channel (VOICE, AUDIO, STT, TTS, LLM, KEYBINDING, CONFIG, HISTORY, DICTIONARY, WINDOW, SYSTEM, STATS, MEMO, VOICE_COMMAND, CONTEXT, CHAIN, CAPTION, FILE_TRANSCRIPTION, MEETING_SUMMARY, DICTATION_TEMPLATE, VOICE_CONVERSATION, RAG, VOICE_ACTION, MEETING_MODE, MEETING_DOC_TEMPLATE, MEETING_CHAT, LICENSE, CLOUD_SYNC, INSTRUCTION, SYSTEM_AUDIO, POPUP_RESULT, POPUP_HISTORY, POPUP_COMMAND, POPUP_CAPTION, VOICE_PARTIAL, CLIPBOARD, APP, ONLINE_AUTH, ADS, SUPPORT, INPUT_TELEMETRY, SUGGESTION, POPUP_SUGGESTION) |
|
||||||
| Key bindings | `./keybinding` | **SSOT** for every global shortcut in the app: `KeyBinding` (`device`/`code`/`ctrl`/`alt`/`shift`/`meta`), `KEY_CATALOG` (10 selectable groups incl. mouse), `KEYBINDING_ACTIONS` (9 rebindable actions incl. the three suggestion actions), `bindingKey`/`normalizeBinding`/`validateBinding`/`detectBindingConflicts`/`formatBindingSegments`/`searchKeyCatalog`/`parseBindingMap`. Persisted as `AppConfig.keyBindings`. i18n keys are exposed as plain `string` so core stays independent of `@d3ro/i18n`; consumers narrow at the boundary (`asTranslationKey`) and a contract test guards the keys — accepted constraint, `11` §7 CONSTRAINT-I18N-01. Tests: `__tests__/keybinding*.test.ts` via `vitest.config.ts` (`npm run test --workspace=@d3ro/core`), 117 cases as of 2026-09-21 |
|
| Key bindings | `./keybinding` | **SSOT** for every global shortcut in the app: `KeyBinding` (`device`/`code`/`ctrl`/`alt`/`shift`/`meta`), `KEY_CATALOG` (10 selectable groups incl. mouse), `KEYBINDING_ACTIONS` (9 rebindable actions incl. the three suggestion actions), `bindingKey`/`normalizeBinding`/`validateBinding`/`detectBindingConflicts`/`formatBindingSegments`/`searchKeyCatalog`/`parseBindingMap`. Persisted as `AppConfig.keyBindings`. i18n keys are exposed as plain `string` so core stays independent of `@d3ro/i18n`; consumers narrow at the boundary (`asTranslationKey`) and a contract test guards the keys — accepted constraint, `11` §7 CONSTRAINT-I18N-01. Tests: `__tests__/keybinding*.test.ts` via `vitest.config.ts` (`npm run test --workspace=@d3ro/core`), 117 cases as of 2026-09-21 |
|
||||||
| Input intelligence | `./input-intelligence` | **SSOT** for the input-telemetry and next-sentence-suggestion domain (added 2026-09-21): key classification (`classifyKeyStroke`), text metrics (`countWords`/`countSentences`/`endsSentence`/`textBeforeCaret`), `computeTypedDelta` (UIA snapshot diff — the IME-safe way to count typed text), `decideSuggestion` + `isAppExcluded` (when to request / skip / clear), `parseSuggestionCandidates`/`sanitizeSuggestionLine` (prompt-leak and prefix-echo defence), `anchorFloatingPanel` (caret-anchored overlay placement), `mergeActivityBucket`/`summarizeActivity`/`pixelsToMeters`, `extractPhrases`/`selectPhraseHints`, `INPUT_TELEMETRY_DEFAULTS`/`SUGGESTION_DEFAULTS` |
|
| Input intelligence | `./input-intelligence` | **SSOT** for the input-telemetry and next-sentence-suggestion domain (added 2026-09-21): key classification (`classifyKeyStroke`), text metrics (`countWords`/`countSentences`/`endsSentence`/`textBeforeCaret`), `computeTypedDelta` (UIA snapshot diff — the IME-safe way to count typed text), `decideSuggestion` + `isAppExcluded` (when to request / skip / clear), `parseSuggestionCandidates`/`sanitizeSuggestionLine` (prompt-leak and prefix-echo defence), `anchorFloatingPanel` (caret-anchored overlay placement), `mergeActivityBucket`/`summarizeActivity`/`pixelsToMeters`, `extractPhrases`/`selectPhraseHints`, `INPUT_TELEMETRY_DEFAULTS`/`SUGGESTION_DEFAULTS` |
|
||||||
| Constants | `./constants` | Shared constants |
|
| Constants | `./constants` | Shared constants |
|
||||||
|
|
|
||||||
|
|
@ -101,7 +101,7 @@ The existing `input-telemetry-handlers` and `suggestion-handlers` IPC extensions
|
||||||
| `UpdateService` | electron-updater (canonical Forgejo feed, channels, mandatory/full-vs-delta policy, staged rollout, restart dialog) |
|
| `UpdateService` | electron-updater (canonical Forgejo feed, channels, mandatory/full-vs-delta policy, staged rollout, restart dialog) |
|
||||||
| `AutoLaunchService` | OS login-item auto-start |
|
| `AutoLaunchService` | OS login-item auto-start |
|
||||||
| `LoggerService` | electron-log wrapper + category loggers |
|
| `LoggerService` | electron-log wrapper + category loggers |
|
||||||
| `checkout`/payment | `payment-handlers.ts` — authenticated Edge-only Stripe/Payple checkout + server readback |
|
| upgrade / billing | No in-app checkout. `license-handlers.ts` `LICENSE.OPEN_BILLING` opens `billingUrl({ tier })` (web Payple); tier returns via `license:tierChanged`. Stripe `payment-handlers.ts`·`CheckoutModal`·`payment:*` IPC removed 2026-09-26 |
|
||||||
|
|
||||||
### Ads (`services/ads/`)
|
### Ads (`services/ads/`)
|
||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
|
|
@ -139,7 +139,6 @@ Registry: `src/main/ipc/index.ts` calls 31 `registerXHandlers()` in fixed order.
|
||||||
| `meeting-mode-handlers` | MEETING_MODE + MEETING_CHAT |
|
| `meeting-mode-handlers` | MEETING_MODE + MEETING_CHAT |
|
||||||
| `meeting-summary-handlers` | MEETING_SUMMARY |
|
| `meeting-summary-handlers` | MEETING_SUMMARY |
|
||||||
| `memo-handlers` | MEMO |
|
| `memo-handlers` | MEMO |
|
||||||
| `payment-handlers` | PAYMENT |
|
|
||||||
| `rag-handlers` | RAG |
|
| `rag-handlers` | RAG |
|
||||||
| `stt-handlers` | STT |
|
| `stt-handlers` | STT |
|
||||||
| `suggestion-handlers` | SUGGESTION + POPUP_SUGGESTION |
|
| `suggestion-handlers` | SUGGESTION + POPUP_SUGGESTION |
|
||||||
|
|
@ -156,7 +155,7 @@ The **`KEYBINDING`** group replaced the old per-action `HOTKEY` group. `HOTKEY`
|
||||||
|
|
||||||
**`LLM.PROCESS` normalizes at the IPC boundary.** The handler runs `buildInstructionInvocation` itself when `action === 'custom'` with a `customPrompt` (`llm-handlers.ts:94-108`), so the renderer passes the **raw instruction text** and never duplicates the substitution or argument-placement rules. This is what makes `VoiceModeService`, `ChainService`, and `LLM.PROCESS` literally share one implementation. No channel or type changed for this; `LLMProcessParams` is unchanged.
|
**`LLM.PROCESS` normalizes at the IPC boundary.** The handler runs `buildInstructionInvocation` itself when `action === 'custom'` with a `customPrompt` (`llm-handlers.ts:94-108`), so the renderer passes the **raw instruction text** and never duplicates the substitution or argument-placement rules. This is what makes `VoiceModeService`, `ChainService`, and `LLM.PROCESS` literally share one implementation. No channel or type changed for this; `LLMProcessParams` is unchanged.
|
||||||
|
|
||||||
Preload exposes **`window.electronAPI`** with 35 namespaces: `platform, audio, config, voice, stt, keybinding, llm (incl. premium), history, dictionary, stats, window, system, instruction, app, memo, voiceCommand, context, chain, caption, license, fileTranscription, meetingSummary, dictationTemplate, rag, voiceAction, voiceConversation, meetingMode, meetingChat, meetingDocTemplate, cloudSync, onlineAuth, ads, support, payment, inputTelemetry, suggestion`. The `keybinding` bridge is 9 methods mirroring the channels above (`src/preload/index.ts:323`), replacing the 11-method `hotkey` bridge. Envelope: `IPCResult<T>` (success/error); `app.onDataChanged` is the global refresh channel.
|
Preload exposes **`window.electronAPI`** with 35 namespaces: `platform, audio, config, voice, stt, keybinding, llm (incl. premium), history, dictionary, stats, window, system, instruction, app, memo, voiceCommand, context, chain, caption, license, fileTranscription, meetingSummary, dictationTemplate, rag, voiceAction, voiceConversation, meetingMode, meetingChat, meetingDocTemplate, cloudSync, onlineAuth, ads, support, inputTelemetry, suggestion`. The `keybinding` bridge is 9 methods mirroring the channels above (`src/preload/index.ts:323`), replacing the 11-method `hotkey` bridge. Envelope: `IPCResult<T>` (success/error); `app.onDataChanged` is the global refresh channel.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ Root layout: `ThemeProvider → I18nProvider → AuthProvider`.
|
||||||
| `(app)/dictionary` | client | Pronunciation dictionary CRUD |
|
| `(app)/dictionary` | client | Pronunciation dictionary CRUD |
|
||||||
| `(app)/history` | client | History list: search, favorites, pagination, copy/delete |
|
| `(app)/history` | client | History list: search, favorites, pagination, copy/delete |
|
||||||
| `(app)/history/[id]` | client | History detail: edit title/original/polished, favorite, delete |
|
| `(app)/history/[id]` | client | History detail: edit title/original/polished, favorite, delete |
|
||||||
| `(app)/billing` | client | Plans + Payple/Stripe checkout, manage/portal |
|
| `(app)/billing` | client | Plans + Payple checkout/manage (Stripe removed 2026-09-26; legacy `provider='stripe'` rows shown read-only) |
|
||||||
|
|
||||||
`(app)/layout.tsx` is the auth guard + shared `<Sidebar/>`.
|
`(app)/layout.tsx` is the auth guard + shared `<Sidebar/>`.
|
||||||
|
|
||||||
|
|
@ -44,7 +44,7 @@ Root layout: `ThemeProvider → I18nProvider → AuthProvider`.
|
||||||
| `actions/` | `action-runner.tsx` (LLM parse → action: create_meeting/search_knowledge/create_memo/send_team_invite) |
|
| `actions/` | `action-runner.tsx` (LLM parse → action: create_meeting/search_knowledge/create_memo/send_team_invite) |
|
||||||
| `record/` | `mic-recorder.tsx` (MediaRecorder + level analyser → `transcribeWebAudio`) |
|
| `record/` | `mic-recorder.tsx` (MediaRecorder + level analyser → `transcribeWebAudio`) |
|
||||||
| `dashboard/` | `meetings-trend-chart.tsx` (recharts) |
|
| `dashboard/` | `meetings-trend-chart.tsx` (recharts) |
|
||||||
| `billing/` | `billing-checkout-options`, `checkout-button` (Stripe), `payple-checkout-button`, `payple-client`, `payple-manage-button`, `portal-button` |
|
| `billing/` | `billing-checkout-options`, `payple-checkout-button`, `payple-client`, `payple-manage-button` |
|
||||||
| `meetings/` | `document-editor`, `generate-document-button`, `live-transcript-list` (Realtime), `markdown-preview` (Mermaid), `meeting-audio-player` (signed URL), `memo-form` |
|
| `meetings/` | `document-editor`, `generate-document-button`, `live-transcript-list` (Realtime), `markdown-preview` (Mermaid), `meeting-audio-player` (signed URL), `memo-form` |
|
||||||
| `chat/` | `chat-panel.tsx` |
|
| `chat/` | `chat-panel.tsx` |
|
||||||
| `teams/` | `create-team-form`, `invite-member-form`, `activity-feed` (team notes + realtime) |
|
| `teams/` | `create-team-form`, `invite-member-form`, `activity-feed` (team notes + realtime) |
|
||||||
|
|
@ -58,7 +58,7 @@ Root layout: `ThemeProvider → I18nProvider → AuthProvider`.
|
||||||
|
|
||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `billing-catalog.ts` | Parse/validate billing catalog (schema v1, pro/pro_plus, Payple/Stripe prices) |
|
| `billing-catalog.ts` | Parse/validate billing catalog (schema v1, pro/pro_plus, Payple KRW prices only) |
|
||||||
| `command-client.ts` | Custom instruction client (types, error codes, execute) |
|
| `command-client.ts` | Custom instruction client (types, error codes, execute) |
|
||||||
| `dashboard-client.ts` | Dashboard snapshot loader |
|
| `dashboard-client.ts` | Dashboard snapshot loader |
|
||||||
| `dictionary-client.ts` | Dictionary CRUD/pagination/search + `serializeDictionary` / `parseDictionaryFile` / `importDictionaryFile` (JSON/CSV) |
|
| `dictionary-client.ts` | Dictionary CRUD/pagination/search + `serializeDictionary` / `parseDictionaryFile` / `importDictionaryFile` (JSON/CSV) |
|
||||||
|
|
@ -78,7 +78,7 @@ Playwright specs in `apps/web/e2e/`: billing, payple-checkout, dashboard-diction
|
||||||
## 5. Web status summary
|
## 5. Web status summary
|
||||||
|
|
||||||
- Full App Router console: auth (email + OAuth), record/STT, history (list+detail), commands, actions, meetings (list+detail+docs), knowledge (add+search), teams (list+detail+invite), chat, dictionary, billing, download/releases.
|
- Full App Router console: auth (email + OAuth), record/STT, history (list+detail), commands, actions, meetings (list+detail+docs), knowledge (add+search), teams (list+detail+invite), chat, dictionary, billing, download/releases.
|
||||||
- Backed by Supabase tables + Edge Functions (`stt-proxy`, `llm-proxy`, `team-invite`, `team-accept`, `stripe-checkout`, `payple-checkout`, `payple-manage`, `search-knowledge`, `generate-meeting-document`).
|
- Backed by Supabase tables + Edge Functions (`stt-proxy`, `llm-proxy`, `team-invite`, `team-accept`, `payple-checkout`, `payple-manage`, `search-knowledge`, `generate-meeting-document`).
|
||||||
- No literal `TODO`/`FIXME` markers; remaining smaller items (see [`11-gap-backlog.md`](./11-gap-backlog.md) `WEB-*`):
|
- No literal `TODO`/`FIXME` markers; remaining smaller items (see [`11-gap-backlog.md`](./11-gap-backlog.md) `WEB-*`):
|
||||||
- Teams `member_count` is `0` in MVP (separate query needed) — `teams/page.tsx`.
|
- Teams `member_count` is `0` in MVP (separate query needed) — `teams/page.tsx`.
|
||||||
- Action runner team-invite uses a manual redirect safety path instead of a live invite (`action-runner.tsx`).
|
- Action runner team-invite uses a manual redirect safety path instead of a live invite (`action-runner.tsx`).
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@
|
||||||
| Teams | team invites, membership, roles, `team_activities` feed (`20260913000033`) |
|
| Teams | team invites, membership, roles, `team_activities` feed (`20260913000033`) |
|
||||||
| Knowledge / RAG | `knowledge_documents`, `knowledge_chunks`, pgvector |
|
| Knowledge / RAG | `knowledge_documents`, `knowledge_chunks`, pgvector |
|
||||||
| Notifications / push | push tokens, durable outbox |
|
| Notifications / push | push tokens, durable outbox |
|
||||||
| Billing | Payple, Stripe, subscriptions, payment provider events/operations |
|
| Billing | Payple, subscriptions, payment provider events/operations (`'stripe'` provider values kept only for historical rows) |
|
||||||
| Admin | admin roles, audit log, atomic admin RPCs |
|
| Admin | admin roles, audit log, atomic admin RPCs |
|
||||||
| Mobile platform | mobile platform/monetization/runtime integrity |
|
| Mobile platform | mobile platform/monetization/runtime integrity |
|
||||||
| Commands | atomic command reorder |
|
| Commands | atomic command reorder |
|
||||||
|
|
@ -48,7 +48,6 @@ Migration numbering referenced in SSOT goes up to `00028`; CI verifies `migratio
|
||||||
| `account-delete` | Account deletion cascade + provider unlink |
|
| `account-delete` | Account deletion cascade + provider unlink |
|
||||||
| `admin-users` / `admin-subscriptions` / `admin-payments` / `admin-audit-log` | Admin operations |
|
| `admin-users` / `admin-subscriptions` / `admin-payments` / `admin-audit-log` | Admin operations |
|
||||||
| `billing-catalog` | Server pricing catalog. Prices/quotas read `PLAN_PRICE_KRW`/`PLAN_QUOTA` from `functions/_shared/core-contract.generated.ts`, generated from `packages/core/src/plan-catalog.ts` by `scripts/ci/sync-core-contract.mjs` (`npm run contract:check` fails on drift; added 2026-09-26, Wave 3, 88f24d8) |
|
| `billing-catalog` | Server pricing catalog. Prices/quotas read `PLAN_PRICE_KRW`/`PLAN_QUOTA` from `functions/_shared/core-contract.generated.ts`, generated from `packages/core/src/plan-catalog.ts` by `scripts/ci/sync-core-contract.mjs` (`npm run contract:check` fails on drift; added 2026-09-26, Wave 3, 88f24d8) |
|
||||||
| `stripe-checkout` / `stripe-portal` / `stripe-webhook` | Stripe billing |
|
|
||||||
| `payple-checkout` / `payple-manage` / `payple-renew` / `payple-webhook` | Payple billing (Korea) |
|
| `payple-checkout` / `payple-manage` / `payple-renew` / `payple-webhook` | Payple billing (Korea) |
|
||||||
| `iap-verify` | Google Play / App Store purchase verification |
|
| `iap-verify` | Google Play / App Store purchase verification |
|
||||||
| `admob-ssv` | AdMob server-side verification + reward ledger |
|
| `admob-ssv` | AdMob server-side verification + reward ledger |
|
||||||
|
|
@ -92,7 +91,7 @@ Shared TS types for these live in `packages/api-client` (SSOT).
|
||||||
|
|
||||||
- Auth (email + Google/GitHub/Apple), RLS, storage, realtime: **implemented**; production Auth + Google provider entry verified GREEN; GitHub/Apple provider secrets and mobile consent callback pending (external).
|
- Auth (email + Google/GitHub/Apple), RLS, storage, realtime: **implemented**; production Auth + Google provider entry verified GREEN; GitHub/Apple provider secrets and mobile consent callback pending (external).
|
||||||
- STT/LLM proxies: **implemented** and fail-closed (no synthetic transcripts); atomic quota reservations verified with 20-way concurrency.
|
- STT/LLM proxies: **implemented** and fail-closed (no synthetic transcripts); atomic quota reservations verified with 20-way concurrency.
|
||||||
- Billing (Stripe + Payple + IAP verify + webhooks/RTDN): **implemented**; live provider end-to-end and Payple webhook signature verification pending.
|
- Billing (Payple + IAP verify + webhooks/RTDN; Stripe removed 2026-09-26): **implemented**; live provider end-to-end and Payple webhook signature verification pending.
|
||||||
- Ads (AdMob SSV, rewarded ledger, replay protection): **implemented** (Edge v13 ACTIVE); production AdMob serving blocked externally (review/serving limits/store link/payment profile).
|
- Ads (AdMob SSV, rewarded ledger, replay protection): **implemented** (Edge v13 ACTIVE); production AdMob serving blocked externally (review/serving limits/store link/payment profile).
|
||||||
- Push: Supabase owns tokens/devices/outbox/retries. Transports implemented for **FCM, Web Push (VAPID + RFC 8291), and APNs (.p8 token)**; the Cloudflare Worker cron drains the outbox every minute. Android still requires FCM at the device. Details: `docs/deployment/push-transport-without-firebase.md`.
|
- Push: Supabase owns tokens/devices/outbox/retries. Transports implemented for **FCM, Web Push (VAPID + RFC 8291), and APNs (.p8 token)**; the Cloudflare Worker cron drains the outbox every minute. Android still requires FCM at the device. Details: `docs/deployment/push-transport-without-firebase.md`.
|
||||||
- Content safety: generation receipts + `content-report` **implemented**.
|
- Content safety: generation receipts + `content-report` **implemented**.
|
||||||
|
|
|
||||||
|
|
@ -143,7 +143,7 @@ Status quick-reference: `[x]` done+verified · `[~]` partial/unverified · `[ ]`
|
||||||
| MON-01 | Tier gating (Free/Pro/Pro+/Team/Enterprise) | [x] | [x] | [x] | [x] | `LicenseService`, entitlement provider |
|
| MON-01 | Tier gating (Free/Pro/Pro+/Team/Enterprise) | [x] | [x] | [x] | [x] | `LicenseService`, entitlement provider |
|
||||||
| MON-02 | Usage quotas (daily_usage) | [x] | [x] | [x] | [x] | |
|
| MON-02 | Usage quotas (daily_usage) | [x] | [x] | [x] | [x] | |
|
||||||
| MON-03 | Desktop offline license (Ed25519) | [x] | [-] | [-] | [x] | `crypto-license` + admin issuer |
|
| MON-03 | Desktop offline license (Ed25519) | [x] | [-] | [-] | [x] | `crypto-license` + admin issuer |
|
||||||
| MON-04 | Web checkout (Stripe) | [-] | [x] | [~] | [x] | Stripe checkout/portal/webhook |
|
| MON-04 | ~~Web checkout (Stripe)~~ — **삭제됨 2026-09-26** | [-] | [-] | [-] | [-] | 사용자 결정: 결제는 웹 Payple(KRW) + 모바일 Google Play만. `stripe-checkout`/`stripe-portal`/`stripe-webhook` 함수, 웹 `checkout-button`/`portal-button`, 데스크톱 `CheckoutModal`·`payment-handlers`·`payment:*` IPC 삭제. 과거 `provider='stripe'` 구독 행은 읽기 전용 표시만(마이그레이션 불변). 운영 정리는 GAP-BILL-03 |
|
||||||
| MON-05 | Web checkout (Payple) | [-] | [x] | [~] | [~] | Payple checkout/manage/renew/webhook; webhook signature pending |
|
| MON-05 | Web checkout (Payple) | [-] | [x] | [~] | [~] | Payple checkout/manage/renew/webhook; webhook signature pending |
|
||||||
| MON-06 | Paywall / upgrade prompts | [x] | [x] | [x] | [x] | `UpgradePromptModal`, `ProPaywallScreen` |
|
| MON-06 | Paywall / upgrade prompts | [x] | [x] | [x] | [x] | `UpgradePromptModal`, `ProPaywallScreen` |
|
||||||
| MON-07 | Mobile IAP purchase + restore | [-] | [-] | [~] | [x] | `iap-verify` + `billing-context`; live store E2E blocked |
|
| MON-07 | Mobile IAP purchase + restore | [-] | [-] | [~] | [x] | `iap-verify` + `billing-context`; live store E2E blocked |
|
||||||
|
|
@ -151,7 +151,7 @@ Status quick-reference: `[x]` done+verified · `[~]` partial/unverified · `[ ]`
|
||||||
| MON-09 | Free-tier banner ads | [~] | [-] | [x] | [x] | Desktop adapters fail-closed; mobile AdMob test GREEN, prod serving blocked |
|
| MON-09 | Free-tier banner ads | [~] | [-] | [x] | [x] | Desktop adapters fail-closed; mobile AdMob test GREEN, prod serving blocked |
|
||||||
| MON-10 | Rewarded ads → quota credits | [~] | [-] | [x] | [x] | Desktop `RewardedQuotaModal` (stub adapters); mobile SSV GREEN |
|
| MON-10 | Rewarded ads → quota credits | [~] | [-] | [x] | [x] | Desktop `RewardedQuotaModal` (stub adapters); mobile SSV GREEN |
|
||||||
| MON-11 | Ad mediation engine + settlement | [~] | [-] | [~] | [x] | Engine + settlement built. `DirectHouseSponsorAdapter` is now a **real configurable REST adapter** (bid/impression/click/reward via `endpointUrl`, fail-closed when unconfigured, unit-tested). Other 9 networks remain `UnavailableAdAdapter` stubs pending official SDKs. |
|
| MON-11 | Ad mediation engine + settlement | [~] | [-] | [~] | [x] | Engine + settlement built. `DirectHouseSponsorAdapter` is now a **real configurable REST adapter** (bid/impression/click/reward via `endpointUrl`, fail-closed when unconfigured, unit-tested). Other 9 networks remain `UnavailableAdAdapter` stubs pending official SDKs. |
|
||||||
| MON-12 | Subscription management (portal/store) | [-] | [x] | [x] | [x] | Stripe portal / Payple manage / Play manage |
|
| MON-12 | Subscription management (portal/store) | [-] | [x] | [x] | [x] | Payple manage / Play manage (Stripe portal 삭제 2026-09-26) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,8 @@ Legend: `[ ]` open · `[~]` in progress · `[!]` blocked externally · `[x]` res
|
||||||
| GAP-REL-09 | Release | 랜딩 사이트가 **재배포되지 않는다**. `deploy` 워크플로가 main push마다 실패한다. 실측 원인(run#66 로그): `site/src/sections/Hero.tsx`가 타이머 ref를 `NodeJS.Timeout`으로 타이핑해 `@types/node` 네임스페이스가 필요했고, 배포 잡은 `npm ci --prefix site`만 하므로 조상 `node_modules`의 hoisted 타입이 없어 `tsc -b`가 `TS2503: Cannot find namespace 'NodeJS'`로 실패한다. 그래서 `https://d3ro.chanpaca.net/release-identity.json`이 404다(공개 버전 검증 불가). | `.forgejo/workflows/deploy-site.yml`, `site/src/sections/Hero.tsx` | `[x]` 2026-09-19: ref를 `ReturnType<typeof setTimeout>`으로 바꿔 hoisted 타입 의존을 제거했다(격리 `--typeRoots`로 CI 조건 재현 → 수정 전 TS2503, 수정 후 clean). 같은 수정을 push하자 `deploy` run#67이 사이트 빌드를 통과해 `dist/`를 만들었고, 실패는 다음 단계(Cloudflare)로 이동했다. |
|
| GAP-REL-09 | Release | 랜딩 사이트가 **재배포되지 않는다**. `deploy` 워크플로가 main push마다 실패한다. 실측 원인(run#66 로그): `site/src/sections/Hero.tsx`가 타이머 ref를 `NodeJS.Timeout`으로 타이핑해 `@types/node` 네임스페이스가 필요했고, 배포 잡은 `npm ci --prefix site`만 하므로 조상 `node_modules`의 hoisted 타입이 없어 `tsc -b`가 `TS2503: Cannot find namespace 'NodeJS'`로 실패한다. 그래서 `https://d3ro.chanpaca.net/release-identity.json`이 404다(공개 버전 검증 불가). | `.forgejo/workflows/deploy-site.yml`, `site/src/sections/Hero.tsx` | `[x]` 2026-09-19: ref를 `ReturnType<typeof setTimeout>`으로 바꿔 hoisted 타입 의존을 제거했다(격리 `--typeRoots`로 CI 조건 재현 → 수정 전 TS2503, 수정 후 clean). 같은 수정을 push하자 `deploy` run#67이 사이트 빌드를 통과해 `dist/`를 만들었고, 실패는 다음 단계(Cloudflare)로 이동했다. |
|
||||||
| GAP-REL-09b | Release | `d3ro.chanpaca.net`이 404였던 직접 원인: 이 Cloudflare 계정에 Pages 프로젝트 `d3ro`/`d3ro-voice`가 **존재하지 않아** 커스텀 도메인 바인딩이 없었다(빈 본문 404, `cf-ray`만 반환). Pages 커스텀 도메인은 존 DNS CNAME(`d3ro → d3ro.pages.dev`)을 요구하는데 기존 `d3ro` 레코드가 남아 있어 `CNAME record not set`으로 pending에 머물렀고, 로컬 wrangler 자격증명에는 DNS 스코프가 없다(403 Authentication error). `deploy-site.yml`은 `CF_API_TOKEN` 시크릿이 없어 마지막 게시 단계에서도 `exit 1`이다. | `server/cloudflare-site-bridge/`, `.forgejo/workflows/deploy-site.yml`, `docs/map/02-infrastructure.md` | `[x]` 2026-09-19: Pages 프로젝트 `d3ro` 생성 + `site/dist` production 배포(`d3ro.pages.dev` 200, `release-identity.json` = commit `2407f5a` / 1.3.7) + 커스텀 도메인 연결. DNS 없이 도메인을 살리기 위해 Workers 라우트 브리지(`server/cloudflare-site-bridge`, `d3ro.chanpaca.net/*` → Pages 프록시, `npx wrangler deploy`)를 배포 → 라이브 확인: `/`·`/privacy/`·`/terms/`·`/delete-account/` 200, 라이브 번들이 설치 파일명을 `1.3.7`로 계산, `/download.html` → `/#download`. 남은 정리 2건: (1) 대시보드에 CNAME을 추가한 뒤 브리지 워커 삭제, (2) CI 자동 게시를 위해 `CF_API_TOKEN`(Pages/Workers Edit) + `CF_ACCOUNT_ID`=`8e83cc130e7329c160cf2b88d6b4c20a`를 Forgejo 시크릿에 등록. **2026-09-23:** CI `deploy`(deploy-site.yml)는 `CF_API_TOKEN` 부재로 run#71까지 여전히 실패한다. 로컬 인증 wrangler(pages:write)로 `npm run build --prefix site` → release-identity 작성 → `npx wrangler pages deploy site/dist --project-name d3ro --branch main`을 수동 배포했고, 라이브 `https://d3ro.chanpaca.net/release-identity.json`이 commit `5c11ee2` / version `1.5.0`을, 라이브 번들이 설치 파일명 `D3RO-Voice-Setup-1.5.0-x64.exe`를 보고한다. 자동 게시에는 여전히 시크릿 등록이 필요하다. |
|
| GAP-REL-09b | Release | `d3ro.chanpaca.net`이 404였던 직접 원인: 이 Cloudflare 계정에 Pages 프로젝트 `d3ro`/`d3ro-voice`가 **존재하지 않아** 커스텀 도메인 바인딩이 없었다(빈 본문 404, `cf-ray`만 반환). Pages 커스텀 도메인은 존 DNS CNAME(`d3ro → d3ro.pages.dev`)을 요구하는데 기존 `d3ro` 레코드가 남아 있어 `CNAME record not set`으로 pending에 머물렀고, 로컬 wrangler 자격증명에는 DNS 스코프가 없다(403 Authentication error). `deploy-site.yml`은 `CF_API_TOKEN` 시크릿이 없어 마지막 게시 단계에서도 `exit 1`이다. | `server/cloudflare-site-bridge/`, `.forgejo/workflows/deploy-site.yml`, `docs/map/02-infrastructure.md` | `[x]` 2026-09-19: Pages 프로젝트 `d3ro` 생성 + `site/dist` production 배포(`d3ro.pages.dev` 200, `release-identity.json` = commit `2407f5a` / 1.3.7) + 커스텀 도메인 연결. DNS 없이 도메인을 살리기 위해 Workers 라우트 브리지(`server/cloudflare-site-bridge`, `d3ro.chanpaca.net/*` → Pages 프록시, `npx wrangler deploy`)를 배포 → 라이브 확인: `/`·`/privacy/`·`/terms/`·`/delete-account/` 200, 라이브 번들이 설치 파일명을 `1.3.7`로 계산, `/download.html` → `/#download`. 남은 정리 2건: (1) 대시보드에 CNAME을 추가한 뒤 브리지 워커 삭제, (2) CI 자동 게시를 위해 `CF_API_TOKEN`(Pages/Workers Edit) + `CF_ACCOUNT_ID`=`8e83cc130e7329c160cf2b88d6b4c20a`를 Forgejo 시크릿에 등록. **2026-09-23:** CI `deploy`(deploy-site.yml)는 `CF_API_TOKEN` 부재로 run#71까지 여전히 실패한다. 로컬 인증 wrangler(pages:write)로 `npm run build --prefix site` → release-identity 작성 → `npx wrangler pages deploy site/dist --project-name d3ro --branch main`을 수동 배포했고, 라이브 `https://d3ro.chanpaca.net/release-identity.json`이 commit `5c11ee2` / version `1.5.0`을, 라이브 번들이 설치 파일명 `D3RO-Voice-Setup-1.5.0-x64.exe`를 보고한다. 자동 게시에는 여전히 시크릿 등록이 필요하다. |
|
||||||
| GAP-BILL-01 | Billing | 사이트 가격과 서버 청구 금액·결제 진입 URL이 제각각이었다. | `packages/core/src/plan-catalog.ts`, `packages/core/src/web-urls.ts`, `scripts/ci/sync-core-contract.mjs` | `[x]` 2026-09-26 (Wave 3, 88f24d8·b6fe588): 가격·쿼터·공개 URL 정본을 core 두 파일로 합치고 Deno는 생성 사본(`contract:check`). Payple 함수 4종+billing-catalog 운영 재배포(payple-* v9, billing-catalog v5) → 신규·갱신 모두 ₩2,900/₩8,900. 결제 진입은 `/app/billing` 하나. |
|
| GAP-BILL-01 | Billing | 사이트 가격과 서버 청구 금액·결제 진입 URL이 제각각이었다. | `packages/core/src/plan-catalog.ts`, `packages/core/src/web-urls.ts`, `scripts/ci/sync-core-contract.mjs` | `[x]` 2026-09-26 (Wave 3, 88f24d8·b6fe588): 가격·쿼터·공개 URL 정본을 core 두 파일로 합치고 Deno는 생성 사본(`contract:check`). Payple 함수 4종+billing-catalog 운영 재배포(payple-* v9, billing-catalog v5) → 신규·갱신 모두 ₩2,900/₩8,900. 결제 진입은 `/app/billing` 하나. |
|
||||||
| GAP-BILL-02 | Billing | **Payple 정기 갱신이 한 번도 실행되지 않았다.** 갱신 cron(`payple-renew`)이 `.github/workflows`에만 있었는데 GitHub 원격이 없다. 웹 Payple 결제용 `NEXT_PUBLIC_PAYPLE_CLIENT_KEY`는 설정된 적이 없다. | `.forgejo/workflows/payple-renew.yml`, `apps/web/src/components/billing/payple-checkout-button.tsx`, `apps/web/Dockerfile` | `[~]` 2026-09-26: 워크플로 Forgejo 이식(dc43884), `CRON_SECRET` 새로 발급해 Supabase 함수 시크릿·Forgejo 시크릿에 등록, Forgejo `SUPABASE_URL` 등록 — main에 push되면 스케줄이 등록된다. **백로그(사용자 결정):** 웹 Payple 클라이언트 키는 아직 넣지 않는다. Stripe는 쓰지 않고, Google Play 가격은 스토어가 정본이라 사이트에 표시하지 않는다. |
|
| GAP-BILL-02 | Billing | **Payple 정기 갱신이 한 번도 실행되지 않았다.** 갱신 cron(`payple-renew`)이 `.github/workflows`에만 있었는데 GitHub 원격이 없다. 웹 Payple 결제용 `NEXT_PUBLIC_PAYPLE_CLIENT_KEY`는 설정된 적이 없다. | `.forgejo/workflows/payple-renew.yml`, `apps/web/src/components/billing/payple-checkout-button.tsx`, `apps/web/Dockerfile` | `[~]` 2026-09-26: 워크플로 Forgejo 이식(dc43884), `CRON_SECRET` 새로 발급해 Supabase 함수 시크릿·Forgejo 시크릿에 등록, Forgejo `SUPABASE_URL` 등록 — main에 push되면 스케줄이 등록된다. **백로그(사용자 결정):** 웹 Payple 클라이언트 키는 아직 넣지 않는다. Stripe는 쓰지 않고(2026-09-26 저장소에서 제거, GAP-BILL-03), Google Play 가격은 스토어가 정본이라 사이트에 표시하지 않는다. |
|
||||||
|
| GAP-BILL-03 | Billing | **Stripe 결제 제거 — 운영 잔여물 정리.** 사용자 결정(2026-09-26)으로 저장소에서 Stripe 코드·설정·테스트를 모두 지웠다(함수 3종, 웹 checkout/portal 버튼, `billing-catalog`의 Stripe 가격 조회, 데스크톱 `CheckoutModal`·`payment:*` IPC, `billingUrl()`의 `success`/`canceled` 복귀 쿼리). 배포된 Supabase 함수와 시크릿은 아직 운영에 남아 있다. 과거 `provider='stripe'` 행·`stripe_*` 컬럼·마이그레이션은 이력 호환으로 유지한다. | `server/supabase/config.toml`, `server/supabase/functions/billing-catalog/index.ts`, `apps/web/src/lib/billing-catalog.ts` | `[ ]` 운영: `supabase functions delete stripe-checkout`·`stripe-portal`·`stripe-webhook`, 시크릿 `STRIPE_SECRET_KEY`·`STRIPE_WEBHOOK_SECRET`·`STRIPE_PRICE_PRO`·`STRIPE_PRICE_PRO_PLUS`·`STRIPE_PRICE_TEAM` unset, Stripe 대시보드 웹훅 엔드포인트 비활성화. `billing-catalog` 재배포는 웹앱 배포보다 먼저(웹 파서가 Stripe 가격을 거부). |
|
||||||
| GAP-WEB-01 | Web | 웹앱(`apps/web`)이 공개되지 않았다(배포 경로가 없었음). | `apps/web/Dockerfile`, `docker-compose.nas.yml`, `server/cloudflare-site-bridge` | `[x]` 2026-09-26: `/app` basePath·NAS `d3ro_voice_web`(3002). 터널 `kd-nas` ingress에 `d3ro.chanpaca.net` path `^/app` → NAS 3002를 추가하고 브리지 워커가 `/app`·`/api`·`/health`를 도메인 원본(터널)으로 흘린다(4aadee8). Supabase `site_url`을 `https://d3ro.chanpaca.net/app`으로(기존 localhost:3000), 허용 목록에 `/app/**` 추가. 라이브 `/app/login` 200. 같은 수정으로 9/19부터 끊겨 있던 API(`/api`, 관리자 로그인·stt-proxy)도 복구. |
|
| GAP-WEB-01 | Web | 웹앱(`apps/web`)이 공개되지 않았다(배포 경로가 없었음). | `apps/web/Dockerfile`, `docker-compose.nas.yml`, `server/cloudflare-site-bridge` | `[x]` 2026-09-26: `/app` basePath·NAS `d3ro_voice_web`(3002). 터널 `kd-nas` ingress에 `d3ro.chanpaca.net` path `^/app` → NAS 3002를 추가하고 브리지 워커가 `/app`·`/api`·`/health`를 도메인 원본(터널)으로 흘린다(4aadee8). Supabase `site_url`을 `https://d3ro.chanpaca.net/app`으로(기존 localhost:3000), 허용 목록에 `/app/**` 추가. 라이브 `/app/login` 200. 같은 수정으로 9/19부터 끊겨 있던 API(`/api`, 관리자 로그인·stt-proxy)도 복구. |
|
||||||
| GAP-OPS-01 | Ops | NAS 운영 compose가 저장소와 어긋나 있었다(JWT 기본값 폴백, 법률 wwwroot 마운트, 필수 변수 4개 누락). | `docker-compose.nas.yml`, NAS `/volume1/docker/d3ro/.env` | `[x]` 2026-09-26: NAS `.env`에 `SUPABASE_URL`·`SUPABASE_SERVICE_ROLE_KEY`(운영 service_role)·`ADMIN_BOOTSTRAP_TOKEN`(신규)·`API_SERVER_URL` 추가, 저장소 compose로 교체 후 `docker compose up -d`. 4개 컨테이너 정상, 백업 `*.before-wave3-20260926`. |
|
| GAP-OPS-01 | Ops | NAS 운영 compose가 저장소와 어긋나 있었다(JWT 기본값 폴백, 법률 wwwroot 마운트, 필수 변수 4개 누락). | `docker-compose.nas.yml`, NAS `/volume1/docker/d3ro/.env` | `[x]` 2026-09-26: NAS `.env`에 `SUPABASE_URL`·`SUPABASE_SERVICE_ROLE_KEY`(운영 service_role)·`ADMIN_BOOTSTRAP_TOKEN`(신규)·`API_SERVER_URL` 추가, 저장소 compose로 교체 후 `docker compose up -d`. 4개 컨테이너 정상, 백업 `*.before-wave3-20260926`. |
|
||||||
| GAP-CI-01 | CI | macOS 빌드·서명 러너가 없다. `.github`의 build-mac·release-signing-ca는 실행된 적 없이 삭제됐다. | `.forgejo/workflows/*`, `.gitlab-ci.yml` | `[!]` EXT: Mac 호스트에 Forgejo runner(`macos` 라벨)를 붙이거나 GitLab `package-macos` 사용. 서명된 Android 릴리스는 GitLab `mobile-production-release`가 정본. |
|
| GAP-CI-01 | CI | macOS 빌드·서명 러너가 없다. `.github`의 build-mac·release-signing-ca는 실행된 적 없이 삭제됐다. | `.forgejo/workflows/*`, `.gitlab-ci.yml` | `[!]` EXT: Mac 호스트에 Forgejo runner(`macos` 라벨)를 붙이거나 GitLab `package-macos` 사용. 서명된 Android 릴리스는 GitLab `mobile-production-release`가 정본. |
|
||||||
|
|
@ -125,7 +126,7 @@ These are the mobile SSOT rows still `[ ]` / `[~]`. Do not duplicate the full te
|
||||||
| EXT-APPSIGN-01 | Live App Links still old certificate | Deploy updated `assetlinks.json`, re-verify live | SSOT App Links row |
|
| EXT-APPSIGN-01 | Live App Links still old certificate | Deploy updated `assetlinks.json`, re-verify live | SSOT App Links row |
|
||||||
| EXT-PHYS-01 | Physical Fold6 install/OAuth/purchase evidence | User runs the artifact on device | SSOT EXT-009 |
|
| EXT-PHYS-01 | Physical Fold6 install/OAuth/purchase evidence | User runs the artifact on device | SSOT EXT-009 |
|
||||||
| EXT-PAY-01 | Payple live history + webhook signature verification | Provider contract + signature scheme | SSOT M-027 |
|
| EXT-PAY-01 | Payple live history + webhook signature verification | Provider contract + signature scheme | SSOT M-027 |
|
||||||
| EXT-STRIPE-01 | Production Stripe/Payple cross-verification | Live payment E2E | SSOT M-013 |
|
| EXT-STRIPE-01 | ~~Production Stripe/Payple cross-verification~~ | **삭제됨 2026-09-26** — Stripe 제거. Payple 라이브 검증은 EXT-PAY-01 | SSOT M-013 |
|
||||||
| EXT-STT-01 | Production provider keys (Groq/OpenAI/Deepgram/Gemini) | Inject provider secrets | `apps/api-server/Program.cs` env docs |
|
| EXT-STT-01 | Production provider keys (Groq/OpenAI/Deepgram/Gemini) | Inject provider secrets | `apps/api-server/Program.cs` env docs |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
| AdMob 배너/리워드 광고 | production 앱/배너/리워드 단위 발급 완료. 현재 TEST-DEMO 앱 산출물은 계속 Google test ID 사용 |
|
| AdMob 배너/리워드 광고 | production 앱/배너/리워드 단위 발급 완료. 현재 TEST-DEMO 앱 산출물은 계속 Google test ID 사용 |
|
||||||
| SSV 리워드 검증 (엣지함수) | **AdMob URL 확인·저장, Edge v13, DB receipt replay barrier 운영 배포 완료** |
|
| SSV 리워드 검증 (엣지함수) | **AdMob URL 확인·저장, Edge v13, DB receipt replay barrier 운영 배포 완료** |
|
||||||
| 리워드→쿼터 지급 체인 | 리워드 시청 = 전사/대화 쿼터 충전 (무료 티어 수익화 핵심) |
|
| 리워드→쿼터 지급 체인 | 리워드 시청 = 전사/대화 쿼터 충전 (무료 티어 수익화 핵심) |
|
||||||
| PRO 구독 (Stripe/Payple) | 결제 인프라 존재 (admin 구독 관리 페이지) |
|
| PRO 구독 (웹 Payple · 모바일 Google Play) | 결제 인프라 존재 (admin 구독 관리 페이지) |
|
||||||
| 관리자 콘솔 | 광고/릴리스/구독/정산 페이지 통합 배포 완료 |
|
| 관리자 콘솔 | 광고/릴리스/구독/정산 페이지 통합 배포 완료 |
|
||||||
| 광고 정산 원장 | eCPM/원천세/KRW 지급 계약 구현 (계열 F) |
|
| 광고 정산 원장 | eCPM/원천세/KRW 지급 계약 구현 (계열 F) |
|
||||||
|
|
||||||
|
|
@ -40,7 +40,7 @@ AdMob 지급 프로필은 아직 미완료다. 스토어 등록정보 연결과
|
||||||
```
|
```
|
||||||
무료 사용자 ─ 배너 노출 ──────────────→ AdMob 수입 (eCPM 집계: 계열 F)
|
무료 사용자 ─ 배너 노출 ──────────────→ AdMob 수입 (eCPM 집계: 계열 F)
|
||||||
└ 리워드 시청 → SSV 검증(리플레이 방어) → 쿼터 지급
|
└ 리워드 시청 → SSV 검증(리플레이 방어) → 쿼터 지급
|
||||||
유료 전환 ─ PRO 구독 (Stripe/Payple) → 광고 제거 + 무제한
|
유료 전환 ─ PRO 구독 (웹 Payple / 모바일 Google Play) → 광고 제거 + 무제한
|
||||||
```
|
```
|
||||||
|
|
||||||
- 지급: `AdSettlementRecord` 원장 (원천세 3.3% 가정, KRW 환산, 계좌이체 기본)
|
- 지급: `AdSettlementRecord` 원장 (원천세 3.3% 가정, KRW 환산, 계좌이체 기본)
|
||||||
|
|
|
||||||
|
|
@ -277,7 +277,7 @@
|
||||||
- [x] M-010 구매 복원은 실제 스토어 조회+서버 검증 후에만 성공 표시
|
- [x] M-010 구매 복원은 실제 스토어 조회+서버 검증 후에만 성공 표시
|
||||||
- [x] M-011 구독 관리 버튼을 Google Play 구독 관리 화면으로 연결
|
- [x] M-011 구독 관리 버튼을 Google Play 구독 관리 화면으로 연결
|
||||||
- [x] M-012 현재 플랜·만료/갱신일·결제 provider·사용량·결제 내역 표시
|
- [x] M-012 현재 플랜·만료/갱신일·결제 provider·사용량·결제 내역 표시
|
||||||
- [~] M-013 가격·기능표를 서버/스토어 SSOT로 통일하고 hardcoded 가짜 가격 제거 — Web billing catalog·desktop checkout server readback·모바일 Play product 가격 사용, production Play/Payple/Stripe 교차 검증 대기
|
- [~] M-013 가격·기능표를 서버/스토어 SSOT로 통일하고 hardcoded 가짜 가격 제거 — Web billing catalog·desktop checkout server readback·모바일 Play product 가격 사용, production Play/Payple 교차 검증 대기 (Stripe는 2026-09-26 제거)
|
||||||
- [x] M-014 Google Mobile Ads SDK와 test/prod app ID·unit ID 분리
|
- [x] M-014 Google Mobile Ads SDK와 test/prod app ID·unit ID 분리
|
||||||
- [x] M-015 Free entitlement에만 adaptive banner를 안정적인 앱 레이아웃 영역에 표시
|
- [x] M-015 Free entitlement에만 adaptive banner를 안정적인 앱 레이아웃 영역에 표시
|
||||||
- [x] M-016 Pro/Pro+ entitlement에서는 광고 request 자체를 하지 않음
|
- [x] M-016 Pro/Pro+ entitlement에서는 광고 request 자체를 하지 않음
|
||||||
|
|
@ -387,7 +387,7 @@
|
||||||
| Ads test | Free Google test banner→rewarded 완주 + production 콘솔/SSV 연결 | 공식 `Test Ad` banner/`AdActivity`/`Reward granted` 실측과 미구성 시 fail-closed를 유지한다. production ID, `50 cloud_ai_tokens`, secret, verified SSV URL, Edge v13, receipt exactly-once는 운영 반영했다. production 광고를 클릭하지 않았으며 test-device 실제 Google-signed reward E2E는 남음 | `scratch/demo/d3ro-mobile-test-ad.png`, `d3ro-mobile-rewarded-test-ad.png`, `server/supabase/functions/admob-ssv`, AdMob live console | 2026-08-24 |
|
| Ads test | Free Google test banner→rewarded 완주 + production 콘솔/SSV 연결 | 공식 `Test Ad` banner/`AdActivity`/`Reward granted` 실측과 미구성 시 fail-closed를 유지한다. production ID, `50 cloud_ai_tokens`, secret, verified SSV URL, Edge v13, receipt exactly-once는 운영 반영했다. production 광고를 클릭하지 않았으며 test-device 실제 Google-signed reward E2E는 남음 | `scratch/demo/d3ro-mobile-test-ad.png`, `d3ro-mobile-rewarded-test-ad.png`, `server/supabase/functions/admob-ssv`, AdMob live console | 2026-08-24 |
|
||||||
| Admin live-local | login→JWT proxy→role/subscription/audit + stale-role | .NET 21/21, migration 25, Edge/JWT 47 assertions, TLS Chromium console/5xx 0 | `scratch/admin-browser-e2e.png` | 2026-08-21 |
|
| Admin live-local | login→JWT proxy→role/subscription/audit + stale-role | .NET 21/21, migration 25, Edge/JWT 47 assertions, TLS Chromium console/5xx 0 | `scratch/admin-browser-e2e.png` | 2026-08-21 |
|
||||||
| STT fail-closed quota | Deno/.NET contracts + 00026 SQL/REST concurrency | provider 전체 실패 502/503, direct legacy user 410, internal exact service token only. 20 concurrent remaining-base 요청에서 exactly 1 reserve, replay single charge, 3 overage exact, provider failure refund, authenticated ledger/RPC denial, fixture 0 | `server/supabase/functions/stt-proxy`, `apps/api-server/Controllers/SttController.cs`, `server/supabase/migrations/20260821000026_stt_quota_reservations.sql`, `apps/mobile-rn/__tests__/stt-quota.local.integration.mjs` | 2026-08-21 |
|
| STT fail-closed quota | Deno/.NET contracts + 00026 SQL/REST concurrency | provider 전체 실패 502/503, direct legacy user 410, internal exact service token only. 20 concurrent remaining-base 요청에서 exactly 1 reserve, replay single charge, 3 overage exact, provider failure refund, authenticated ledger/RPC denial, fixture 0 | `server/supabase/functions/stt-proxy`, `apps/api-server/Controllers/SttController.cs`, `server/supabase/migrations/20260821000026_stt_quota_reservations.sql`, `apps/mobile-rn/__tests__/stt-quota.local.integration.mjs` | 2026-08-21 |
|
||||||
| Desktop payment·ads | authenticated Edge-only checkout/subscription readback + no-fill·위조 event·reward·settlement 경계 | 임의 provider/tier/URL, 무조건 성공 verify, legacy local license 우회를 제거하고 timeout·retry idempotency를 fail-closed로 검증. 광고 20/20, 전체 desktop 590 tests, typecheck/build/scoped lint GREEN; 실제 production provider 결제 E2E는 외부 게이트 | `apps/desktop/src/main/ipc/payment-handlers.ts`, `apps/desktop/tests/unit/payment-handlers.spec.ts`, `scratch/demo/d3ro-desktop-ads-fail-closed.png` | 2026-08-21 |
|
| Desktop payment·ads | authenticated Edge-only checkout/subscription readback + no-fill·위조 event·reward·settlement 경계 | 임의 provider/tier/URL, 무조건 성공 verify, legacy local license 우회를 제거하고 timeout·retry idempotency를 fail-closed로 검증. 광고 20/20, 전체 desktop 590 tests, typecheck/build/scoped lint GREEN; 실제 production provider 결제 E2E는 외부 게이트 | `apps/desktop/src/main/ipc/payment-handlers.ts`, `apps/desktop/tests/unit/payment-handlers.spec.ts`, `scratch/demo/d3ro-desktop-ads-fail-closed.png` (2026-09-26: Stripe 제거로 두 파일 삭제, 데스크톱 업그레이드는 `LICENSE.OPEN_BILLING` → `billingUrl()` 웹 Payple) | 2026-08-21 |
|
||||||
| Credential gate | repository runtime secret scan + release-keystore audit + API fail-closed bootstrap | 평문 keystore password를 포함하던 untracked 생성 스크립트를 발견해 환경변수·PKCS12·create-only로 교체하고 scanner에 keytool literal negative case 추가. 노출된 키는 live direct APK cert `06:EE:…:E4:81`과 동일해 `d3ro-release-key.COMPROMISED-20260821.keystore`로 보존 격리했으며 active path에서 제거. scanner self-test/전체 scan, .NET 26/26, script syntax GREEN | `scripts/gen-keystore.js`, `scripts/ci/check-no-hardcoded-secrets.mjs`, `.github/workflows/ci.yml` | 2026-08-21 |
|
| Credential gate | repository runtime secret scan + release-keystore audit + API fail-closed bootstrap | 평문 keystore password를 포함하던 untracked 생성 스크립트를 발견해 환경변수·PKCS12·create-only로 교체하고 scanner에 keytool literal negative case 추가. 노출된 키는 live direct APK cert `06:EE:…:E4:81`과 동일해 `d3ro-release-key.COMPROMISED-20260821.keystore`로 보존 격리했으며 active path에서 제거. scanner self-test/전체 scan, .NET 26/26, script syntax GREEN | `scripts/gen-keystore.js`, `scripts/ci/check-no-hardcoded-secrets.mjs`, `.github/workflows/ci.yml` | 2026-08-21 |
|
||||||
| Exact-APK cold launch | fresh uninstall/install→`am start -W`→top activity/logcat | GREEN; exact `.11` SHA-256 `74035B69…380C2B` fresh install, `LaunchState:COLD`, total 0.872초, `MainActivity` top-resumed. fresh auth·signup·invite PASS, fatal/ANR/React fatal 0 | API 34 `emulator-5556`, installed package inspection + Maestro + logcat | 2026-08-21 |
|
| Exact-APK cold launch | fresh uninstall/install→`am start -W`→top activity/logcat | GREEN; exact `.11` SHA-256 `74035B69…380C2B` fresh install, `LaunchState:COLD`, total 0.872초, `MainActivity` top-resumed. fresh auth·signup·invite PASS, fatal/ANR/React fatal 0 | API 34 `emulator-5556`, installed package inspection + Maestro + logcat | 2026-08-21 |
|
||||||
| Exact-APK Maestro E2E | exact `.11` TEST-DEMO fresh install + auth/signup/invite, 직전 `.9` small-screen journey | `.11` GREEN: fresh-install-auth 30s, fresh-install-signup 40s, invite warm listener+cold `getInitialURL` 14s. 동일 UI `.9`의 320x568dp signup 2m39s PASS. `.11` 전체 구간 fatal/ANR/React fatal 0 | `apps/mobile-rn/.maestro/*.v11.junit.xml`, `apps/mobile-rn/.maestro-output`, `apps/mobile-rn/.maestro-output/final-20260821-9` | 2026-08-21 |
|
| Exact-APK Maestro E2E | exact `.11` TEST-DEMO fresh install + auth/signup/invite, 직전 `.9` small-screen journey | `.11` GREEN: fresh-install-auth 30s, fresh-install-signup 40s, invite warm listener+cold `getInitialURL` 14s. 동일 UI `.9`의 320x568dp signup 2m39s PASS. `.11` 전체 구간 fatal/ANR/React fatal 0 | `apps/mobile-rn/.maestro/*.v11.junit.xml`, `apps/mobile-rn/.maestro-output`, `apps/mobile-rn/.maestro-output/final-20260821-9` | 2026-08-21 |
|
||||||
|
|
|
||||||
|
|
@ -339,10 +339,11 @@ export type Subscription = {
|
||||||
tier: SubscriptionTier
|
tier: SubscriptionTier
|
||||||
/** Phase 3.2: 베이스 쿼터 소진 시 차감되는 추가 크레딧 */
|
/** Phase 3.2: 베이스 쿼터 소진 시 차감되는 추가 크레딧 */
|
||||||
overage_credits: number
|
overage_credits: number
|
||||||
/** @deprecated Phase 3.2-B (Payple 이관 예정) */
|
/** @deprecated Stripe 결제 제거(2026-09-26). 과거 행을 읽기 위한 DB 컬럼일 뿐 새로 쓰지 않는다. */
|
||||||
stripe_customer_id: string | null
|
stripe_customer_id: string | null
|
||||||
/** @deprecated Phase 3.2-B (Payple 이관 예정) */
|
/** @deprecated Stripe 결제 제거(2026-09-26). 과거 행을 읽기 위한 DB 컬럼일 뿐 새로 쓰지 않는다. */
|
||||||
stripe_subscription_id: string | null
|
stripe_subscription_id: string | null
|
||||||
|
/** 'stripe' 는 과거 구독 행 호환용. 새 결제는 'payple'(웹) · 'google_play'(모바일)뿐이다. */
|
||||||
payment_provider: 'none' | 'stripe' | 'payple' | 'google_play' | 'app_store'
|
payment_provider: 'none' | 'stripe' | 'payple' | 'google_play' | 'app_store'
|
||||||
payple_payer_id: string | null
|
payple_payer_id: string | null
|
||||||
payple_pay_oid: string | null
|
payple_pay_oid: string | null
|
||||||
|
|
|
||||||
|
|
@ -527,14 +527,6 @@ export const IPC_CHANNELS = {
|
||||||
CHECK_REFUND: 'support:checkRefund',
|
CHECK_REFUND: 'support:checkRefund',
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Multi-PG Payment & Billing ──
|
|
||||||
PAYMENT: {
|
|
||||||
CREATE_CHECKOUT_SESSION: 'payment:createCheckoutSession',
|
|
||||||
VERIFY_PAYMENT: 'payment:verifyPayment',
|
|
||||||
GET_SUBSCRIPTION_STATUS: 'payment:getSubscriptionStatus',
|
|
||||||
CANCEL_SUBSCRIPTION: 'payment:cancelSubscription',
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── Input telemetry (수집·동의·리포트) ──
|
// ── Input telemetry (수집·동의·리포트) ──
|
||||||
INPUT_TELEMETRY: {
|
INPUT_TELEMETRY: {
|
||||||
GET_STATE: 'inputTelemetry:getState',
|
GET_STATE: 'inputTelemetry:getState',
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ export type PlanTier = 'free' | PaidPlanTier
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 월 요금(원). Payple 청구 금액이며, 기존 구독자도 다음 갱신부터 이 값으로 청구된다.
|
* 월 요금(원). Payple 청구 금액이며, 기존 구독자도 다음 갱신부터 이 값으로 청구된다.
|
||||||
* Stripe(USD)·Google Play 가격은 각 콘솔에서 따로 관리한다.
|
* Google Play 가격은 Play Console에서 따로 관리한다. 웹 결제는 Payple(KRW) 하나다.
|
||||||
*/
|
*/
|
||||||
export const PLAN_PRICE_KRW: Readonly<Record<PlanTier, number>> = {
|
export const PLAN_PRICE_KRW: Readonly<Record<PlanTier, number>> = {
|
||||||
free: 0,
|
free: 0,
|
||||||
|
|
|
||||||
|
|
@ -1841,7 +1841,7 @@ export interface AdSettlementRecord {
|
||||||
exchangeRateKrw: number // e.g. 1350
|
exchangeRateKrw: number // e.g. 1350
|
||||||
netPayoutKrw: number
|
netPayoutKrw: number
|
||||||
payoutStatus: 'pending' | 'processing' | 'settled' | 'paid'
|
payoutStatus: 'pending' | 'processing' | 'settled' | 'paid'
|
||||||
paymentMethod: 'bank_wire_krw' | 'paypal' | 'stripe_connect'
|
paymentMethod: 'bank_wire_krw' | 'paypal'
|
||||||
beneficiaryAccount: string
|
beneficiaryAccount: string
|
||||||
settledAt?: number
|
settledAt?: number
|
||||||
invoiceNumber?: string
|
invoiceNumber?: string
|
||||||
|
|
@ -1946,32 +1946,3 @@ export interface RefundEligibilityResult {
|
||||||
refundableAmount: number
|
refundableAmount: number
|
||||||
currency: string
|
currency: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// Phase 18: Multi-PG Billing & Checkout
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
export type PaymentGatewayProvider = 'stripe'
|
|
||||||
export type CheckoutTier = 'pro' | 'pro_plus'
|
|
||||||
|
|
||||||
export interface CheckoutSessionParams {
|
|
||||||
tier: CheckoutTier
|
|
||||||
provider: PaymentGatewayProvider
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CheckoutSessionResult {
|
|
||||||
checkoutUrl: string
|
|
||||||
provider: 'stripe'
|
|
||||||
status: 'pending'
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface VerifyPaymentResult {
|
|
||||||
success: boolean
|
|
||||||
activeTier: 'free' | CheckoutTier
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SubscriptionStatusResult {
|
|
||||||
tier: 'free' | CheckoutTier
|
|
||||||
valid: boolean
|
|
||||||
expiresAt: number | null
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -31,19 +31,13 @@ export const SITE_URLS = {
|
||||||
acceptInvite: `${PUBLIC_SITE_ORIGIN}/accept-invite/`,
|
acceptInvite: `${PUBLIC_SITE_ORIGIN}/accept-invite/`,
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
/** 결제 결과로 돌아올 때 붙는 쿼리. apps/web 결제 페이지가 읽는 이름과 같다. */
|
|
||||||
export type BillingReturn = 'success' | 'canceled'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 웹 결제 페이지 URL.
|
* 웹 결제 페이지 URL. 결제는 이 페이지 안의 Payple 창에서 끝나므로 복귀 쿼리가 없다.
|
||||||
* - `tier`: 고를 요금제를 미리 선택한다.
|
* - `tier`: 고를 요금제를 미리 선택한다.
|
||||||
* - `result`: 외부 결제(Stripe 등)에서 돌아올 때의 결과.
|
|
||||||
*/
|
*/
|
||||||
export function billingUrl(options: { tier?: PaidPlanTier; result?: BillingReturn } = {}): string {
|
export function billingUrl(options: { tier?: PaidPlanTier } = {}): string {
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
if (options.tier) params.set('tier', options.tier)
|
if (options.tier) params.set('tier', options.tier)
|
||||||
if (options.result === 'success') params.set('success', '1')
|
|
||||||
if (options.result === 'canceled') params.set('canceled', '1')
|
|
||||||
const query = params.toString()
|
const query = params.toString()
|
||||||
return `${WEB_APP_URL}/billing${query ? `?${query}` : ''}`
|
return `${WEB_APP_URL}/billing${query ? `?${query}` : ''}`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -202,22 +202,9 @@ async function capture() {
|
||||||
await page.waitForTimeout(400);
|
await page.waitForTimeout(400);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Click "Remove ads with Pro" on banner to show CheckoutModal
|
|
||||||
console.log('Capturing Screenshot 4: Checkout Modal...');
|
|
||||||
const removeAdsLink = page.locator('text=/Remove ads with Pro/').first();
|
|
||||||
if (await removeAdsLink.isVisible()) {
|
|
||||||
await removeAdsLink.click();
|
|
||||||
await page.waitForTimeout(800);
|
|
||||||
await page.screenshot({
|
|
||||||
path: path.join(SCREENSHOT_DIR, '14_actual_desktop_checkout_modal.png'),
|
|
||||||
});
|
|
||||||
await page.keyboard.press('Escape');
|
|
||||||
await page.waitForTimeout(400);
|
|
||||||
}
|
|
||||||
|
|
||||||
await browser.close();
|
await browser.close();
|
||||||
server.close();
|
server.close();
|
||||||
console.log('All 4 actual desktop app screenshots captured successfully!');
|
console.log('All 3 actual desktop app screenshots captured successfully!');
|
||||||
}
|
}
|
||||||
|
|
||||||
capture().catch((err) => {
|
capture().catch((err) => {
|
||||||
|
|
|
||||||
|
|
@ -151,8 +151,8 @@ async function validateContract(source) {
|
||||||
for (const [key, url] of Object.entries(contract.SITE_URLS ?? {})) {
|
for (const [key, url] of Object.entries(contract.SITE_URLS ?? {})) {
|
||||||
if (!String(url).startsWith(`${contract.PUBLIC_SITE_ORIGIN}/`)) fail(`SITE_URLS.${key} must live under PUBLIC_SITE_ORIGIN.`)
|
if (!String(url).startsWith(`${contract.PUBLIC_SITE_ORIGIN}/`)) fail(`SITE_URLS.${key} must live under PUBLIC_SITE_ORIGIN.`)
|
||||||
}
|
}
|
||||||
const billing = contract.billingUrl({ tier: 'pro', result: 'success' })
|
const billing = contract.billingUrl({ tier: 'pro' })
|
||||||
if (billing !== `${contract.WEB_APP_URL}/billing?tier=pro&success=1`) {
|
if (billing !== `${contract.WEB_APP_URL}/billing?tier=pro`) {
|
||||||
fail(`billingUrl() contract changed unexpectedly: ${billing}`)
|
fail(`billingUrl() contract changed unexpectedly: ${billing}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -102,19 +102,10 @@ verify_jwt = false
|
||||||
# 2026 sb_publishable_ 키와 Gateway JWT 검증 비호환 — requireUser()에서 직접 인증
|
# 2026 sb_publishable_ 키와 Gateway JWT 검증 비호환 — requireUser()에서 직접 인증
|
||||||
verify_jwt = false
|
verify_jwt = false
|
||||||
|
|
||||||
[functions.stripe-checkout]
|
|
||||||
verify_jwt = true
|
|
||||||
|
|
||||||
[functions.billing-catalog]
|
[functions.billing-catalog]
|
||||||
# requireUser supports modern publishable keys and validates the access token.
|
# requireUser supports modern publishable keys and validates the access token.
|
||||||
verify_jwt = false
|
verify_jwt = false
|
||||||
|
|
||||||
[functions.stripe-portal]
|
|
||||||
verify_jwt = true
|
|
||||||
|
|
||||||
[functions.stripe-webhook]
|
|
||||||
verify_jwt = false
|
|
||||||
|
|
||||||
[functions.payple-checkout]
|
[functions.payple-checkout]
|
||||||
verify_jwt = false
|
verify_jwt = false
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,65 +1,33 @@
|
||||||
import {
|
import { createBillingCatalog } from './billing-catalog.ts'
|
||||||
createBillingCatalog,
|
|
||||||
parseStripeCatalogPrice,
|
|
||||||
} from './billing-catalog.ts'
|
|
||||||
import { PLAN_PRICE_KRW } from './core-contract.generated.ts'
|
import { PLAN_PRICE_KRW } from './core-contract.generated.ts'
|
||||||
|
|
||||||
function assert(condition: unknown, message: string): asserts condition {
|
function assert(condition: unknown, message: string): asserts condition {
|
||||||
if (!condition) throw new Error(message)
|
if (!condition) throw new Error(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
Deno.test('Stripe catalog accepts only the exact active recurring price', () => {
|
Deno.test('catalog exposes only configured Payple prices and never invents a fallback', () => {
|
||||||
const parsed = parseStripeCatalogPrice({
|
|
||||||
id: 'price_pro',
|
|
||||||
active: true,
|
|
||||||
type: 'recurring',
|
|
||||||
unit_amount: 990,
|
|
||||||
currency: 'usd',
|
|
||||||
recurring: { interval: 'month', interval_count: 1 },
|
|
||||||
}, 'price_pro')
|
|
||||||
assert(parsed?.unit_amount === 990, 'amount must be preserved')
|
|
||||||
assert(parsed?.currency === 'USD', 'currency must be normalized')
|
|
||||||
assert(parsed?.interval === 'month', 'interval must be preserved')
|
|
||||||
})
|
|
||||||
|
|
||||||
Deno.test('Stripe catalog rejects mismatched, inactive, free, malformed, and one-time prices', () => {
|
|
||||||
const base = {
|
|
||||||
id: 'price_pro',
|
|
||||||
active: true,
|
|
||||||
type: 'recurring',
|
|
||||||
unit_amount: 990,
|
|
||||||
currency: 'usd',
|
|
||||||
recurring: { interval: 'month', interval_count: 1 },
|
|
||||||
}
|
|
||||||
const invalid = [
|
|
||||||
{ ...base, id: 'price_other' },
|
|
||||||
{ ...base, active: false },
|
|
||||||
{ ...base, type: 'one_time', recurring: null },
|
|
||||||
{ ...base, unit_amount: 0 },
|
|
||||||
{ ...base, currency: 'US$' },
|
|
||||||
{ ...base, recurring: { interval: 'minute', interval_count: 1 } },
|
|
||||||
{ ...base, recurring: { interval: 'month', interval_count: 0 } },
|
|
||||||
]
|
|
||||||
for (const value of invalid) {
|
|
||||||
assert(parseStripeCatalogPrice(value, 'price_pro') === null, 'invalid Stripe price must fail closed')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
Deno.test('catalog exposes only configured provider prices and never invents a fallback', () => {
|
|
||||||
const catalog = createBillingCatalog({
|
const catalog = createBillingCatalog({
|
||||||
payple: { pro: PLAN_PRICE_KRW.pro, pro_plus: PLAN_PRICE_KRW.pro_plus },
|
payple: { pro: PLAN_PRICE_KRW.pro, pro_plus: PLAN_PRICE_KRW.pro_plus },
|
||||||
stripe: {
|
|
||||||
pro: { unit_amount: 990, currency: 'USD', interval: 'month', interval_count: 1 },
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
assert(catalog.plans[0].prices.length === 2, 'pro must expose two verified providers')
|
assert(
|
||||||
assert(catalog.plans[1].prices.length === 1, 'pro plus must omit unavailable Stripe')
|
catalog.plans.every((plan) => plan.prices.length === 1 && plan.prices[0].provider === 'payple'),
|
||||||
|
'each paid plan must expose exactly one Payple price',
|
||||||
|
)
|
||||||
assert(
|
assert(
|
||||||
catalog.plans[0].prices[0].unit_amount === PLAN_PRICE_KRW.pro
|
catalog.plans[0].prices[0].unit_amount === PLAN_PRICE_KRW.pro
|
||||||
&& catalog.plans[1].prices[0].unit_amount === PLAN_PRICE_KRW.pro_plus,
|
&& catalog.plans[1].prices[0].unit_amount === PLAN_PRICE_KRW.pro_plus,
|
||||||
'Payple prices must come from the core plan catalog',
|
'Payple prices must come from the core plan catalog',
|
||||||
)
|
)
|
||||||
|
assert(
|
||||||
|
catalog.plans.every((plan) => plan.prices[0].currency === 'KRW' && plan.prices[0].interval === 'month'),
|
||||||
|
'Payple prices are monthly KRW',
|
||||||
|
)
|
||||||
|
|
||||||
const unavailable = createBillingCatalog({})
|
const unavailable = createBillingCatalog({})
|
||||||
assert(unavailable.plans.every((plan) => plan.prices.length === 0), 'missing configuration must stay unavailable')
|
assert(unavailable.plans.every((plan) => plan.prices.length === 0), 'missing configuration must stay unavailable')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
Deno.test('catalog rejects non-positive or non-integer Payple amounts', () => {
|
||||||
|
const catalog = createBillingCatalog({ payple: { pro: 0, pro_plus: 1.5 } })
|
||||||
|
assert(catalog.plans.every((plan) => plan.prices.length === 0), 'invalid amounts must fail closed')
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
export type BillingCatalogTier = 'pro' | 'pro_plus'
|
export type BillingCatalogTier = 'pro' | 'pro_plus'
|
||||||
export type BillingCatalogProvider = 'payple' | 'stripe'
|
/** 웹 결제는 Payple(KRW) 하나다. 모바일은 Google Play 스토어 가격을 쓰고 이 카탈로그에 오지 않는다. */
|
||||||
|
export type BillingCatalogProvider = 'payple'
|
||||||
|
|
||||||
export interface BillingCatalogPrice {
|
export interface BillingCatalogPrice {
|
||||||
provider: BillingCatalogProvider
|
provider: BillingCatalogProvider
|
||||||
|
|
@ -19,54 +20,8 @@ export interface BillingCatalogResponse {
|
||||||
plans: BillingCatalogPlan[]
|
plans: BillingCatalogPlan[]
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StripePriceRecord {
|
|
||||||
id?: unknown
|
|
||||||
active?: unknown
|
|
||||||
type?: unknown
|
|
||||||
unit_amount?: unknown
|
|
||||||
currency?: unknown
|
|
||||||
recurring?: {
|
|
||||||
interval?: unknown
|
|
||||||
interval_count?: unknown
|
|
||||||
} | null
|
|
||||||
}
|
|
||||||
|
|
||||||
const CURRENCY_PATTERN = /^[a-z]{3}$/
|
|
||||||
const INTERVALS = new Set(['day', 'week', 'month', 'year'])
|
|
||||||
|
|
||||||
export function parseStripeCatalogPrice(
|
|
||||||
value: unknown,
|
|
||||||
expectedId: string,
|
|
||||||
): Omit<BillingCatalogPrice, 'provider'> | null {
|
|
||||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
|
|
||||||
const price = value as StripePriceRecord
|
|
||||||
if (
|
|
||||||
price.id !== expectedId
|
|
||||||
|| price.active !== true
|
|
||||||
|| price.type !== 'recurring'
|
|
||||||
|| !Number.isSafeInteger(price.unit_amount)
|
|
||||||
|| (price.unit_amount as number) < 1
|
|
||||||
|| typeof price.currency !== 'string'
|
|
||||||
|| !CURRENCY_PATTERN.test(price.currency)
|
|
||||||
|| !price.recurring
|
|
||||||
|| typeof price.recurring.interval !== 'string'
|
|
||||||
|| !INTERVALS.has(price.recurring.interval)
|
|
||||||
|| !Number.isSafeInteger(price.recurring.interval_count)
|
|
||||||
|| (price.recurring.interval_count as number) < 1
|
|
||||||
|| (price.recurring.interval_count as number) > 12
|
|
||||||
) return null
|
|
||||||
|
|
||||||
return {
|
|
||||||
unit_amount: price.unit_amount as number,
|
|
||||||
currency: price.currency.toUpperCase(),
|
|
||||||
interval: price.recurring.interval as BillingCatalogPrice['interval'],
|
|
||||||
interval_count: price.recurring.interval_count as number,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createBillingCatalog(input: {
|
export function createBillingCatalog(input: {
|
||||||
payple?: Record<BillingCatalogTier, number> | null
|
payple?: Record<BillingCatalogTier, number> | null
|
||||||
stripe?: Partial<Record<BillingCatalogTier, Omit<BillingCatalogPrice, 'provider'>>> | null
|
|
||||||
}): BillingCatalogResponse {
|
}): BillingCatalogResponse {
|
||||||
const tiers: BillingCatalogTier[] = ['pro', 'pro_plus']
|
const tiers: BillingCatalogTier[] = ['pro', 'pro_plus']
|
||||||
return {
|
return {
|
||||||
|
|
@ -83,8 +38,6 @@ export function createBillingCatalog(input: {
|
||||||
interval_count: 1,
|
interval_count: 1,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
const stripePrice = input.stripe?.[tier]
|
|
||||||
if (stripePrice) prices.push({ provider: 'stripe', ...stripePrice })
|
|
||||||
return { tier, prices }
|
return { tier, prices }
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ export type PlanTier = 'free' | PaidPlanTier
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 월 요금(원). Payple 청구 금액이며, 기존 구독자도 다음 갱신부터 이 값으로 청구된다.
|
* 월 요금(원). Payple 청구 금액이며, 기존 구독자도 다음 갱신부터 이 값으로 청구된다.
|
||||||
* Stripe(USD)·Google Play 가격은 각 콘솔에서 따로 관리한다.
|
* Google Play 가격은 Play Console에서 따로 관리한다. 웹 결제는 Payple(KRW) 하나다.
|
||||||
*/
|
*/
|
||||||
export const PLAN_PRICE_KRW: Readonly<Record<PlanTier, number>> = {
|
export const PLAN_PRICE_KRW: Readonly<Record<PlanTier, number>> = {
|
||||||
free: 0,
|
free: 0,
|
||||||
|
|
@ -122,19 +122,13 @@ export const SITE_URLS = {
|
||||||
acceptInvite: `${PUBLIC_SITE_ORIGIN}/accept-invite/`,
|
acceptInvite: `${PUBLIC_SITE_ORIGIN}/accept-invite/`,
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
/** 결제 결과로 돌아올 때 붙는 쿼리. apps/web 결제 페이지가 읽는 이름과 같다. */
|
|
||||||
export type BillingReturn = 'success' | 'canceled'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 웹 결제 페이지 URL.
|
* 웹 결제 페이지 URL. 결제는 이 페이지 안의 Payple 창에서 끝나므로 복귀 쿼리가 없다.
|
||||||
* - `tier`: 고를 요금제를 미리 선택한다.
|
* - `tier`: 고를 요금제를 미리 선택한다.
|
||||||
* - `result`: 외부 결제(Stripe 등)에서 돌아올 때의 결과.
|
|
||||||
*/
|
*/
|
||||||
export function billingUrl(options: { tier?: PaidPlanTier; result?: BillingReturn } = {}): string {
|
export function billingUrl(options: { tier?: PaidPlanTier } = {}): string {
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
if (options.tier) params.set('tier', options.tier)
|
if (options.tier) params.set('tier', options.tier)
|
||||||
if (options.result === 'success') params.set('success', '1')
|
|
||||||
if (options.result === 'canceled') params.set('canceled', '1')
|
|
||||||
const query = params.toString()
|
const query = params.toString()
|
||||||
return `${WEB_APP_URL}/billing${query ? `?${query}` : ''}`
|
return `${WEB_APP_URL}/billing${query ? `?${query}` : ''}`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,6 @@ import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts'
|
||||||
import { requireUser, authErrorResponse, type AuthError } from '../_shared/auth.ts'
|
import { requireUser, authErrorResponse, type AuthError } from '../_shared/auth.ts'
|
||||||
import {
|
import {
|
||||||
createBillingCatalog,
|
createBillingCatalog,
|
||||||
parseStripeCatalogPrice,
|
|
||||||
type BillingCatalogPrice,
|
|
||||||
type BillingCatalogTier,
|
type BillingCatalogTier,
|
||||||
} from '../_shared/billing-catalog.ts'
|
} from '../_shared/billing-catalog.ts'
|
||||||
import {
|
import {
|
||||||
|
|
@ -23,20 +21,6 @@ function jsonResponse(body: unknown, status = 200): Response {
|
||||||
return new Response(JSON.stringify(body), { status, headers: NO_STORE_HEADERS })
|
return new Response(JSON.stringify(body), { status, headers: NO_STORE_HEADERS })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadStripePrice(
|
|
||||||
secretKey: string,
|
|
||||||
priceId: string,
|
|
||||||
): Promise<Omit<BillingCatalogPrice, 'provider'> | null> {
|
|
||||||
const response = await fetch(`https://api.stripe.com/v1/prices/${encodeURIComponent(priceId)}`, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: { Authorization: `Bearer ${secretKey}` },
|
|
||||||
signal: AbortSignal.timeout(8_000),
|
|
||||||
})
|
|
||||||
if (!response.ok) return null
|
|
||||||
const payload = await response.json().catch(() => null)
|
|
||||||
return parseStripeCatalogPrice(payload, priceId)
|
|
||||||
}
|
|
||||||
|
|
||||||
Deno.serve(async (req: Request) => {
|
Deno.serve(async (req: Request) => {
|
||||||
const preflight = handleCorsPreflightRequest(req)
|
const preflight = handleCorsPreflightRequest(req)
|
||||||
if (preflight) return preflight
|
if (preflight) return preflight
|
||||||
|
|
@ -56,24 +40,7 @@ Deno.serve(async (req: Request) => {
|
||||||
if (!(error instanceof PaypleConfigurationError)) throw error
|
if (!(error instanceof PaypleConfigurationError)) throw error
|
||||||
}
|
}
|
||||||
|
|
||||||
const stripeSecret = Deno.env.get('STRIPE_SECRET_KEY')?.trim() ?? ''
|
return jsonResponse(createBillingCatalog({ payple }))
|
||||||
const stripeIds: Record<BillingCatalogTier, string> = {
|
|
||||||
pro: Deno.env.get('STRIPE_PRICE_PRO')?.trim() ?? '',
|
|
||||||
pro_plus: (Deno.env.get('STRIPE_PRICE_PRO_PLUS')
|
|
||||||
?? Deno.env.get('STRIPE_PRICE_TEAM'))?.trim() ?? '',
|
|
||||||
}
|
|
||||||
const stripe: Partial<Record<BillingCatalogTier, Omit<BillingCatalogPrice, 'provider'>>> = {}
|
|
||||||
if (stripeSecret) {
|
|
||||||
const tiers: BillingCatalogTier[] = ['pro', 'pro_plus']
|
|
||||||
await Promise.all(tiers.map(async (tier) => {
|
|
||||||
const priceId = stripeIds[tier]
|
|
||||||
if (!priceId) return
|
|
||||||
const price = await loadStripePrice(stripeSecret, priceId).catch(() => null)
|
|
||||||
if (price) stripe[tier] = price
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
return jsonResponse(createBillingCatalog({ payple, stripe }))
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error && typeof error === 'object' && 'status' in error && 'message' in error) {
|
if (error && typeof error === 'object' && 'status' in error && 'message' in error) {
|
||||||
return authErrorResponse(error as AuthError, NO_STORE_HEADERS)
|
return authErrorResponse(error as AuthError, NO_STORE_HEADERS)
|
||||||
|
|
|
||||||
|
|
@ -1,202 +0,0 @@
|
||||||
import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts'
|
|
||||||
import { requireUser, authErrorResponse, type AuthError } from '../_shared/auth.ts'
|
|
||||||
import { createServiceRoleClient } from '../_shared/quota.ts'
|
|
||||||
|
|
||||||
interface CheckoutRequest {
|
|
||||||
tier?: unknown
|
|
||||||
success_url?: unknown
|
|
||||||
cancel_url?: unknown
|
|
||||||
idempotency_key?: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OperationReservation {
|
|
||||||
created?: boolean
|
|
||||||
operation_id?: string
|
|
||||||
state?: string
|
|
||||||
reason?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function jsonResponse(body: Record<string, unknown>, status = 200): Response {
|
|
||||||
return new Response(JSON.stringify(body), {
|
|
||||||
status,
|
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeTier(value: unknown): 'pro' | 'pro_plus' | null {
|
|
||||||
if (value === 'pro') return 'pro'
|
|
||||||
// team is accepted only as an input compatibility alias. It is never stored.
|
|
||||||
if (value === 'pro_plus' || value === 'team') return 'pro_plus'
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseReturnUrls(success: unknown, cancel: unknown): { success: string; cancel: string } | null {
|
|
||||||
if (typeof success !== 'string' || typeof cancel !== 'string') return null
|
|
||||||
try {
|
|
||||||
const successUrl = new URL(success)
|
|
||||||
const cancelUrl = new URL(cancel)
|
|
||||||
const validProtocol = (url: URL) => url.protocol === 'https:'
|
|
||||||
|| (url.protocol === 'http:' && ['localhost', '127.0.0.1'].includes(url.hostname))
|
|
||||||
if (
|
|
||||||
!validProtocol(successUrl)
|
|
||||||
|| !validProtocol(cancelUrl)
|
|
||||||
|| successUrl.origin !== cancelUrl.origin
|
|
||||||
|| successUrl.username
|
|
||||||
|| successUrl.password
|
|
||||||
|| cancelUrl.username
|
|
||||||
|| cancelUrl.password
|
|
||||||
) return null
|
|
||||||
return { success: successUrl.toString(), cancel: cancelUrl.toString() }
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function stripeHeaders(secretKey: string, idempotencyKey?: string): HeadersInit {
|
|
||||||
return {
|
|
||||||
Authorization: `Bearer ${secretKey}`,
|
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
|
||||||
...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Deno.serve(async (req: Request) => {
|
|
||||||
const preflight = handleCorsPreflightRequest(req)
|
|
||||||
if (preflight) return preflight
|
|
||||||
if (req.method !== 'POST') return jsonResponse({ error: 'method_not_allowed' }, 405)
|
|
||||||
|
|
||||||
const serviceClient = createServiceRoleClient()
|
|
||||||
let operationId: string | null = null
|
|
||||||
try {
|
|
||||||
const user = await requireUser(req)
|
|
||||||
const body = await req.json() as CheckoutRequest
|
|
||||||
const tier = normalizeTier(body.tier)
|
|
||||||
const urls = parseReturnUrls(body.success_url, body.cancel_url)
|
|
||||||
if (
|
|
||||||
!tier
|
|
||||||
|| !urls
|
|
||||||
|| (body.idempotency_key !== undefined
|
|
||||||
&& (typeof body.idempotency_key !== 'string'
|
|
||||||
|| !/^[A-Za-z0-9._:-]{12,160}$/.test(body.idempotency_key)))
|
|
||||||
) {
|
|
||||||
return jsonResponse({ error: 'invalid_request' }, 400)
|
|
||||||
}
|
|
||||||
|
|
||||||
const stripeKey = Deno.env.get('STRIPE_SECRET_KEY')?.trim() ?? ''
|
|
||||||
const priceMap = {
|
|
||||||
pro: Deno.env.get('STRIPE_PRICE_PRO')?.trim() ?? '',
|
|
||||||
pro_plus: (Deno.env.get('STRIPE_PRICE_PRO_PLUS')
|
|
||||||
?? Deno.env.get('STRIPE_PRICE_TEAM'))?.trim() ?? '',
|
|
||||||
}
|
|
||||||
const priceId = priceMap[tier]
|
|
||||||
if (!stripeKey || !priceId) {
|
|
||||||
return jsonResponse({ error: 'stripe_not_configured' }, 503)
|
|
||||||
}
|
|
||||||
|
|
||||||
const idempotencyKey = typeof body.idempotency_key === 'string'
|
|
||||||
? body.idempotency_key
|
|
||||||
: `stripe-checkout:${crypto.randomUUID()}`
|
|
||||||
const providerOrderId = `STRIPE-${crypto.randomUUID()}`
|
|
||||||
const { data: reservationData, error: reservationError } = await serviceClient.rpc(
|
|
||||||
'reserve_payment_provider_operation',
|
|
||||||
{
|
|
||||||
p_user_id: user.id,
|
|
||||||
p_provider: 'stripe',
|
|
||||||
p_operation_type: 'checkout',
|
|
||||||
p_requested_tier: tier,
|
|
||||||
p_idempotency_key: idempotencyKey,
|
|
||||||
p_provider_order_id: providerOrderId,
|
|
||||||
p_provider_resource_id: null,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if (reservationError) throw new Error('payment_reservation_failed')
|
|
||||||
const reservation = reservationData as OperationReservation | null
|
|
||||||
if (!reservation?.created || typeof reservation.operation_id !== 'string') {
|
|
||||||
return jsonResponse({
|
|
||||||
error: reservation?.reason ?? 'payment_operation_in_progress',
|
|
||||||
state: reservation?.state ?? 'rejected',
|
|
||||||
}, 409)
|
|
||||||
}
|
|
||||||
operationId = reservation.operation_id
|
|
||||||
|
|
||||||
const { data: subscription, error: subscriptionError } = await serviceClient
|
|
||||||
.from('subscriptions')
|
|
||||||
.select('stripe_customer_id')
|
|
||||||
.eq('user_id', user.id)
|
|
||||||
.single()
|
|
||||||
if (subscriptionError) throw new Error('subscription_lookup_failed')
|
|
||||||
let customerId = typeof subscription?.stripe_customer_id === 'string'
|
|
||||||
? subscription.stripe_customer_id
|
|
||||||
: null
|
|
||||||
|
|
||||||
if (!customerId) {
|
|
||||||
const customerResponse = await fetch('https://api.stripe.com/v1/customers', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: stripeHeaders(stripeKey, `customer-${operationId}`),
|
|
||||||
body: new URLSearchParams({
|
|
||||||
email: user.email ?? '',
|
|
||||||
'metadata[user_id]': user.id,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
if (!customerResponse.ok) throw new Error('stripe_customer_creation_failed')
|
|
||||||
const customer = await customerResponse.json() as { id?: unknown }
|
|
||||||
if (typeof customer.id !== 'string' || !customer.id.startsWith('cus_')) {
|
|
||||||
throw new Error('stripe_customer_response_invalid')
|
|
||||||
}
|
|
||||||
customerId = customer.id
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionResponse = await fetch('https://api.stripe.com/v1/checkout/sessions', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: stripeHeaders(stripeKey, `checkout-${operationId}`),
|
|
||||||
body: new URLSearchParams({
|
|
||||||
customer: customerId,
|
|
||||||
mode: 'subscription',
|
|
||||||
'line_items[0][price]': priceId,
|
|
||||||
'line_items[0][quantity]': '1',
|
|
||||||
success_url: urls.success,
|
|
||||||
cancel_url: urls.cancel,
|
|
||||||
client_reference_id: user.id,
|
|
||||||
'metadata[user_id]': user.id,
|
|
||||||
'metadata[tier]': tier,
|
|
||||||
'metadata[operation_id]': operationId,
|
|
||||||
'subscription_data[metadata][user_id]': user.id,
|
|
||||||
'subscription_data[metadata][tier]': tier,
|
|
||||||
'subscription_data[metadata][operation_id]': operationId,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
if (!sessionResponse.ok) throw new Error('stripe_checkout_creation_failed')
|
|
||||||
const session = await sessionResponse.json() as { id?: unknown; url?: unknown }
|
|
||||||
if (
|
|
||||||
typeof session.id !== 'string'
|
|
||||||
|| !session.id.startsWith('cs_')
|
|
||||||
|| typeof session.url !== 'string'
|
|
||||||
|| !session.url.startsWith('https://checkout.stripe.com/')
|
|
||||||
) {
|
|
||||||
throw new Error('stripe_checkout_response_invalid')
|
|
||||||
}
|
|
||||||
|
|
||||||
const { error: operationError } = await serviceClient.rpc('mark_payment_provider_operation', {
|
|
||||||
p_operation_id: operationId,
|
|
||||||
p_state: 'external_created',
|
|
||||||
p_external_reference: session.id,
|
|
||||||
p_error_code: null,
|
|
||||||
})
|
|
||||||
if (operationError) throw new Error('payment_operation_update_failed')
|
|
||||||
|
|
||||||
return jsonResponse({ url: session.url })
|
|
||||||
} catch (error) {
|
|
||||||
if (operationId) {
|
|
||||||
await serviceClient.rpc('mark_payment_provider_operation', {
|
|
||||||
p_operation_id: operationId,
|
|
||||||
p_state: 'failed',
|
|
||||||
p_external_reference: null,
|
|
||||||
p_error_code: 'stripe_checkout_failed',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (error && typeof error === 'object' && 'status' in error && 'message' in error) {
|
|
||||||
return authErrorResponse(error as AuthError, corsHeaders)
|
|
||||||
}
|
|
||||||
return jsonResponse({ error: 'stripe_checkout_failed' }, 502)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
@ -1,114 +0,0 @@
|
||||||
// server/supabase/functions/stripe-portal/index.ts
|
|
||||||
// Stripe Customer Portal 세션 생성 — 로그인한 사용자가 자신의 구독을 관리할 수 있도록.
|
|
||||||
// 응답: { url } → 클라이언트가 redirect.
|
|
||||||
|
|
||||||
import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts'
|
|
||||||
import { requireUser, authErrorResponse, type AuthError } from '../_shared/auth.ts'
|
|
||||||
import { createServiceRoleClient } from '../_shared/quota.ts'
|
|
||||||
|
|
||||||
interface PortalRequest {
|
|
||||||
return_url?: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
function validReturnUrl(value: unknown): string | null {
|
|
||||||
if (typeof value !== 'string') return null
|
|
||||||
try {
|
|
||||||
const url = new URL(value)
|
|
||||||
if (url.username || url.password) return null
|
|
||||||
if (url.protocol === 'https:') return url.toString()
|
|
||||||
if (url.protocol === 'http:' && ['localhost', '127.0.0.1'].includes(url.hostname)) {
|
|
||||||
return url.toString()
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Deno.serve(async (req: Request) => {
|
|
||||||
const preflight = handleCorsPreflightRequest(req)
|
|
||||||
if (preflight) return preflight
|
|
||||||
|
|
||||||
if (req.method !== 'POST') {
|
|
||||||
return new Response(JSON.stringify({ error: 'Method not allowed' }), {
|
|
||||||
status: 405,
|
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const user = await requireUser(req)
|
|
||||||
const body = (await req.json()) as PortalRequest
|
|
||||||
const returnUrl = validReturnUrl(body.return_url)
|
|
||||||
if (!returnUrl) {
|
|
||||||
return new Response(JSON.stringify({ error: 'invalid_return_url' }), {
|
|
||||||
status: 400,
|
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const stripeKey = Deno.env.get('STRIPE_SECRET_KEY') ?? ''
|
|
||||||
if (!stripeKey) {
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ error: 'stripe_not_configured' }),
|
|
||||||
{ status: 503, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 기존 customer_id 조회
|
|
||||||
const serviceClient = createServiceRoleClient()
|
|
||||||
const { data: sub } = await serviceClient
|
|
||||||
.from('subscriptions')
|
|
||||||
.select('stripe_customer_id')
|
|
||||||
.eq('user_id', user.id)
|
|
||||||
.maybeSingle()
|
|
||||||
|
|
||||||
const customerId = (sub?.stripe_customer_id as string | null | undefined) ?? null
|
|
||||||
if (!customerId?.startsWith('cus_')) {
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
error: 'no_customer',
|
|
||||||
message: '활성 구독이 없습니다. 먼저 업그레이드하세요.'
|
|
||||||
}),
|
|
||||||
{ status: 404, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Portal session 생성
|
|
||||||
const portalResp = await fetch('https://api.stripe.com/v1/billing_portal/sessions', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${stripeKey}`,
|
|
||||||
'Content-Type': 'application/x-www-form-urlencoded'
|
|
||||||
},
|
|
||||||
body: new URLSearchParams({
|
|
||||||
customer: customerId,
|
|
||||||
return_url: returnUrl
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!portalResp.ok) {
|
|
||||||
throw new Error('stripe_portal_creation_failed')
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = (await portalResp.json()) as { url?: unknown }
|
|
||||||
if (typeof data.url !== 'string') throw new Error('stripe_portal_response_invalid')
|
|
||||||
const portalUrl = new URL(data.url)
|
|
||||||
if (portalUrl.protocol !== 'https:' || !portalUrl.hostname.endsWith('.stripe.com')) {
|
|
||||||
throw new Error('stripe_portal_response_invalid')
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Response(JSON.stringify({ url: data.url }), {
|
|
||||||
status: 200,
|
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
if (err && typeof err === 'object' && 'status' in err && 'message' in err) {
|
|
||||||
return authErrorResponse(err as AuthError, corsHeaders)
|
|
||||||
}
|
|
||||||
return new Response(JSON.stringify({ error: 'stripe_portal_failed' }), {
|
|
||||||
status: 502,
|
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
import {
|
|
||||||
constantTimeEqual,
|
|
||||||
normalizeStripeTier,
|
|
||||||
tierFromStripeSubscriptionPrice,
|
|
||||||
verifyStripeSignature,
|
|
||||||
} from './index.ts'
|
|
||||||
|
|
||||||
function assert(condition: boolean, message: string): asserts condition {
|
|
||||||
if (!condition) throw new Error(message)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function stripeHeader(payload: string, secret: string, timestamp: number): Promise<string> {
|
|
||||||
const key = await crypto.subtle.importKey(
|
|
||||||
'raw',
|
|
||||||
new TextEncoder().encode(secret),
|
|
||||||
{ name: 'HMAC', hash: 'SHA-256' },
|
|
||||||
false,
|
|
||||||
['sign'],
|
|
||||||
)
|
|
||||||
const signature = await crypto.subtle.sign(
|
|
||||||
'HMAC',
|
|
||||||
key,
|
|
||||||
new TextEncoder().encode(`${timestamp}.${payload}`),
|
|
||||||
)
|
|
||||||
const hex = Array.from(new Uint8Array(signature))
|
|
||||||
.map((part) => part.toString(16).padStart(2, '0'))
|
|
||||||
.join('')
|
|
||||||
return `t=${timestamp},v1=${hex}`
|
|
||||||
}
|
|
||||||
|
|
||||||
Deno.test('Stripe signature verifies exact payload and rejects tampering', async () => {
|
|
||||||
const now = 1_800_000_000
|
|
||||||
const payload = JSON.stringify({ id: 'evt_test', type: 'customer.subscription.updated' })
|
|
||||||
const header = await stripeHeader(payload, 'whsec_test', now)
|
|
||||||
assert(await verifyStripeSignature(payload, header, 'whsec_test', 300, now * 1000), 'valid signature')
|
|
||||||
assert(
|
|
||||||
!(await verifyStripeSignature(`${payload} `, header, 'whsec_test', 300, now * 1000)),
|
|
||||||
'payload mutation must fail',
|
|
||||||
)
|
|
||||||
assert(
|
|
||||||
!(await verifyStripeSignature(payload, header, 'different', 300, now * 1000)),
|
|
||||||
'wrong secret must fail',
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
Deno.test('Stripe signature rejects stale and ambiguous timestamp headers', async () => {
|
|
||||||
const timestamp = 1_800_000_000
|
|
||||||
const payload = '{}'
|
|
||||||
const header = await stripeHeader(payload, 'whsec_test', timestamp)
|
|
||||||
assert(
|
|
||||||
!(await verifyStripeSignature(payload, header, 'whsec_test', 300, (timestamp + 301) * 1000)),
|
|
||||||
'stale signature must fail',
|
|
||||||
)
|
|
||||||
assert(
|
|
||||||
!(await verifyStripeSignature(payload, `${header},t=${timestamp}`, 'whsec_test', 300, timestamp * 1000)),
|
|
||||||
'multiple timestamp fields must fail',
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
Deno.test('Stripe tier is derived from the exact configured subscription price', () => {
|
|
||||||
const prices = { pro: 'price_pro', pro_plus: 'price_pro_plus' }
|
|
||||||
assert(tierFromStripeSubscriptionPrice({
|
|
||||||
items: { data: [{ price: { id: 'price_pro' } }] },
|
|
||||||
}, prices) === 'pro', 'pro price must map to pro')
|
|
||||||
assert(tierFromStripeSubscriptionPrice({
|
|
||||||
items: { data: [{ price: { id: 'price_pro_plus' } }] },
|
|
||||||
}, prices) === 'pro_plus', 'pro plus price must map to pro_plus')
|
|
||||||
assert(tierFromStripeSubscriptionPrice({
|
|
||||||
items: { data: [{ price: { id: 'price_attacker' } }] },
|
|
||||||
}, prices) === null, 'unknown price must fail closed')
|
|
||||||
assert(tierFromStripeSubscriptionPrice({
|
|
||||||
items: { data: [{ price: { id: 'price_pro' } }, { price: { id: 'price_pro_plus' } }] },
|
|
||||||
}, prices) === null, 'ambiguous multi-price subscription must fail closed')
|
|
||||||
assert(normalizeStripeTier('team') === 'pro_plus', 'legacy team alias maps only to pro_plus')
|
|
||||||
assert(normalizeStripeTier('enterprise') === null, 'unknown metadata tier must fail')
|
|
||||||
})
|
|
||||||
|
|
||||||
Deno.test('constant-time comparator rejects length and value mismatch', () => {
|
|
||||||
assert(constantTimeEqual('abc', 'abc'), 'equal strings')
|
|
||||||
assert(!constantTimeEqual('abc', 'abd'), 'different strings')
|
|
||||||
assert(!constantTimeEqual('abc', 'ab'), 'different lengths')
|
|
||||||
})
|
|
||||||
|
|
@ -1,320 +0,0 @@
|
||||||
import { createServiceRoleClient } from '../_shared/quota.ts'
|
|
||||||
|
|
||||||
interface StripeEvent {
|
|
||||||
id: string
|
|
||||||
type: string
|
|
||||||
created: number
|
|
||||||
data: { object: Record<string, unknown> }
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StripeMetadata {
|
|
||||||
user_id?: string
|
|
||||||
tier?: string
|
|
||||||
operation_id?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ProviderApplyResult {
|
|
||||||
applied?: boolean
|
|
||||||
duplicate?: boolean
|
|
||||||
reason?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function constantTimeEqual(a: string, b: string): boolean {
|
|
||||||
if (a.length !== b.length) return false
|
|
||||||
let mismatch = 0
|
|
||||||
for (let index = 0; index < a.length; index += 1) {
|
|
||||||
mismatch |= a.charCodeAt(index) ^ b.charCodeAt(index)
|
|
||||||
}
|
|
||||||
return mismatch === 0
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function verifyStripeSignature(
|
|
||||||
payload: string,
|
|
||||||
signatureHeader: string,
|
|
||||||
secret: string,
|
|
||||||
toleranceSec = 300,
|
|
||||||
nowMs = Date.now(),
|
|
||||||
): Promise<boolean> {
|
|
||||||
if (!signatureHeader || !secret || !payload || toleranceSec <= 0) return false
|
|
||||||
const parts = signatureHeader.split(',').map((part) => part.trim())
|
|
||||||
const timestampParts = parts.filter((part) => part.startsWith('t='))
|
|
||||||
const signatures = parts
|
|
||||||
.filter((part) => part.startsWith('v1='))
|
|
||||||
.map((part) => part.slice(3).toLowerCase())
|
|
||||||
.filter((part) => /^[0-9a-f]{64}$/.test(part))
|
|
||||||
if (timestampParts.length !== 1 || signatures.length === 0) return false
|
|
||||||
|
|
||||||
const timestamp = Number(timestampParts[0].slice(2))
|
|
||||||
if (!Number.isInteger(timestamp) || timestamp <= 0) return false
|
|
||||||
const nowSeconds = Math.floor(nowMs / 1000)
|
|
||||||
if (Math.abs(nowSeconds - timestamp) > toleranceSec) return false
|
|
||||||
|
|
||||||
const key = await crypto.subtle.importKey(
|
|
||||||
'raw',
|
|
||||||
new TextEncoder().encode(secret),
|
|
||||||
{ name: 'HMAC', hash: 'SHA-256' },
|
|
||||||
false,
|
|
||||||
['sign'],
|
|
||||||
)
|
|
||||||
const signature = await crypto.subtle.sign(
|
|
||||||
'HMAC',
|
|
||||||
key,
|
|
||||||
new TextEncoder().encode(`${timestamp}.${payload}`),
|
|
||||||
)
|
|
||||||
const expected = Array.from(new Uint8Array(signature))
|
|
||||||
.map((part) => part.toString(16).padStart(2, '0'))
|
|
||||||
.join('')
|
|
||||||
return signatures.some((candidate) => constantTimeEqual(candidate, expected))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function sha256Payload(payload: string): Promise<string> {
|
|
||||||
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(payload))
|
|
||||||
return Array.from(new Uint8Array(digest))
|
|
||||||
.map((part) => part.toString(16).padStart(2, '0'))
|
|
||||||
.join('')
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeStripeTier(value: unknown): 'pro' | 'pro_plus' | null {
|
|
||||||
if (value === 'pro') return 'pro'
|
|
||||||
if (value === 'pro_plus' || value === 'team') return 'pro_plus'
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
export function tierFromStripeSubscriptionPrice(
|
|
||||||
subscription: Record<string, unknown>,
|
|
||||||
prices: { pro: string; pro_plus: string },
|
|
||||||
): 'pro' | 'pro_plus' | null {
|
|
||||||
const items = subscription.items as { data?: unknown } | undefined
|
|
||||||
if (!Array.isArray(items?.data) || items.data.length !== 1) return null
|
|
||||||
const item = items.data[0] as { price?: { id?: unknown } } | undefined
|
|
||||||
const priceId = asString(item?.price?.id)
|
|
||||||
if (priceId && priceId === prices.pro) return 'pro'
|
|
||||||
if (priceId && priceId === prices.pro_plus) return 'pro_plus'
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
function asString(value: unknown): string | null {
|
|
||||||
return typeof value === 'string' && value.length > 0 ? value : null
|
|
||||||
}
|
|
||||||
|
|
||||||
function epochToIso(value: unknown): string | null {
|
|
||||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return null
|
|
||||||
return new Date(value * 1000).toISOString()
|
|
||||||
}
|
|
||||||
|
|
||||||
function stripePeriodValue(subscription: Record<string, unknown>, field: 'current_period_start' | 'current_period_end'): unknown {
|
|
||||||
if (subscription[field] !== undefined) return subscription[field]
|
|
||||||
const items = subscription.items as { data?: unknown } | undefined
|
|
||||||
if (!Array.isArray(items?.data) || items.data.length !== 1) return undefined
|
|
||||||
return (items.data[0] as Record<string, unknown> | undefined)?.[field]
|
|
||||||
}
|
|
||||||
|
|
||||||
function stripeSubscriptionEntitled(status: string): boolean {
|
|
||||||
return ['active', 'trialing', 'past_due'].includes(status)
|
|
||||||
}
|
|
||||||
|
|
||||||
function isUuid(value: unknown): value is string {
|
|
||||||
return typeof value === 'string'
|
|
||||||
&& /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function stripeWebhookHandler(req: Request): Promise<Response> {
|
|
||||||
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 })
|
|
||||||
|
|
||||||
const webhookSecret = Deno.env.get('STRIPE_WEBHOOK_SECRET')?.trim() ?? ''
|
|
||||||
const stripeSecret = Deno.env.get('STRIPE_SECRET_KEY')?.trim() ?? ''
|
|
||||||
if (!webhookSecret || !stripeSecret) {
|
|
||||||
return new Response('Stripe webhook not configured', { status: 503 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = await req.text()
|
|
||||||
const validSignature = await verifyStripeSignature(
|
|
||||||
payload,
|
|
||||||
req.headers.get('stripe-signature') ?? '',
|
|
||||||
webhookSecret,
|
|
||||||
)
|
|
||||||
if (!validSignature) return new Response('Invalid signature', { status: 400 })
|
|
||||||
|
|
||||||
let event: StripeEvent
|
|
||||||
try {
|
|
||||||
event = JSON.parse(payload) as StripeEvent
|
|
||||||
} catch {
|
|
||||||
return new Response('Invalid JSON', { status: 400 })
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
!/^evt_[A-Za-z0-9]+$/.test(event.id ?? '')
|
|
||||||
|| typeof event.type !== 'string'
|
|
||||||
|| !Number.isInteger(event.created)
|
|
||||||
|| event.created <= 0
|
|
||||||
|| !event.data?.object
|
|
||||||
) {
|
|
||||||
return new Response('Invalid event', { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const serviceClient = createServiceRoleClient()
|
|
||||||
const payloadDigest = await sha256Payload(payload)
|
|
||||||
try {
|
|
||||||
if (event.type === 'checkout.session.completed') {
|
|
||||||
const session = event.data.object
|
|
||||||
const metadata = (session.metadata ?? {}) as StripeMetadata
|
|
||||||
const userId = asString(metadata.user_id) ?? asString(session.client_reference_id)
|
|
||||||
const subscriptionId = asString(session.subscription)
|
|
||||||
if (!userId || !subscriptionId) {
|
|
||||||
return new Response(JSON.stringify({ received: true, ignored: 'missing_correlation' }), {
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const { error: observationError } = await serviceClient.rpc(
|
|
||||||
'record_payment_provider_observation',
|
|
||||||
{
|
|
||||||
p_user_id: userId,
|
|
||||||
p_provider: 'stripe',
|
|
||||||
p_event_id: event.id,
|
|
||||||
p_event_created_at: new Date(event.created * 1000).toISOString(),
|
|
||||||
p_event_type: event.type,
|
|
||||||
p_payload_digest: payloadDigest,
|
|
||||||
p_provider_resource_id: subscriptionId,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if (observationError) throw new Error('stripe_observation_failed')
|
|
||||||
|
|
||||||
if (isUuid(metadata.operation_id)) {
|
|
||||||
const { error: operationError } = await serviceClient.rpc(
|
|
||||||
'mark_payment_provider_operation',
|
|
||||||
{
|
|
||||||
p_operation_id: metadata.operation_id,
|
|
||||||
p_state: 'external_created',
|
|
||||||
p_external_reference: asString(session.id) ?? subscriptionId,
|
|
||||||
p_error_code: null,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if (operationError) throw new Error('stripe_operation_update_failed')
|
|
||||||
}
|
|
||||||
return new Response(JSON.stringify({ received: true }), {
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (![
|
|
||||||
'customer.subscription.created',
|
|
||||||
'customer.subscription.updated',
|
|
||||||
'customer.subscription.deleted',
|
|
||||||
].includes(event.type)) {
|
|
||||||
return new Response(JSON.stringify({ received: true, ignored: true }), {
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
let subscription = event.data.object
|
|
||||||
const subscriptionId = asString(subscription.id)
|
|
||||||
if (!subscriptionId) return new Response('Invalid subscription', { status: 400 })
|
|
||||||
|
|
||||||
// Stripe signs each delivery but does not guarantee delivery order. For
|
|
||||||
// non-deletion events, retrieve the subscription's current authoritative
|
|
||||||
// state so a late event cannot resurrect an older state from its payload.
|
|
||||||
if (event.type !== 'customer.subscription.deleted') {
|
|
||||||
const currentResponse = await fetch(
|
|
||||||
`https://api.stripe.com/v1/subscriptions/${encodeURIComponent(subscriptionId)}`,
|
|
||||||
{ headers: { Authorization: `Bearer ${stripeSecret}` } },
|
|
||||||
)
|
|
||||||
if (!currentResponse.ok) throw new Error('stripe_subscription_verification_failed')
|
|
||||||
const current = await currentResponse.json() as Record<string, unknown>
|
|
||||||
if (asString(current.id) !== subscriptionId) {
|
|
||||||
throw new Error('stripe_subscription_verification_mismatch')
|
|
||||||
}
|
|
||||||
subscription = current
|
|
||||||
}
|
|
||||||
|
|
||||||
const customerId = asString(subscription.customer)
|
|
||||||
const metadata = (subscription.metadata ?? {}) as StripeMetadata
|
|
||||||
if (!customerId) return new Response('Invalid subscription', { status: 400 })
|
|
||||||
|
|
||||||
const prices = {
|
|
||||||
pro: Deno.env.get('STRIPE_PRICE_PRO')?.trim() ?? '',
|
|
||||||
pro_plus: (Deno.env.get('STRIPE_PRICE_PRO_PLUS')
|
|
||||||
?? Deno.env.get('STRIPE_PRICE_TEAM'))?.trim() ?? '',
|
|
||||||
}
|
|
||||||
if (!prices.pro && !prices.pro_plus) {
|
|
||||||
return new Response('Stripe prices not configured', { status: 503 })
|
|
||||||
}
|
|
||||||
const priceTier = tierFromStripeSubscriptionPrice(subscription, prices)
|
|
||||||
const metadataTier = normalizeStripeTier(metadata.tier)
|
|
||||||
if (!priceTier || (metadataTier && metadataTier !== priceTier)) {
|
|
||||||
return new Response('Subscription price mismatch', { status: 422 })
|
|
||||||
}
|
|
||||||
|
|
||||||
let userId = asString(metadata.user_id)
|
|
||||||
const tier: 'pro' | 'pro_plus' = priceTier
|
|
||||||
if (!userId) {
|
|
||||||
const { data: existing, error: lookupError } = await serviceClient
|
|
||||||
.from('subscriptions')
|
|
||||||
.select('user_id, tier')
|
|
||||||
.eq('stripe_subscription_id', subscriptionId)
|
|
||||||
.maybeSingle()
|
|
||||||
if (lookupError) throw new Error('stripe_subscription_lookup_failed')
|
|
||||||
userId ??= asString(existing?.user_id)
|
|
||||||
const existingTier = normalizeStripeTier(existing?.tier)
|
|
||||||
if (existingTier && existingTier !== priceTier) {
|
|
||||||
return new Response('Stored subscription price mismatch', { status: 422 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!userId) return new Response('Missing subscription owner', { status: 422 })
|
|
||||||
|
|
||||||
const status = event.type === 'customer.subscription.deleted'
|
|
||||||
? 'canceled'
|
|
||||||
: asString(subscription.status)
|
|
||||||
if (!status || ![
|
|
||||||
'active', 'trialing', 'past_due', 'canceled', 'unpaid', 'incomplete',
|
|
||||||
'incomplete_expired', 'paused',
|
|
||||||
].includes(status)) {
|
|
||||||
return new Response('Invalid subscription status', { status: 400 })
|
|
||||||
}
|
|
||||||
const entitled = stripeSubscriptionEntitled(status)
|
|
||||||
const cancelAtPeriodEnd = subscription.cancel_at_period_end === true
|
|
||||||
const periodEnd = epochToIso(stripePeriodValue(subscription, 'current_period_end'))
|
|
||||||
const cancelAt = epochToIso(subscription.cancel_at)
|
|
||||||
?? (cancelAtPeriodEnd ? periodEnd : null)
|
|
||||||
const { data: applyData, error: applyError } = await serviceClient.rpc(
|
|
||||||
'apply_payment_provider_event',
|
|
||||||
{
|
|
||||||
p_user_id: userId,
|
|
||||||
p_provider: 'stripe',
|
|
||||||
p_event_id: event.id,
|
|
||||||
p_event_created_at: new Date(event.created * 1000).toISOString(),
|
|
||||||
p_event_type: event.type,
|
|
||||||
p_payload_digest: payloadDigest,
|
|
||||||
p_provider_resource_id: subscriptionId,
|
|
||||||
p_tier: entitled ? tier : 'free',
|
|
||||||
p_status: status,
|
|
||||||
p_entitled: entitled,
|
|
||||||
p_current_period_start: epochToIso(stripePeriodValue(subscription, 'current_period_start')),
|
|
||||||
p_current_period_end: periodEnd,
|
|
||||||
p_cancel_at: cancelAt,
|
|
||||||
p_auto_renewing: entitled && !cancelAtPeriodEnd,
|
|
||||||
p_provider_customer_id: customerId,
|
|
||||||
p_provider_order_id: null,
|
|
||||||
p_store_product_id: null,
|
|
||||||
p_store_purchase_id: null,
|
|
||||||
p_operation_id: isUuid(metadata.operation_id) ? metadata.operation_id : null,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if (applyError) throw new Error('stripe_entitlement_apply_failed')
|
|
||||||
const result = applyData as ProviderApplyResult | null
|
|
||||||
return new Response(JSON.stringify({
|
|
||||||
received: true,
|
|
||||||
applied: result?.applied ?? false,
|
|
||||||
duplicate: result?.duplicate ?? false,
|
|
||||||
reason: result?.reason,
|
|
||||||
}), {
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
return new Response(JSON.stringify({ error: 'stripe_webhook_processing_failed' }), {
|
|
||||||
status: 500,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (import.meta.main) Deno.serve(stripeWebhookHandler)
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue