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.
242 lines
8.2 KiB
TypeScript
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,
|
|
}
|
|
}
|