d3ro-voice/server/supabase/functions/_shared/payple.test.ts
Yun Chan 88f24d84a1 refactor(core): keep plan prices, cloud quotas and public URLs in one contract (WS-A)
Prices, quotas and site URLs were copied by hand into the edge functions,
admin, desktop and the landing site, and the copies disagreed (Payple billed
9,900/29,900 KRW, admin labels said 12,900/24,900 KRW and $9.9/$19.9, the
site said 2,900/8,900 KRW).

- packages/core/src/plan-catalog.ts is the single source for PLAN_PRICE_KRW
  (Free 0 / Pro 2,900 / Pro+ 8,900 a month) and PLAN_QUOTA.
- packages/core/src/web-urls.ts is the single source for the public origin,
  the /app web-app base path, SITE_URLS and billingUrl().
- Deno cannot bundle packages/core, so scripts/ci/sync-core-contract.mjs
  generates _shared/core-contract.generated.ts; `npm run contract:check`
  fails on drift (same pattern as version:sync).
- Payple checkout, renewal and webhook amount checks now bill the catalog
  price, so existing subscribers move to the new price at their next renewal.
  quota.ts, team-contract.ts and the tests read the generated values.
- Admin MRR/ARR is computed in KRW from the catalog; license labels, the
  release link and desktop PREMIUM_LLM limits derive from core; the site
  imports prices and quotas directly.

Policy: docs/REFACTOR_POLICY.md Wave 3, W3-1 and W3-2.
2026-09-26 15:48:18 +09:00

187 lines
7 KiB
TypeScript

import {
buildPaypleConfig,
generateOrderId,
parsePaypleTimestamp,
paypleAuth,
paypleBilling,
PaypleBillingError,
payplePaymentEventDigest,
payplePaymentEventId,
payplePayerNumber,
PaypleConfigurationError,
PaypleVerificationError,
resolvePaypleOrderDate,
TIER_PRICE,
} from './payple.ts'
import { PLAN_PRICE_KRW } from './core-contract.generated.ts'
function assert(condition: boolean, message: string): asserts condition {
if (!condition) throw new Error(message)
}
const validConfig = {
PAYPLE_ENVIRONMENT: 'live',
PAYPLE_CST_ID: 'merchant-id',
PAYPLE_CUST_KEY: 'merchant-secret',
PAYPLE_REFUND_KEY: 'refund-secret',
PAYPLE_CLIENT_KEY: 'client-key',
PAYPLE_SITE_URL: 'https://d3ro.chanpaca.net/payments',
}
Deno.test('Payple configuration has no implicit demo credential fallback', () => {
for (const missing of Object.keys(validConfig)) {
const candidate = { ...validConfig, [missing]: undefined }
let error: unknown
try {
buildPaypleConfig(candidate)
} catch (caught) {
error = caught
}
assert(error instanceof PaypleConfigurationError, `${missing} must be required`)
}
})
Deno.test('Payple environment selects only the official fixed API host', () => {
const live = buildPaypleConfig(validConfig)
assert(live.baseUrl === 'https://cpay.payple.kr', 'live host')
assert(live.siteUrl === 'https://d3ro.chanpaca.net', 'referer uses registered origin')
const test = buildPaypleConfig({ ...validConfig, PAYPLE_ENVIRONMENT: 'test' })
assert(test.baseUrl === 'https://democpay.payple.kr', 'test host')
})
Deno.test('Payple timestamps are interpreted as Korea standard time', () => {
const timestamp = parsePaypleTimestamp('20260821153045')
assert(timestamp.toISOString() === '2026-08-21T06:30:45.000Z', 'KST conversion')
assert(
parsePaypleTimestamp('2026-08-21 15:30:45').toISOString() === timestamp.toISOString(),
'documented formatted timestamp',
)
let error: unknown
try {
parsePaypleTimestamp('2026-99-99')
} catch (caught) {
error = caught
}
assert(error instanceof PaypleVerificationError, 'invalid timestamp must fail')
})
Deno.test('Payple order identifiers are unique and carry a reconcilable payment date', () => {
const first = generateOrderId('11111111-2222-4333-8444-555555555555', 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee')
const second = generateOrderId('11111111-2222-4333-8444-555555555555', 'ffffffff-bbbb-4ccc-8ddd-eeeeeeeeeeee')
assert(first !== second, 'nonce must prevent same-second collision')
assert(/^[A-Za-z0-9._-]{8,64}$/.test(first), 'Payple order syntax')
assert(resolvePaypleOrderDate(first) === first.slice(5, 13), 'order date is recoverable')
assert(resolvePaypleOrderDate('legacy-order', '20260821153045') === '20260821', 'legacy fallback date')
assert(
resolvePaypleOrderDate('D3RO-20260820153045-test-nonce', '20260821003045') === '20260821',
'authoritative Payple time wins across the Korea/UTC date boundary',
)
})
Deno.test('Payple payer number is deterministic, numeric and user-specific', async () => {
const first = await payplePayerNumber('11111111-2222-4333-8444-555555555555')
const same = await payplePayerNumber('11111111-2222-4333-8444-555555555555')
const other = await payplePayerNumber('aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee')
assert(/^\d{18}$/.test(first), 'payer number must satisfy Payple 18-digit contract')
assert(first === same, 'same user must map consistently')
assert(first !== other, 'different users must not share the fixture mapping')
})
Deno.test('Payple synchronous charge and webhook share one provider event identity', async () => {
const identity = {
orderId: 'D3RO-20260821153045-test-nonce',
payerId: 'payer-fixture',
payType: 'card' as const,
amount: TIER_PRICE.pro,
}
const directDigest = await payplePaymentEventDigest(identity)
const webhookDigest = await payplePaymentEventDigest({ ...identity })
const differentCharge = await payplePaymentEventDigest({ ...identity, amount: TIER_PRICE.pro_plus })
assert(payplePaymentEventId(identity.orderId) === `payment:${identity.orderId}`, 'canonical event id')
assert(directDigest === webhookDigest, 'same external charge must be an exact replay')
assert(directDigest !== differentCharge, 'different charge identity must not collide')
})
Deno.test('Payple auth response cannot redirect server calls to another origin', async () => {
const originalFetch = globalThis.fetch
globalThis.fetch = () => Promise.resolve(new Response(JSON.stringify({
result: 'success',
cst_id: 'encrypted-id',
custKey: 'encrypted-key',
AuthKey: 'auth-key',
PCD_PAY_HOST: 'https://cpay.payple.kr.evil.example',
PCD_PAY_URL: '/php/PayChkAct.php',
}), { status: 200, headers: { 'Content-Type': 'application/json' } }))
let error: unknown
try {
await paypleAuth(buildPaypleConfig(validConfig), { payCheckFlag: true })
} catch (caught) {
error = caught
} finally {
globalThis.fetch = originalFetch
}
assert(
error instanceof PaypleVerificationError
&& error.code === 'payple_untrusted_api_host',
'untrusted Payple response host must fail closed',
)
})
Deno.test('Payple billing separates definitive declines from ambiguous transport outcomes', async () => {
const config = buildPaypleConfig(validConfig)
const auth = {
PCD_CST_ID: 'encrypted-id',
PCD_CUST_KEY: 'encrypted-key',
PCD_AUTH_KEY: 'auth-key',
PCD_PAY_HOST: 'https://cpay.payple.kr',
PCD_PAY_URL: '',
}
const originalFetch = globalThis.fetch
const cases: Array<{
fetcher: typeof fetch
definitive: boolean
}> = [
{
fetcher: () => Promise.reject(new TypeError('connection reset')),
definitive: false,
},
{
fetcher: () => Promise.resolve(new Response('upstream unavailable', { status: 503 })),
definitive: false,
},
{
fetcher: () => Promise.resolve(new Response(JSON.stringify({
PCD_PAY_RST: 'error',
PCD_PAY_CODE: 'BILL0001',
PCD_PAY_MSG: 'declined',
}), { status: 200, headers: { 'Content-Type': 'application/json' } })),
definitive: true,
},
]
try {
for (const testCase of cases) {
globalThis.fetch = testCase.fetcher
let error: unknown
try {
await paypleBilling(config, auth, {
payerId: 'payer-fixture',
amount: TIER_PRICE.pro,
orderId: 'D3RO-20260821153045-test-nonce',
goodsName: 'D3RO Voice Pro',
})
} catch (caught) {
error = caught
}
assert(error instanceof PaypleBillingError, 'billing failure must be typed')
assert(error.definitive === testCase.definitive, 'billing certainty classification')
}
} finally {
globalThis.fetch = originalFetch
}
})
Deno.test('Payple charge amounts are the core plan catalog prices', () => {
assert(TIER_PRICE.pro === PLAN_PRICE_KRW.pro, 'Pro charge must equal PLAN_PRICE_KRW.pro')
assert(TIER_PRICE.pro_plus === PLAN_PRICE_KRW.pro_plus, 'Pro+ charge must equal PLAN_PRICE_KRW.pro_plus')
assert(Object.keys(TIER_PRICE).sort().join(',') === 'pro,pro_plus', 'only paid tiers are chargeable')
})