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.
187 lines
7 KiB
TypeScript
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')
|
|
})
|