d3ro-voice/server/supabase/functions/payple-webhook/webhook-policy.ts
Yun Chan ba9ef9741e fix: red-team round 3 hardening across desktop, mobile, core and server
Batch of red-team r3 fixes that were in the working tree before the
2026-09-28 design overhaul, committed as one unit with their tests.

- desktop main: STT timeouts and sidecar, voice recording store, sync
  (credentials, audio, knowledge reindex, push gates), runtime
  provisioner, update policy, AltGr keybindings, voice-command policy,
  dictionary file codec/limits, meeting transcript condensing and a
  local recording ledger so interrupted-session recovery only closes
  meetings this device recorded (a phone's live meeting is left alone).
- mobile: login CSRF via implicit token callbacks rejected, account
  deletion/retention, durable queue retention, knowledge realtime
  without unfiltered DELETE, meeting re-record failure paths, cloud STT
  client, preferences store/resync.
- core: text chunking splits long unbroken transcripts to fit, template
  field policy, dictionary limits, meeting markdown inline handling.
- server: payple webhook policy and cancellation order scope, meeting
  document generation quota, team RPC null-role guard, unified LLM
  quota in-flight accounting, knowledge chunk vector index, meeting
  re-record failure paths (migrations 20260929*).
- ci: portable/runtime feed gates, update-policy schema, Forgejo file
  delete and alias planning.

Four older tests are updated to the new contracts rather than the old
behavior: token-pair auth callbacks are rejected, knowledge realtime no
longer subscribes to DELETE, long transcript lines are split, and
meeting recovery requires the local recording ledger for empty rows.
2026-09-28 20:45:52 +09:00

242 lines
8.2 KiB
TypeScript

// Pure decision rules for the Payple webhook edge function. No IO lives here:
// index.ts owns the Supabase/Payple calls and asks these functions what a
// reconciled webhook means for the user's entitlement, so the rules (which
// order may revoke a subscription, which amount is acceptable, ...) are
// unit-testable without a network or a database.
import {
calcSubscriptionPeriod,
parsePaypleTimestamp,
payplePaymentEventId,
PaypleVerificationError,
type PayplePaymentLookupResult,
} from '../_shared/payple.ts'
export interface PaypleWebhookPayload {
PCD_PAY_RST?: unknown
PCD_PAY_CODE?: unknown
PCD_PAY_MSG?: unknown
PCD_PAY_TYPE?: unknown
PCD_PAY_OID?: unknown
PCD_PAY_TOTAL?: unknown
PCD_PAYER_ID?: unknown
PCD_PAYER_NO?: unknown
PCD_PAY_TIME?: unknown
PCD_PAY_WORK?: unknown
PCD_PAYCANCEL_FLAG?: unknown
PCD_PAY_CARDTRADENUM?: unknown
}
export type WebhookKind = 'payment' | 'cancellation' | 'billing_key_revoked' | 'unsupported'
/** The webhook kinds that are reconciled against Payple and may change entitlement. */
export type ReconciledWebhookKind = 'payment' | 'cancellation'
export type PaypleTier = 'pro' | 'pro_plus'
export interface CorrelatedPayment {
user_id: string
tier: PaypleTier
operation_id: string | null
payer_id: string
/**
* subscriptions.payple_pay_oid at correlation time: the order that funds the
* current paid period. null when the user has no Payple order on record.
*/
current_order_id: string | null
}
export function stringValue(value: unknown): string | null {
return typeof value === 'string' && value.length > 0 ? value : null
}
export function normalizeTier(value: unknown): PaypleTier | null {
if (value === 'pro') return 'pro'
if (value === 'pro_plus' || value === 'team') return 'pro_plus'
return null
}
export function classifyPaypleWebhook(payload: PaypleWebhookPayload): WebhookKind {
if (payload.PCD_PAY_WORK === 'PUSERDEL') return 'billing_key_revoked'
if (
payload.PCD_PAYCANCEL_FLAG === 'Y'
|| (typeof payload.PCD_PAY_CODE === 'string' && payload.PCD_PAY_CODE.startsWith('PAYC'))
) {
return 'cancellation'
}
if (payload.PCD_PAY_RST === 'success' && payload.PCD_PAY_OID) return 'payment'
return 'unsupported'
}
export function validateReconciledPaypleEvent(
payload: PaypleWebhookPayload,
lookup: PayplePaymentLookupResult,
): void {
const orderId = stringValue(payload.PCD_PAY_OID)
const payType = stringValue(payload.PCD_PAY_TYPE)
const payerId = stringValue(payload.PCD_PAYER_ID)
if (!orderId || lookup.PCD_PAY_OID !== orderId || lookup.PCD_PAY_RST !== 'success') {
throw new PaypleVerificationError('payple_webhook_order_mismatch')
}
if (payType && lookup.PCD_PAY_TYPE !== payType) {
throw new PaypleVerificationError('payple_webhook_type_mismatch')
}
if (payerId && lookup.PCD_PAYER_ID && lookup.PCD_PAYER_ID !== payerId) {
throw new PaypleVerificationError('payple_webhook_payer_mismatch')
}
const payloadTotal = stringValue(payload.PCD_PAY_TOTAL)
if (payloadTotal && lookup.PCD_PAY_TOTAL && Number(payloadTotal) !== Number(lookup.PCD_PAY_TOTAL)) {
throw new PaypleVerificationError('payple_webhook_amount_mismatch')
}
}
/** Payple's PayChkAct state is the only authority that an order was canceled. */
export function isAuthoritativeCancellation(lookup: PayplePaymentLookupResult): boolean {
return lookup.PCD_PAY_STATE === '승인취소완료' || lookup.PCD_PAY_STATE === 'canceled'
}
/**
* apply_payment_provider_event arguments, minus p_payload_digest (the digest
* is an async hash computed by the IO layer from the event it forwards).
*/
export type PaypleProviderEventArgs = {
p_user_id: string
p_provider: 'payple'
p_event_id: string
p_event_created_at: string
p_event_type: 'payment.completed' | 'webhook.payment_canceled'
p_provider_resource_id: string
p_tier: 'free' | PaypleTier
p_status: 'active' | 'canceled'
p_entitled: boolean
p_current_period_start: string | null
p_current_period_end: string
p_cancel_at: string | null
p_auto_renewing: boolean
p_provider_customer_id: string
p_provider_order_id: string
p_store_product_id: null
p_store_purchase_id: null
p_operation_id: string | null
}
export interface WebhookTransitionInput {
kind: ReconciledWebhookKind
orderId: string
lookup: PayplePaymentLookupResult
correlated: CorrelatedPayment
/** Payple price of the correlated tier; undefined when the tier has no price. */
expectedAmount: number | undefined
now: Date
}
export type IgnoredWebhookReason = 'canceled_order_not_current'
export type WebhookTransition =
| { kind: 'reject'; status: 401 | 409 | 422; error: string }
| {
kind: 'ignore'
reason: IgnoredWebhookReason
eventId: string
eventCreatedAt: string
eventType: 'webhook.payment_canceled'
providerResourceId: string
}
| { kind: 'apply'; amount: number; args: PaypleProviderEventArgs }
function cancellationEventId(orderId: string, lookup: PayplePaymentLookupResult): string {
return `cancel:${orderId}:${lookup.PCD_PAY_STATE ?? 'confirmed'}`
}
/**
* Decides what a reconciled Payple webhook does to the subscription.
*
* - The Payple lookup's payer must be the correlated payer (401).
* - A payment must match the correlated tier's price exactly (422).
* - A cancellation must be confirmed by Payple's own lookup state (409).
* - A confirmed cancellation revokes entitlement only when the canceled order
* is the one that funds the current period (subscriptions.payple_pay_oid).
* A refund of an older order (last month's checkout, a duplicate charge) —
* or of any order while no current order is on record — is ignored, because
* every order on the same billing key shares the payer id and would
* otherwise pass the database's resource-ownership check.
*/
export function decideWebhookTransition(input: WebhookTransitionInput): WebhookTransition {
const { kind, orderId, lookup, correlated, now } = input
if (lookup.PCD_PAYER_ID && lookup.PCD_PAYER_ID !== correlated.payer_id) {
return { kind: 'reject', status: 401, error: 'payment_owner_mismatch' }
}
const amount = Number(lookup.PCD_PAY_TOTAL)
if (kind === 'payment' && (!Number.isFinite(amount) || amount !== input.expectedAmount)) {
return { kind: 'reject', status: 422, error: 'payment_amount_mismatch' }
}
const paymentTime = lookup.PCD_PAY_TIME ? parsePaypleTimestamp(lookup.PCD_PAY_TIME) : now
if (kind === 'cancellation') {
if (!isAuthoritativeCancellation(lookup)) {
return { kind: 'reject', status: 409, error: 'cancellation_not_confirmed' }
}
const eventId = cancellationEventId(orderId, lookup)
const eventCreatedAt = now.toISOString()
if (correlated.current_order_id !== orderId) {
return {
kind: 'ignore',
reason: 'canceled_order_not_current',
eventId,
eventCreatedAt,
eventType: 'webhook.payment_canceled',
providerResourceId: correlated.payer_id,
}
}
return {
kind: 'apply',
amount,
args: {
...baseArgs(correlated, orderId),
p_event_id: eventId,
p_event_created_at: eventCreatedAt,
p_event_type: 'webhook.payment_canceled',
p_tier: 'free',
p_status: 'canceled',
p_entitled: false,
p_current_period_start: null,
p_current_period_end: eventCreatedAt,
p_cancel_at: eventCreatedAt,
p_auto_renewing: false,
},
}
}
const { start, end } = calcSubscriptionPeriod(paymentTime)
return {
kind: 'apply',
amount,
args: {
...baseArgs(correlated, orderId),
p_event_id: payplePaymentEventId(orderId),
p_event_created_at: paymentTime.toISOString(),
p_event_type: 'payment.completed',
p_tier: correlated.tier,
p_status: 'active',
p_entitled: true,
p_current_period_start: start,
p_current_period_end: end,
p_cancel_at: null,
p_auto_renewing: true,
},
}
}
function baseArgs(correlated: CorrelatedPayment, orderId: string) {
return {
p_user_id: correlated.user_id,
p_provider: 'payple' as const,
p_provider_resource_id: correlated.payer_id,
p_provider_customer_id: correlated.payer_id,
p_provider_order_id: orderId,
p_store_product_id: null,
p_store_purchase_id: null,
p_operation_id: correlated.operation_id,
}
}