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.
218 lines
9.4 KiB
TypeScript
218 lines
9.4 KiB
TypeScript
import { randomUUID } from 'node:crypto'
|
|
import { expect, test } from '@playwright/test'
|
|
import { createClient, type SupabaseClient } from '@supabase/supabase-js'
|
|
import { PAYPLE_PENDING_CHECKOUT_STORAGE_KEY } from '../src/components/billing/payple-client'
|
|
|
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
|
|
const serviceRoleKey = process.env.E2E_SUPABASE_SERVICE_ROLE_KEY
|
|
const paypleClientKey = process.env.NEXT_PUBLIC_PAYPLE_CLIENT_KEY
|
|
const hasFixtureAccess = Boolean(supabaseUrl && serviceRoleKey && paypleClientKey)
|
|
|
|
test.describe('Billing provider and Payple DOM flow', () => {
|
|
test.skip(!hasFixtureAccess, 'Local Supabase and Payple E2E configuration are required')
|
|
test.describe.configure({ mode: 'serial' })
|
|
test.setTimeout(90_000)
|
|
|
|
const runId = randomUUID()
|
|
const email = `billing-${runId}@example.test`
|
|
const password = `Billing-${runId}-Aa1!`
|
|
let admin: SupabaseClient
|
|
let userId = ''
|
|
|
|
test.beforeAll(async () => {
|
|
admin = createClient(supabaseUrl!, serviceRoleKey!, {
|
|
auth: { autoRefreshToken: false, persistSession: false }
|
|
})
|
|
const created = await admin.auth.admin.createUser({ email, password, email_confirm: true })
|
|
if (created.error || !created.data.user) throw created.error ?? new Error('Billing fixture user was not created')
|
|
userId = created.data.user.id
|
|
|
|
const { error } = await admin.from('subscriptions').upsert({
|
|
user_id: userId,
|
|
tier: 'free',
|
|
status: 'active',
|
|
provider: 'none',
|
|
payment_provider: 'none',
|
|
auto_renewing: false,
|
|
cancel_at: null,
|
|
current_period_start: null,
|
|
current_period_end: null
|
|
}, { onConflict: 'user_id' })
|
|
if (error) throw error
|
|
})
|
|
|
|
test.afterAll(async () => {
|
|
if (userId) await admin.auth.admin.deleteUser(userId)
|
|
})
|
|
|
|
test('renders real subscription and fails closed before refreshing successful checkout/cancellation', async ({ page }) => {
|
|
let checkoutMode: 'server-error' | 'success' = 'server-error'
|
|
let checkoutCalls = 0
|
|
const idempotencyKeys: string[] = []
|
|
let manageCalls = 0
|
|
|
|
await page.route('https://democpay.payple.kr/js/v1/payment.js', async (route) => {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/javascript',
|
|
body: `
|
|
window.__paypleMode = 'cancel';
|
|
window.PaypleCpayAuthCheck = function (request) {
|
|
window.__lastPayplePayerNo = request.PCD_PAYER_NO;
|
|
if (window.__paypleMode === 'throw') throw new Error('mock sdk failure');
|
|
if (window.__paypleMode === 'cancel') {
|
|
setTimeout(function () {
|
|
request.callbackFunction({ PCD_PAY_RST: 'error', PCD_PAY_MSG: '사용자 취소' });
|
|
}, 0);
|
|
return;
|
|
}
|
|
setTimeout(function () {
|
|
request.callbackFunction({
|
|
PCD_PAY_RST: 'success',
|
|
PCD_PAYER_ID: 'billing-key-e2e',
|
|
PCD_PAY_CARDNAME: 'TEST',
|
|
PCD_PAY_CARDNUM: '1234-****-****-5678'
|
|
});
|
|
}, 0);
|
|
};
|
|
`
|
|
})
|
|
})
|
|
|
|
await page.route('**/functions/v1/payple-checkout', async (route) => {
|
|
checkoutCalls += 1
|
|
const requestBody = route.request().postDataJSON() as { idempotency_key?: unknown }
|
|
if (typeof requestBody.idempotency_key === 'string') idempotencyKeys.push(requestBody.idempotency_key)
|
|
if (checkoutMode === 'server-error') {
|
|
await route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ error: 'mock_failure' }) })
|
|
return
|
|
}
|
|
|
|
const periodEnd = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString()
|
|
const { error } = await admin.from('subscriptions').update({
|
|
tier: 'pro',
|
|
status: 'active',
|
|
provider: 'payple',
|
|
payment_provider: 'payple',
|
|
auto_renewing: true,
|
|
cancel_at: null,
|
|
current_period_start: new Date().toISOString(),
|
|
current_period_end: periodEnd
|
|
}).eq('user_id', userId)
|
|
if (error) throw error
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ success: true, tier: 'pro', order_id: `D3RO-${runId}`, amount: 9900 })
|
|
})
|
|
})
|
|
|
|
await page.route('**/functions/v1/payple-manage', async (route) => {
|
|
manageCalls += 1
|
|
const cancelAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString()
|
|
const { error } = await admin.from('subscriptions').update({
|
|
status: 'canceled',
|
|
auto_renewing: false,
|
|
cancel_at: cancelAt,
|
|
current_period_end: cancelAt
|
|
}).eq('user_id', userId)
|
|
if (error) throw error
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ success: true, cancel_at: cancelAt })
|
|
})
|
|
})
|
|
|
|
await page.goto('/app/login')
|
|
await page.getByPlaceholder('user@studio.com').fill(email)
|
|
await page.getByPlaceholder('••••••••').fill(password)
|
|
await page.getByRole('button', { name: '로그인', exact: true }).click()
|
|
await page.waitForURL(/\/dashboard/, { timeout: 15_000 })
|
|
await page.goto('/app/billing')
|
|
|
|
await expect(page.getByTestId('billing-current-tier')).toHaveText('FREE')
|
|
await expect(page.getByTestId('billing-current-provider')).toContainText('없음')
|
|
await expect(page.getByTestId('billing-account')).toContainText(email)
|
|
const proPlan = page.getByTestId('billing-plan-pro')
|
|
await expect(proPlan.getByTestId('payple-upgrade-pro')).toBeEnabled()
|
|
await expect(page.getByTestId('billing-plan-pro_plus')).toBeVisible()
|
|
|
|
await expect(proPlan.getByRole('button', { name: /Stripe/ })).toHaveCount(0)
|
|
|
|
// 결제 경로가 없어도 과거 provider='stripe' 구독 행은 계속 읽혀야 한다.
|
|
const stripePeriodEnd = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString()
|
|
const { error: stripeFixtureError } = await admin.from('subscriptions').update({
|
|
tier: 'pro',
|
|
status: 'active',
|
|
provider: 'stripe',
|
|
payment_provider: 'stripe',
|
|
auto_renewing: true,
|
|
cancel_at: null,
|
|
current_period_start: new Date().toISOString(),
|
|
current_period_end: stripePeriodEnd
|
|
}).eq('user_id', userId)
|
|
if (stripeFixtureError) throw stripeFixtureError
|
|
await page.reload()
|
|
await expect(page.getByTestId('billing-current-provider')).toContainText('Stripe')
|
|
await expect(page.getByTestId('stripe-portal-open')).toHaveCount(0)
|
|
await expect(page.getByTestId('billing-checkout-pro')).toHaveCount(0)
|
|
|
|
const { error: freeFixtureError } = await admin.from('subscriptions').update({
|
|
tier: 'free',
|
|
status: 'active',
|
|
provider: 'none',
|
|
payment_provider: 'none',
|
|
auto_renewing: false,
|
|
cancel_at: null,
|
|
current_period_start: null,
|
|
current_period_end: null
|
|
}).eq('user_id', userId)
|
|
if (freeFixtureError) throw freeFixtureError
|
|
await page.reload()
|
|
await expect(page.getByTestId('billing-current-tier')).toHaveText('FREE')
|
|
await expect(proPlan.getByTestId('payple-upgrade-pro')).toBeEnabled()
|
|
|
|
await page.evaluate(() => Reflect.set(window, '__paypleMode', 'cancel'))
|
|
await proPlan.getByTestId('payple-upgrade-pro').click()
|
|
await expect(proPlan.getByTestId('payple-error-pro')).toContainText('사용자 취소')
|
|
expect(checkoutCalls).toBe(0)
|
|
|
|
await page.evaluate(() => Reflect.set(window, '__paypleMode', 'throw'))
|
|
await proPlan.getByTestId('payple-upgrade-pro').click()
|
|
await expect(proPlan.getByTestId('payple-error-pro')).toContainText('mock sdk failure')
|
|
expect(checkoutCalls).toBe(0)
|
|
|
|
await page.evaluate(() => Reflect.set(window, '__paypleMode', 'success'))
|
|
await proPlan.getByTestId('payple-upgrade-pro').click()
|
|
await expect(proPlan.getByTestId('payple-error-pro')).toContainText('결제 서버에서 요청을 완료하지 못했습니다')
|
|
expect(checkoutCalls).toBe(1)
|
|
expect(idempotencyKeys[0]).toMatch(/^payple-checkout:[0-9a-f-]{36}$/)
|
|
await expect.poll(async () => {
|
|
const { data } = await admin.from('subscriptions').select('tier').eq('user_id', userId).single()
|
|
return data?.tier
|
|
}).toBe('free')
|
|
|
|
checkoutMode = 'success'
|
|
await proPlan.getByTestId('payple-upgrade-pro').click()
|
|
await expect(proPlan.getByTestId('payple-success-pro')).toBeVisible()
|
|
expect(checkoutCalls).toBe(2)
|
|
expect(idempotencyKeys[1]).toBe(idempotencyKeys[0])
|
|
await expect.poll(() => page.evaluate((key) => window.sessionStorage.getItem(key), PAYPLE_PENDING_CHECKOUT_STORAGE_KEY)).toBeNull()
|
|
const payerNumber = await page.evaluate(() => Reflect.get(window, '__lastPayplePayerNo'))
|
|
expect(payerNumber).toMatch(/^\d{18}$/)
|
|
expect(payerNumber).not.toBe(userId)
|
|
|
|
await expect(page.getByTestId('billing-current-tier')).toHaveText('PRO', { timeout: 10_000 })
|
|
await expect(page.getByTestId('billing-current-provider')).toContainText('Payple')
|
|
await expect(page.getByTestId('payple-manage-open')).toBeEnabled()
|
|
|
|
await page.getByTestId('payple-manage-open').click()
|
|
await page.getByTestId('payple-manage-confirm').click()
|
|
await expect(page.getByTestId('payple-manage-success')).toBeVisible()
|
|
expect(manageCalls).toBe(1)
|
|
await expect(page.getByTestId('billing-cancel-at')).toBeVisible({ timeout: 10_000 })
|
|
await expect(page.getByText('자동 갱신이 해지되었습니다.')).toBeVisible()
|
|
await expect(page.getByTestId('payple-manage-open')).toHaveCount(0)
|
|
})
|
|
})
|