refactor(billing): remove Stripe; payments are Payple (web) and Google Play (mobile)
Some checks failed
ci / 정본·보안·린트·타입·테스트 (push) Failing after 1m13s
ci / 워크스페이스 빌드 검증 (push) Has been skipped
ci / 모바일 린트·타입·Jest (push) Failing after 1m4s
ci / Supabase Edge Functions + Cloudflare Worker (push) Successful in 37s
ci / .NET API 서버 테스트 (push) Successful in 27s
deploy-site / deploy (push) Failing after 20s

Stripe is not used. Keeping its checkout, portal and webhook paths meant a
second payment provider, a second return-URL format and dead UI.

- Delete the stripe-checkout, stripe-portal and stripe-webhook functions and
  their config; billing-catalog serves Payple prices only, and the web parser
  rejects a catalog that still mixes in Stripe prices.
- Web: drop the Stripe checkout/portal buttons, provider toggle and return
  notices; billing shows Payple only. Past rows with provider='stripe' are
  still displayed ("Stripe (종료)") with a support contact instead of a portal.
- Desktop: delete the Stripe checkout modal, payment IPC channels, preload
  namespace and their types; "Remove ads with Pro" opens the web billing page
  via license.openBilling. Support/refund copy names Payple.
- billingUrl() loses the Stripe-only success/canceled result option; the
  Deno contract is regenerated.
- Migrations and the DB's accepted provider values are untouched (history).
- Docs and the backlog record the removal (MON-04, EXT-STRIPE-01, GAP-BILL-03).

Verified: typecheck (desktop/web/admin/api-client/mobile), contract:check,
deno check all functions, deno test 80/80, desktop 1478/1480 on the Electron
runtime (2 known environment failures), web and admin builds, release
metadata and mobile boundary self-tests, eslint on changed files.
This commit is contained in:
Yun Chan 2026-09-26 20:56:18 +09:00
parent 7224e43bfb
commit eedd127ea7
50 changed files with 97 additions and 2600 deletions

View file

@ -1,252 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { IPCResult } from '@d3ro/core/errors'
import type { CheckoutSessionResult, SubscriptionStatusResult } from '@d3ro/core/types'
import {
CheckoutFlowController,
type CheckoutFlowPorts,
isTrustedStripeCheckoutUrl
} from '../../src/renderer/components/payment/checkout-flow'
type CreateResult = IPCResult<CheckoutSessionResult>
type StatusResult = IPCResult<SubscriptionStatusResult>
const trustedUrl = 'https://checkout.stripe.com/c/pay/cs_test_renderer_safe#fragment'
const checkoutSuccess: CreateResult = {
success: true,
data: { checkoutUrl: trustedUrl, provider: 'stripe', status: 'pending' }
}
const openSuccess: IPCResult<void> = { success: true, data: undefined }
const activeSubscription: StatusResult = {
success: true,
data: { tier: 'pro_plus', valid: true, expiresAt: Date.parse('2099-01-01T00:00:00.000Z') }
}
const ipcFailure = {
success: false as const,
error: { code: 999, message: 'private provider details' }
}
function deferred<T>(): {
promise: Promise<T>
resolve: (value: T) => void
} {
let resolve!: (value: T) => void
const promise = new Promise<T>((done) => {
resolve = done
})
return { promise, resolve }
}
describe('renderer checkout flow', () => {
let createCheckoutSession: ReturnType<typeof vi.fn>
let openExternal: ReturnType<typeof vi.fn>
let getSubscriptionStatus: ReturnType<typeof vi.fn>
let controller: CheckoutFlowController
beforeEach(() => {
createCheckoutSession = vi.fn().mockResolvedValue(checkoutSuccess)
openExternal = vi.fn().mockResolvedValue(openSuccess)
getSubscriptionStatus = vi.fn().mockResolvedValue(activeSubscription)
const ports: CheckoutFlowPorts = {
createCheckoutSession,
openExternal,
getSubscriptionStatus
}
controller = new CheckoutFlowController(ports)
})
it('opens only the authenticated IPC checkout result and waits for server confirmation', async () => {
const opened = await controller.start('pro')
expect(opened).toBe(true)
expect(createCheckoutSession).toHaveBeenCalledWith({ tier: 'pro', provider: 'stripe' })
expect(openExternal).toHaveBeenCalledWith({ url: trustedUrl })
expect(getSubscriptionStatus).not.toHaveBeenCalled()
expect(controller.getState()).toEqual({
phase: 'awaiting',
error: null,
verifiedTier: null
})
})
it('fails closed when checkout IPC returns a provider error', async () => {
createCheckoutSession.mockResolvedValue(ipcFailure)
await controller.start('pro')
expect(controller.getState()).toMatchObject({ phase: 'idle', error: 'checkout-failed' })
expect(openExternal).not.toHaveBeenCalled()
})
it('fails closed when checkout IPC throws a network error', async () => {
createCheckoutSession.mockRejectedValue(new Error('private network details'))
await controller.start('pro')
expect(controller.getState()).toMatchObject({ phase: 'idle', error: 'network-failed' })
expect(openExternal).not.toHaveBeenCalled()
})
it.each([
{
checkoutUrl: 'http://checkout.stripe.com/c/pay/cs_test_bad',
provider: 'stripe',
status: 'pending'
},
{ checkoutUrl: 'https://evil.test/c/pay/cs_test_bad', provider: 'stripe', status: 'pending' },
{ checkoutUrl: trustedUrl, provider: 'toss', status: 'pending' },
{ checkoutUrl: trustedUrl, provider: 'stripe', status: 'completed' }
])('rejects an unsafe or forged checkout response %#', async (data) => {
createCheckoutSession.mockResolvedValue({ success: true, data })
await controller.start('pro')
expect(controller.getState()).toMatchObject({
phase: 'idle',
error: 'unsafe-checkout-response'
})
expect(openExternal).not.toHaveBeenCalled()
})
it('fails closed when the OS cannot open the checkout page', async () => {
openExternal.mockResolvedValue(ipcFailure)
await controller.start('pro')
expect(controller.getState()).toMatchObject({ phase: 'idle', error: 'open-failed' })
})
it('fails closed when opening the checkout page throws', async () => {
openExternal.mockRejectedValue(new Error('private shell details'))
await controller.start('pro')
expect(controller.getState()).toMatchObject({ phase: 'idle', error: 'open-failed' })
})
it('coalesces duplicate checkout clicks into one IPC request', async () => {
const pending = deferred<CreateResult>()
createCheckoutSession.mockReturnValue(pending.promise)
const first = controller.start('pro')
const duplicate = controller.start('pro_plus')
expect(await duplicate).toBe(false)
expect(createCheckoutSession).toHaveBeenCalledTimes(1)
pending.resolve(checkoutSuccess)
expect(await first).toBe(true)
expect(openExternal).toHaveBeenCalledTimes(1)
})
it('invalidates a pending checkout when the user closes the modal', async () => {
const pending = deferred<CreateResult>()
createCheckoutSession.mockReturnValue(pending.promise)
const start = controller.start('pro')
controller.cancel()
pending.resolve(checkoutSuccess)
expect(await start).toBe(false)
expect(openExternal).not.toHaveBeenCalled()
expect(controller.getState()).toEqual({ phase: 'idle', error: null, verifiedTier: null })
})
it('does not treat an opened or cancelled provider page as payment success', async () => {
await controller.start('pro')
getSubscriptionStatus.mockResolvedValue({
success: true,
data: { tier: 'free', valid: false, expiresAt: null }
})
const tier = await controller.verify()
expect(tier).toBeNull()
expect(controller.getState()).toMatchObject({ phase: 'awaiting', error: 'not-confirmed' })
})
it('fails closed when subscription readback returns an IPC error', async () => {
await controller.start('pro')
getSubscriptionStatus.mockResolvedValue(ipcFailure)
const tier = await controller.verify()
expect(tier).toBeNull()
expect(controller.getState()).toMatchObject({
phase: 'awaiting',
error: 'verification-failed'
})
})
it('fails closed when subscription readback throws a network error', async () => {
await controller.start('pro')
getSubscriptionStatus.mockRejectedValue(new Error('private subscription details'))
const tier = await controller.verify()
expect(tier).toBeNull()
expect(controller.getState()).toMatchObject({ phase: 'awaiting', error: 'network-failed' })
})
it('rejects an arbitrary paid tier even when a forged response claims it is valid', async () => {
await controller.start('pro')
getSubscriptionStatus.mockResolvedValue({
success: true,
data: { tier: 'enterprise', valid: true, expiresAt: null }
})
const tier = await controller.verify()
expect(tier).toBeNull()
expect(controller.getState()).toMatchObject({
phase: 'awaiting',
error: 'invalid-entitlement'
})
})
it('returns and displays only the server-read subscription tier', async () => {
await controller.start('pro')
const tier = await controller.verify()
expect(tier).toBe('pro_plus')
expect(controller.getState()).toEqual({
phase: 'complete',
error: null,
verifiedTier: 'pro_plus'
})
})
it('coalesces duplicate subscription verification clicks', async () => {
await controller.start('pro')
const pending = deferred<StatusResult>()
getSubscriptionStatus.mockReturnValue(pending.promise)
const first = controller.verify()
const duplicate = controller.verify()
expect(await duplicate).toBeNull()
expect(getSubscriptionStatus).toHaveBeenCalledTimes(1)
pending.resolve(activeSubscription)
expect(await first).toBe('pro_plus')
})
it('ignores a successful subscription response after cancellation', async () => {
await controller.start('pro')
const pending = deferred<StatusResult>()
getSubscriptionStatus.mockReturnValue(pending.promise)
const verification = controller.verify()
controller.cancel()
pending.resolve(activeSubscription)
expect(await verification).toBeNull()
expect(controller.getState()).toEqual({ phase: 'idle', error: null, verifiedTier: null })
})
it('accepts only the trusted Stripe HTTPS checkout origin and path', () => {
expect(isTrustedStripeCheckoutUrl(trustedUrl)).toBe(true)
expect(isTrustedStripeCheckoutUrl('https://checkout.stripe.com/portal/cs_test_bad')).toBe(false)
expect(
isTrustedStripeCheckoutUrl('https://user:pass@checkout.stripe.com/c/pay/cs_test_bad')
).toBe(false)
})
})

View file

@ -1,332 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ipcMain } from 'electron'
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
import type { IPCResult } from '@d3ro/core/errors'
import type { CheckoutSessionParams, CheckoutSessionResult } from '@d3ro/core/types'
const mocks = vi.hoisted(() => {
const invokeFunction = vi.fn()
const activate = vi.fn()
const getLicenseService = vi.fn(() => ({ activate }))
const cloud = {
isAuthenticated: vi.fn(() => true),
getUser: vi.fn(() => ({ id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' })),
invokeFunction
}
return { activate, cloud, getLicenseService, invokeFunction }
})
vi.mock('../../src/main/services/CloudSyncService', () => ({
getCloudSyncService: () => mocks.cloud
}))
vi.mock('../../src/main/services/LicenseService', () => ({
getLicenseService: mocks.getLicenseService
}))
import {
PAYMENT_REQUEST_TIMEOUT_MS,
registerPaymentHandlers
} from '../../src/main/ipc/payment-handlers'
type CapturedHandler = (...args: unknown[]) => unknown
const handlers = new Map<string, CapturedHandler>()
const validParams: CheckoutSessionParams = {
tier: 'pro',
provider: 'stripe'
}
async function invokeIpc<T>(channel: string, ...args: unknown[]): Promise<IPCResult<T>> {
const handler = handlers.get(channel)
if (!handler) throw new Error(`Missing IPC handler: ${channel}`)
return (await handler({}, ...args)) as IPCResult<T>
}
function checkoutResponse(url = 'https://checkout.stripe.com/c/pay/cs_test_safe#fragment') {
return { data: { url }, error: null }
}
beforeEach(() => {
vi.useRealTimers()
vi.clearAllMocks()
handlers.clear()
mocks.cloud.isAuthenticated.mockReturnValue(true)
mocks.cloud.getUser.mockReturnValue({ id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' })
vi.mocked(ipcMain.handle).mockImplementation((channel: string, handler: CapturedHandler) => {
handlers.set(channel, handler)
})
registerPaymentHandlers()
})
afterEach(() => {
vi.useRealTimers()
})
describe('desktop payment IPC security boundary', () => {
it('uses the authenticated Stripe Edge checkout contract and returns only its trusted URL', async () => {
mocks.invokeFunction.mockResolvedValue(checkoutResponse())
const result = await invokeIpc<CheckoutSessionResult>(
IPC_CHANNELS.PAYMENT.CREATE_CHECKOUT_SESSION,
validParams
)
expect(result).toEqual({
success: true,
data: {
checkoutUrl: 'https://checkout.stripe.com/c/pay/cs_test_safe#fragment',
provider: 'stripe',
status: 'pending'
}
})
expect(mocks.invokeFunction).toHaveBeenCalledTimes(1)
const [name, body, options] = mocks.invokeFunction.mock.calls[0]
expect(name).toBe('stripe-checkout')
expect(body).toEqual({
tier: 'pro',
success_url: 'https://d3ro.chanpaca.net/app/billing?success=1',
cancel_url: 'https://d3ro.chanpaca.net/app/billing?canceled=1',
idempotency_key: expect.stringMatching(/^desktop:stripe-checkout:[0-9a-f-]{36}$/)
})
expect(options).toMatchObject({ timeoutMs: PAYMENT_REQUEST_TIMEOUT_MS })
expect(options.signal).toBeInstanceOf(AbortSignal)
})
it.each(['toss', 'portone', 'arbitrary-provider'])(
'rejects unsupported provider %s before network I/O',
async (provider) => {
const result = await invokeIpc(IPC_CHANNELS.PAYMENT.CREATE_CHECKOUT_SESSION, {
...validParams,
provider
})
expect(result.success).toBe(false)
expect(mocks.invokeFunction).not.toHaveBeenCalled()
}
)
it.each(['free', 'team', 'enterprise', 'arbitrary-tier'])(
'rejects renderer-selected tier %s before network I/O',
async (tier) => {
const result = await invokeIpc(IPC_CHANNELS.PAYMENT.CREATE_CHECKOUT_SESSION, {
...validParams,
tier
})
expect(result.success).toBe(false)
expect(mocks.invokeFunction).not.toHaveBeenCalled()
}
)
it.each([
'http://checkout.stripe.com/c/pay/cs_test_unsafe',
'https://checkout.stripe.com.evil.test/c/pay/cs_test_unsafe',
'https://checkout.stripe.com@evil.test/c/pay/cs_test_unsafe',
'https://user:pass@checkout.stripe.com/c/pay/cs_test_unsafe',
'https://checkout.stripe.com/portal/cs_test_unsafe'
])('rejects an untrusted checkout URL: %s', async (url) => {
mocks.invokeFunction.mockResolvedValue(checkoutResponse(url))
const result = await invokeIpc(IPC_CHANNELS.PAYMENT.CREATE_CHECKOUT_SESSION, validParams)
expect(result.success).toBe(false)
})
it('fails closed on provider errors without exposing provider details', async () => {
mocks.invokeFunction.mockResolvedValue({
data: null,
error: { message: 'sensitive Stripe provider details' }
})
const result = await invokeIpc(IPC_CHANNELS.PAYMENT.CREATE_CHECKOUT_SESSION, validParams)
expect(result).toMatchObject({
success: false,
error: { message: 'Checkout service rejected the request' }
})
expect(JSON.stringify(result)).not.toContain('sensitive Stripe provider details')
})
it('fails closed on network errors without converting them into checkout success', async () => {
mocks.invokeFunction.mockRejectedValue(new Error('sensitive network details'))
const result = await invokeIpc(IPC_CHANNELS.PAYMENT.CREATE_CHECKOUT_SESSION, validParams)
expect(result).toMatchObject({
success: false,
error: { message: 'Checkout service is unavailable' }
})
expect(JSON.stringify(result)).not.toContain('sensitive network details')
})
it('coalesces concurrent and sequential duplicate checkout requests', async () => {
let resolveProvider: ((value: ReturnType<typeof checkoutResponse>) => void) | undefined
mocks.invokeFunction.mockReturnValue(
new Promise((resolve) => {
resolveProvider = resolve
})
)
const first = invokeIpc<CheckoutSessionResult>(
IPC_CHANNELS.PAYMENT.CREATE_CHECKOUT_SESSION,
validParams
)
const concurrent = invokeIpc<CheckoutSessionResult>(
IPC_CHANNELS.PAYMENT.CREATE_CHECKOUT_SESSION,
validParams
)
expect(mocks.invokeFunction).toHaveBeenCalledTimes(1)
resolveProvider?.(checkoutResponse())
const [firstResult, concurrentResult] = await Promise.all([first, concurrent])
const sequentialResult = await invokeIpc<CheckoutSessionResult>(
IPC_CHANNELS.PAYMENT.CREATE_CHECKOUT_SESSION,
validParams
)
expect(firstResult).toEqual(concurrentResult)
expect(sequentialResult).toEqual(firstResult)
expect(mocks.invokeFunction).toHaveBeenCalledTimes(1)
})
it('reuses the server idempotency key when a failed checkout is retried', async () => {
mocks.invokeFunction.mockResolvedValue({
data: null,
error: { message: 'provider unavailable' }
})
const first = await invokeIpc(IPC_CHANNELS.PAYMENT.CREATE_CHECKOUT_SESSION, validParams)
const retry = await invokeIpc(IPC_CHANNELS.PAYMENT.CREATE_CHECKOUT_SESSION, validParams)
expect(first.success).toBe(false)
expect(retry.success).toBe(false)
expect(mocks.invokeFunction).toHaveBeenCalledTimes(2)
expect(mocks.invokeFunction.mock.calls[0][1].idempotency_key).toBe(
mocks.invokeFunction.mock.calls[1][1].idempotency_key
)
})
it('aborts and fails closed when checkout exceeds the deadline', async () => {
vi.useFakeTimers()
mocks.invokeFunction.mockReturnValue(new Promise(() => undefined))
const pending = invokeIpc(IPC_CHANNELS.PAYMENT.CREATE_CHECKOUT_SESSION, validParams)
await vi.advanceTimersByTimeAsync(PAYMENT_REQUEST_TIMEOUT_MS)
const result = await pending
expect(result).toMatchObject({
success: false,
error: { message: 'Payment server request timed out' }
})
const options = mocks.invokeFunction.mock.calls[0][2]
expect(options.signal.aborted).toBe(true)
})
it('uses server subscription readback and never mutates the local license', async () => {
mocks.invokeFunction.mockResolvedValue({
data: {
tier: 'pro_plus',
status: 'active',
current_period_end: '2099-01-01T00:00:00.000Z'
},
error: null
})
const result = await invokeIpc<{ success: boolean; activeTier: string }>(
IPC_CHANNELS.PAYMENT.VERIFY_PAYMENT,
{ tier: 'enterprise' }
)
expect(result).toEqual({
success: true,
data: { success: true, activeTier: 'pro_plus' }
})
expect(mocks.invokeFunction).toHaveBeenCalledWith(
'payple-manage',
{ action: 'info' },
expect.objectContaining({ timeoutMs: PAYMENT_REQUEST_TIMEOUT_MS })
)
expect(mocks.getLicenseService).not.toHaveBeenCalled()
expect(mocks.activate).not.toHaveBeenCalled()
})
it('does not grant an arbitrary renderer tier when the server says free', async () => {
mocks.invokeFunction.mockResolvedValue({
data: { tier: 'free', status: 'active', current_period_end: null },
error: null
})
const result = await invokeIpc<{ success: boolean; activeTier: string }>(
IPC_CHANNELS.PAYMENT.VERIFY_PAYMENT,
{ tier: 'enterprise' }
)
expect(result).toEqual({
success: true,
data: { success: false, activeTier: 'free' }
})
expect(mocks.activate).not.toHaveBeenCalled()
})
it('preserves prepaid cancellation until expiry and fails closed after expiry', async () => {
mocks.invokeFunction
.mockResolvedValueOnce({
data: {
tier: 'pro',
status: 'canceled',
current_period_end: '2099-01-01T00:00:00.000Z'
},
error: null
})
.mockResolvedValueOnce({
data: {
tier: 'pro',
status: 'canceled',
current_period_end: '2000-01-01T00:00:00.000Z'
},
error: null
})
const current = await invokeIpc<{
tier: string
valid: boolean
expiresAt: number | null
}>(IPC_CHANNELS.PAYMENT.GET_SUBSCRIPTION_STATUS)
const expired = await invokeIpc<{
tier: string
valid: boolean
expiresAt: number | null
}>(IPC_CHANNELS.PAYMENT.GET_SUBSCRIPTION_STATUS)
expect(current).toMatchObject({ success: true, data: { tier: 'pro', valid: true } })
expect(expired).toMatchObject({ success: true, data: { tier: 'free', valid: false } })
})
it('rejects malformed or arbitrary server entitlement data', async () => {
mocks.invokeFunction.mockResolvedValue({
data: {
tier: 'enterprise',
status: 'active',
current_period_end: '2099-01-01T00:00:00.000Z'
},
error: null
})
const result = await invokeIpc(IPC_CHANNELS.PAYMENT.GET_SUBSCRIPTION_STATUS)
expect(result).toMatchObject({
success: false,
error: { message: 'Subscription status is unavailable' }
})
})
it('requires an authenticated user before invoking payment functions', async () => {
mocks.cloud.isAuthenticated.mockReturnValue(false)
const result = await invokeIpc(IPC_CHANNELS.PAYMENT.CREATE_CHECKOUT_SESSION, validParams)
expect(result.success).toBe(false)
expect(mocks.invokeFunction).not.toHaveBeenCalled()
})
})