Files
sub2api/frontend/src/components/payment/paymentFlow.ts
2026-04-22 00:36:55 +08:00

266 lines
7.8 KiB
TypeScript

import type {
CreateOrderRequest,
CreateOrderResult,
MethodLimit,
OrderType,
WechatJSAPIPayload,
WechatOAuthInfo,
} from '@/types/payment'
export const PAYMENT_RECOVERY_STORAGE_KEY = 'payment.recovery.current'
const VISIBLE_METHOD_ALIASES = {
alipay: 'alipay',
alipay_direct: 'alipay',
wxpay: 'wxpay',
wxpay_direct: 'wxpay',
} as const
export type VisiblePaymentMethod = 'alipay' | 'wxpay'
export type StripeVisibleMethod = 'alipay' | 'wechat_pay'
export type PaymentLaunchKind =
| 'qr_waiting'
| 'redirect_waiting'
| 'stripe_popup'
| 'stripe_route'
| 'wechat_oauth'
| 'wechat_jsapi'
| 'unhandled'
export interface PaymentRecoverySnapshot {
orderId: number
amount: number
qrCode: string
expiresAt: string
paymentType: string
payUrl: string
clientSecret: string
payAmount: number
orderType: OrderType | ''
paymentMode: string
resumeToken: string
createdAt: number
}
export interface PaymentLaunchContext {
visibleMethod: string
orderType: OrderType
isMobile: boolean
isWechatBrowser?: boolean
now?: number
stripePopupUrl?: string
stripeRouteUrl?: string
}
export interface PaymentLaunchDecision {
kind: PaymentLaunchKind
paymentState: PaymentRecoverySnapshot
recovery: PaymentRecoverySnapshot
stripeMethod?: StripeVisibleMethod
oauth?: WechatOAuthInfo
jsapi?: WechatJSAPIPayload
}
export interface BuildCreateOrderPayloadInput {
amount: number
paymentType: string
orderType: OrderType
planId?: number
origin?: string
isWechatBrowser: boolean
}
type CreateOrderFlowResult = CreateOrderResult & {
resume_token?: string
}
type StorageWriter = Pick<Storage, 'removeItem' | 'setItem'>
export function normalizeVisibleMethod(method: string): VisiblePaymentMethod | '' {
const normalized = VISIBLE_METHOD_ALIASES[method.trim() as keyof typeof VISIBLE_METHOD_ALIASES]
return normalized ?? ''
}
export function getVisibleMethods(methods: Record<string, MethodLimit>): Record<string, MethodLimit> {
const visible: Record<string, MethodLimit> = {}
Object.entries(methods).forEach(([type, limit]) => {
const normalized = normalizeVisibleMethod(type)
if (!normalized) return
const isCanonical = type === normalized
const existing = visible[normalized]
if (!existing || isCanonical) {
visible[normalized] = { ...limit }
}
})
return visible
}
export function buildCreateOrderPayload(input: BuildCreateOrderPayloadInput): CreateOrderRequest {
const visibleMethod = normalizeVisibleMethod(input.paymentType) || input.paymentType.trim()
const normalizedOrigin = (input.origin || '').trim().replace(/\/+$/, '')
const payload: CreateOrderRequest = {
amount: input.amount,
payment_type: visibleMethod,
order_type: input.orderType,
payment_source: visibleMethod === 'wxpay' && input.isWechatBrowser
? 'wechat_in_app_resume'
: 'hosted_redirect',
}
if (input.planId) {
payload.plan_id = input.planId
}
if (normalizedOrigin) {
payload.return_url = `${normalizedOrigin}/payment/result`
}
return payload
}
export function decidePaymentLaunch(
result: CreateOrderFlowResult,
context: PaymentLaunchContext,
): PaymentLaunchDecision {
const visibleMethod = normalizeVisibleMethod(context.visibleMethod) || context.visibleMethod
const baseState = createPaymentRecoverySnapshot({
orderId: result.order_id,
amount: result.amount,
qrCode: result.qr_code || '',
expiresAt: result.expires_at || '',
paymentType: visibleMethod,
payUrl: result.pay_url || '',
clientSecret: result.client_secret || '',
payAmount: result.pay_amount,
orderType: context.orderType,
paymentMode: (result.payment_mode || '').trim(),
resumeToken: result.resume_token || '',
}, context.now)
if (baseState.clientSecret) {
const stripeMethod: StripeVisibleMethod = visibleMethod === 'wxpay' ? 'wechat_pay' : 'alipay'
const kind: PaymentLaunchKind = stripeMethod === 'alipay' && !context.isMobile
? 'stripe_popup'
: 'stripe_route'
const payUrl = kind === 'stripe_popup'
? context.stripePopupUrl || context.stripeRouteUrl || ''
: context.stripeRouteUrl || context.stripePopupUrl || ''
const paymentState = { ...baseState, payUrl }
return { kind, paymentState, recovery: paymentState, stripeMethod }
}
if (result.result_type === 'oauth_required' && result.oauth?.authorize_url) {
return { kind: 'wechat_oauth', paymentState: baseState, recovery: baseState, oauth: result.oauth }
}
const jsapiPayload = result.jsapi ?? result.jsapi_payload
if (result.result_type === 'jsapi_ready' && jsapiPayload) {
return { kind: 'wechat_jsapi', paymentState: baseState, recovery: baseState, jsapi: jsapiPayload }
}
const normalizedPaymentMode = baseState.paymentMode.trim().toLowerCase()
const prefersRedirect = normalizedPaymentMode === 'redirect'
|| normalizedPaymentMode === 'popup'
|| (context.isMobile && !!baseState.payUrl)
const prefersQr = normalizedPaymentMode === 'qrcode'
|| normalizedPaymentMode === 'native'
|| (!prefersRedirect && !!baseState.qrCode)
if (visibleMethod === 'wxpay' && context.isWechatBrowser && baseState.payUrl && !baseState.qrCode) {
return { kind: 'redirect_waiting', paymentState: baseState, recovery: baseState }
}
if (prefersRedirect && baseState.payUrl) {
return { kind: 'redirect_waiting', paymentState: baseState, recovery: baseState }
}
if (prefersQr && baseState.qrCode) {
return { kind: 'qr_waiting', paymentState: baseState, recovery: baseState }
}
if (baseState.payUrl) {
return { kind: 'redirect_waiting', paymentState: baseState, recovery: baseState }
}
return { kind: 'unhandled', paymentState: baseState, recovery: baseState }
}
export function createPaymentRecoverySnapshot(
state: Omit<PaymentRecoverySnapshot, 'createdAt'>,
now = Date.now(),
): PaymentRecoverySnapshot {
return {
...state,
createdAt: now,
}
}
export function writePaymentRecoverySnapshot(
storage: StorageWriter,
snapshot: PaymentRecoverySnapshot,
key = PAYMENT_RECOVERY_STORAGE_KEY,
): void {
storage.setItem(key, JSON.stringify(snapshot))
}
export function clearPaymentRecoverySnapshot(
storage: Pick<Storage, 'removeItem'>,
key = PAYMENT_RECOVERY_STORAGE_KEY,
): void {
storage.removeItem(key)
}
export function readPaymentRecoverySnapshot(
raw: string | null | undefined,
options: { now?: number; resumeToken?: string } = {},
): PaymentRecoverySnapshot | null {
if (!raw) return null
try {
const parsed = JSON.parse(raw) as Partial<PaymentRecoverySnapshot>
if (
typeof parsed.orderId !== 'number'
|| typeof parsed.amount !== 'number'
|| typeof parsed.qrCode !== 'string'
|| typeof parsed.expiresAt !== 'string'
|| typeof parsed.paymentType !== 'string'
|| typeof parsed.payUrl !== 'string'
|| typeof parsed.clientSecret !== 'string'
|| typeof parsed.payAmount !== 'number'
|| typeof parsed.paymentMode !== 'string'
|| typeof parsed.resumeToken !== 'string'
|| typeof parsed.createdAt !== 'number'
) {
return null
}
const now = options.now ?? Date.now()
const expiresAt = Date.parse(parsed.expiresAt)
if (Number.isFinite(expiresAt) && expiresAt <= now) {
return null
}
if (options.resumeToken && parsed.resumeToken && parsed.resumeToken !== options.resumeToken) {
return null
}
return {
orderId: parsed.orderId,
amount: parsed.amount,
qrCode: parsed.qrCode,
expiresAt: parsed.expiresAt,
paymentType: parsed.paymentType,
payUrl: parsed.payUrl,
clientSecret: parsed.clientSecret,
payAmount: parsed.payAmount,
orderType: parsed.orderType === 'subscription' ? 'subscription' : 'balance',
paymentMode: parsed.paymentMode,
resumeToken: parsed.resumeToken,
createdAt: parsed.createdAt,
}
} catch {
return null
}
}