import { type CorrelatedPayment, decideWebhookTransition, type WebhookTransitionInput, } from './webhook-policy.ts' import type { PayplePaymentLookupResult } from '../_shared/payple.ts' function assert(condition: boolean, message: string): asserts condition { if (!condition) throw new Error(message) } function assertEquals(actual: unknown, expected: unknown, message: string): void { const a = JSON.stringify(actual) const e = JSON.stringify(expected) if (a !== e) throw new Error(`${message}: expected ${e}, got ${a}`) } const OLD_ORDER = 'D3RO-20260801090000-user-oid1' const CURRENT_ORDER = 'D3RO-20260901090000-user-oid2' const NOW = new Date('2026-09-15T00:00:00.000Z') function correlated(overrides: Partial = {}): CorrelatedPayment { return { user_id: '00000000-0000-4000-8000-000000000001', tier: 'pro', operation_id: '00000000-0000-4000-8000-0000000000aa', payer_id: 'payer-billing-key', current_order_id: CURRENT_ORDER, ...overrides, } } function lookup(orderId: string, overrides: Partial = {}): PayplePaymentLookupResult { return { PCD_PAY_RST: 'success', PCD_PAY_CODE: 'PCHK0000', PCD_PAY_MSG: 'ok', PCD_PAY_OID: orderId, PCD_PAY_TYPE: 'card', PCD_PAYER_ID: 'payer-billing-key', PCD_PAY_TOTAL: '9900', PCD_PAY_TIME: '20260901090000', ...overrides, } } function input(overrides: Partial): WebhookTransitionInput { return { kind: 'payment', orderId: CURRENT_ORDER, lookup: lookup(CURRENT_ORDER), correlated: correlated(), expectedAmount: 9900, now: NOW, ...overrides, } } Deno.test('payment of the current order grants the correlated tier for one period', () => { const decision = decideWebhookTransition(input({})) assert(decision.kind === 'apply', `expected apply, got ${decision.kind}`) assertEquals(decision.amount, 9900, 'amount') const args = decision.args assertEquals(args.p_event_id, `payment:${CURRENT_ORDER}`, 'event id') assertEquals(args.p_event_type, 'payment.completed', 'event type') assertEquals(args.p_entitled, true, 'entitled') assertEquals(args.p_tier, 'pro', 'tier') assertEquals(args.p_status, 'active', 'status') assertEquals(args.p_auto_renewing, true, 'auto renewing') assertEquals(args.p_provider_order_id, CURRENT_ORDER, 'order id') assertEquals(args.p_provider_resource_id, 'payer-billing-key', 'resource id') // 20260901090000 KST = 2026-09-01T00:00:00Z assertEquals(args.p_event_created_at, '2026-09-01T00:00:00.000Z', 'event time is payment time') assertEquals(args.p_current_period_start, '2026-09-01T00:00:00.000Z', 'period start') assertEquals(args.p_current_period_end, '2026-10-01T00:00:00.000Z', 'period end') assertEquals(args.p_cancel_at, null, 'no cancel_at') }) Deno.test('payment of an older registered order is still applied (ordering is the RPC cursor)', () => { const decision = decideWebhookTransition(input({ orderId: OLD_ORDER, lookup: lookup(OLD_ORDER, { PCD_PAY_TIME: '20260801090000' }), })) assert(decision.kind === 'apply', 'payments are not order-scoped') }) Deno.test('payment whose reconciled amount differs from the tier price is rejected', () => { for (const total of ['29900', 'not-a-number', undefined]) { const decision = decideWebhookTransition(input({ lookup: lookup(CURRENT_ORDER, { PCD_PAY_TOTAL: total }), })) assertEquals(decision, { kind: 'reject', status: 422, error: 'payment_amount_mismatch' }, `total ${total}`) } const unpricedTier = decideWebhookTransition(input({ expectedAmount: undefined })) assertEquals(unpricedTier.kind, 'reject', 'tier without a price cannot be paid') }) Deno.test('lookup payer different from the correlated payer is rejected', () => { const decision = decideWebhookTransition(input({ lookup: lookup(CURRENT_ORDER, { PCD_PAYER_ID: 'payer-other' }), })) assertEquals(decision, { kind: 'reject', status: 401, error: 'payment_owner_mismatch' }, 'owner') }) Deno.test('cancellation not confirmed by the Payple lookup is rejected', () => { for (const state of [undefined, '승인완료']) { const decision = decideWebhookTransition(input({ kind: 'cancellation', lookup: lookup(CURRENT_ORDER, { PCD_PAY_STATE: state }), })) assertEquals(decision, { kind: 'reject', status: 409, error: 'cancellation_not_confirmed' }, `state ${state}`) } }) Deno.test('confirmed cancellation of the current order revokes entitlement now', () => { for (const state of ['승인취소완료', 'canceled']) { const decision = decideWebhookTransition(input({ kind: 'cancellation', lookup: lookup(CURRENT_ORDER, { PCD_PAY_STATE: state, PCD_PAY_TOTAL: '1' }), })) assert(decision.kind === 'apply', `expected apply for ${state}`) const args = decision.args assertEquals(args.p_event_id, `cancel:${CURRENT_ORDER}:${state}`, 'event id') assertEquals(args.p_event_type, 'webhook.payment_canceled', 'event type') assertEquals(args.p_entitled, false, 'not entitled') assertEquals(args.p_tier, 'free', 'tier') assertEquals(args.p_status, 'canceled', 'status') assertEquals(args.p_auto_renewing, false, 'auto renewing') assertEquals(args.p_event_created_at, NOW.toISOString(), 'event time is now') assertEquals(args.p_current_period_start, null, 'period start untouched') assertEquals(args.p_current_period_end, NOW.toISOString(), 'period ends now') assertEquals(args.p_cancel_at, NOW.toISOString(), 'cancel_at now') assertEquals(args.p_provider_order_id, CURRENT_ORDER, 'order id') } }) Deno.test('confirmed cancellation of an older order is ignored and keeps the paid period', () => { const decision = decideWebhookTransition(input({ kind: 'cancellation', orderId: OLD_ORDER, lookup: lookup(OLD_ORDER, { PCD_PAY_STATE: '승인취소완료', PCD_PAY_TIME: '20260801090000' }), })) assertEquals(decision, { kind: 'ignore', reason: 'canceled_order_not_current', eventId: `cancel:${OLD_ORDER}:승인취소완료`, eventCreatedAt: NOW.toISOString(), eventType: 'webhook.payment_canceled', providerResourceId: 'payer-billing-key', }, 'old order refund must not revoke the current subscription') }) Deno.test('confirmed cancellation while no current order is on record is ignored', () => { const decision = decideWebhookTransition(input({ kind: 'cancellation', correlated: correlated({ current_order_id: null }), lookup: lookup(CURRENT_ORDER, { PCD_PAY_STATE: '승인취소완료' }), })) assertEquals(decision.kind, 'ignore', 'fail safe: unknown current order never revokes') })