get_config(); return !empty($config['secret_key']) && !empty($config['publishable_key']) && !empty($config['webhook_secret']); } public function create_payment(array $order): array { if (!$this->is_configured()) { return ['success' => false, 'message' => __('Stripe 未配置', 'aksrt')]; } $config = $this->get_config(); $order_number = (string) ($order['order_number'] ?? ''); // Re-pay attempts carry a fresh provider number; the callback resolves it back. $out_trade_no = (string) ($order['out_trade_no'] ?? ''); if ($out_trade_no === '') { $out_trade_no = class_exists('\AKSRT\Payment\OrderRepository') ? \AKSRT\Payment\OrderRepository::provider_out_trade_no($order) : $order_number; } $amount = is_string($order['amount'] ?? null) ? trim($order['amount']) : ''; $subject = $order['subject'] ?? __('订单支付', 'aksrt'); $order_currency = strtoupper((string) ($order['currency'] ?? '')); $currency = strtolower($config['currency'] ?? 'usd'); if ($order_currency === '' || !hash_equals($order_currency, strtoupper($currency))) { return ['success' => false, 'message' => __('Stripe 币种与订单币种不一致', 'aksrt')]; } $smallest_amount = $this->to_smallest_unit($amount, $currency); if ($order_number === '' || $out_trade_no === '' || $smallest_amount === null || $smallest_amount <= 0) { return ['success' => false, 'message' => __('订单金额无效', 'aksrt')]; } try { $stripe = $this->create_client($config); // 创建 Checkout Session $session_params = [ 'payment_method_types' => $this->get_payment_methods($config), 'line_items' => [[ 'price_data' => [ 'currency' => $currency, 'product_data' => [ 'name' => $subject, ], 'unit_amount' => $smallest_amount, ], 'quantity' => 1, ]], 'mode' => 'payment', 'client_reference_id' => $out_trade_no, 'success_url' => home_url('/wp-json/aksrt/v1/payment/stripe/return?session_id={CHECKOUT_SESSION_ID}&order=' . rawurlencode($out_trade_no)), 'cancel_url' => home_url('/wp-json/aksrt/v1/payment/stripe/cancel?order=' . rawurlencode($out_trade_no)), 'metadata' => [ 'order_number' => $out_trade_no, ], ]; // 启用自动支付方式(Apple Pay, Google Pay 等) if (!empty($config['auto_payment_methods'])) { $session_params['automatic_payment_methods'] = [ 'enabled' => true, ]; } $session = \Stripe\Checkout\Session::create($session_params, [ 'api_key' => $config['secret_key'], ]); // Persist the session id before returning the redirect. Without this, // status/refund lookup may miss older sessions after traffic exceeds 100. if ($order_number !== '' && class_exists('\AKSRT\Payment\OrderRepository')) { $persisted = \AKSRT\Payment\OrderRepository::get_instance()->merge_meta($order_number, [ 'stripe_session_id' => (string) $session->id, 'stripe_sessions' => $this->append_session_mapping($order, $out_trade_no, (string) $session->id), ]); if (!$persisted) { try { \Stripe\Checkout\Session::expire((string) $session->id, [ 'api_key' => $config['secret_key'], ]); } catch (\Throwable $e) { // Best-effort cleanup; do not return an untraceable session. } return ['success' => false, 'message' => __('Stripe 支付会话保存失败,请重试', 'aksrt')]; } } return [ 'success' => true, 'payment_url' => $session->url, 'payment_data' => [ 'order_number' => $order_number, 'out_trade_no' => $out_trade_no, 'session_id' => $session->id, ], 'message' => __('Stripe 支付页面已创建', 'aksrt'), ]; } catch (\Stripe\Exception\ApiErrorException $e) { return ['success' => false, 'message' => $e->getMessage()]; } catch (\Exception $e) { return ['success' => false, 'message' => $e->getMessage()]; } } public function verify_callback(\WP_REST_Request $request): array { $config = $this->get_config(); try { $stripe = $this->create_client($config); $payload = $request->get_body(); $sigHeader = $_SERVER['HTTP_STRIPE_SIGNATURE'] ?? ''; // 验证 Webhook 签名 $event = \Stripe\Webhook::constructEvent( $payload, $sigHeader, $config['webhook_secret'] ?? '' ); if ($event->type !== 'checkout.session.completed') { return ['success' => false, 'message' => __('非支付完成事件', 'aksrt')]; } $session = $event->data->object; $orderNumber = $session->metadata->order_number ?? ''; $currency = strtolower((string) ($session->currency ?? $config['currency'] ?? 'usd')); $amount = $this->from_smallest_unit((int) ($session->amount_total ?? 0), $currency); return [ 'success' => true, 'order_number' => $orderNumber, 'amount' => $amount, 'currency' => strtoupper($currency), 'transaction_id' => $session->payment_intent ?? $session->id, 'status' => $session->payment_status === 'paid' ? 'paid' : 'pending', ]; } catch (\Stripe\Exception\SignatureVerificationException $e) { return ['success' => false, 'message' => __('Webhook 签名验证失败', 'aksrt')]; } catch (\Exception $e) { return ['success' => false, 'message' => $e->getMessage()]; } } public function query_status(string $order_number): array { if (!$this->is_configured()) { return ['success' => false, 'message' => __('Stripe 未配置', 'aksrt')]; } $config = $this->get_config(); try { $this->create_client($config); $found = $this->find_session($order_number, $config, true); if ($found) { $status = 'pending'; if ($found->payment_status === 'paid') { $status = 'paid'; } elseif ($found->status === 'expired') { $status = 'cancelled'; } $transaction_id = is_string($found->payment_intent) ? $found->payment_intent : (string) ($found->payment_intent->id ?? ''); return [ 'success' => true, 'order_number' => (string) ($found->metadata->order_number ?? $order_number), 'amount' => $this->from_smallest_unit( (int) ($found->amount_total ?? 0), strtolower((string) ($found->currency ?? $config['currency'] ?? 'usd')) ), 'currency' => strtoupper((string) ($found->currency ?? $config['currency'] ?? 'usd')), 'status' => $status, 'transaction_id' => $transaction_id, ]; } return ['success' => true, 'status' => 'pending', 'message' => __('未找到支付记录', 'aksrt')]; } catch (\Exception $e) { return ['success' => false, 'message' => $e->getMessage()]; } } public function refund(string $order_number, string $amount, string $request_id = ''): array { try { $refund_amount = \AKSRT\Ledger\AssetAmount::balance($amount); } catch (\InvalidArgumentException $e) { return ['success' => false, 'message' => __('退款金额无效', 'aksrt')]; } if ($refund_amount->minor() <= 0) { return ['success' => false, 'message' => __('退款金额无效', 'aksrt')]; } if (!$this->is_configured()) { return ['success' => false, 'message' => __('Stripe 未配置', 'aksrt')]; } $config = $this->get_config(); try { $this->create_client($config); $found = $this->find_session($order_number, $config, true); if (!$found) { return ['success' => false, 'message' => __('未找到支付记录', 'aksrt')]; } $paymentIntentId = is_string($found->payment_intent) ? $found->payment_intent : (string) ($found->payment_intent->id ?? ''); if ($paymentIntentId === '') { return ['success' => false, 'message' => __('未找到支付意图', 'aksrt')]; } $currency = strtolower((string) ($found->currency ?? $config['currency'] ?? 'usd')); $refund_smallest = $this->to_smallest_unit($refund_amount->decimal(), $currency); if ($refund_smallest === null || $refund_smallest <= 0) { return ['success' => false, 'message' => __('退款金额与币种精度不匹配', 'aksrt')]; } $options = ['api_key' => $config['secret_key']]; if ($request_id !== '') { $options['idempotency_key'] = $request_id; } $refund = \Stripe\Refund::create([ 'payment_intent' => $paymentIntentId, 'amount' => $refund_smallest, 'reason' => 'requested_by_customer', 'metadata' => [ 'aksrt_request_id' => $request_id, 'order_number' => $order_number, ], ], $options); return [ 'success' => true, 'status' => ($refund->status ?? '') === 'succeeded' ? 'succeeded' : 'pending', 'refund_id' => $refund->id, 'request_id' => $request_id, 'message' => __('退款请求已提交', 'aksrt'), ]; } catch (\Stripe\Exception\ApiErrorException $e) { return ['success' => false, 'message' => $e->getMessage()]; } catch (\Exception $e) { return ['success' => false, 'message' => $e->getMessage()]; } } public function query_refund(string $request_id, string $order_number = ''): array { if (!$this->is_configured() || $request_id === '') { return ['success' => false, 'status' => 'failed', 'message' => __('Stripe 退款查询参数无效', 'aksrt')]; } try { $config = $this->get_config(); $this->create_client($config); $refunds = \Stripe\Refund::all(['limit' => 100], ['api_key' => $config['secret_key']]); foreach ($refunds->data as $refund) { if (($refund->metadata->aksrt_request_id ?? '') !== $request_id) { continue; } $status = ($refund->status ?? '') === 'succeeded' ? 'succeeded' : (($refund->status ?? '') === 'failed' ? 'failed' : 'pending'); return [ 'success' => true, 'status' => $status, 'refund_id' => (string) ($refund->id ?? ''), ]; } return ['success' => true, 'status' => 'unknown', 'message' => __('未找到 Stripe 退款记录', 'aksrt')]; } catch (\Exception $e) { return ['success' => false, 'status' => 'unknown', 'message' => $e->getMessage()]; } } /** * 获取 Stripe Publishable Key(前端使用)。 */ public function get_publishable_key(): string { $config = $this->get_config(); return $config['publishable_key'] ?? ''; } /** * @param array $order * @return array */ private function append_session_mapping(array $order, string $out_trade_no, string $session_id): array { $meta = is_array($order['meta_payload'] ?? null) ? $order['meta_payload'] : []; $map = is_array($meta['stripe_sessions'] ?? null) ? $meta['stripe_sessions'] : []; $map[$out_trade_no] = $session_id; if (count($map) > 20) { $map = array_slice($map, -20, null, true); } return $map; } /** * Locate the Checkout Session for an order. Prefers the session id persisted * at create time; falls back to scanning recent sessions for legacy orders. * * @return \Stripe\Checkout\Session|null */ private function find_session(string $order_number, array $config, bool $allow_legacy_scan = false) { $session_id = ''; if ($order_number !== '' && class_exists('\AKSRT\Payment\OrderRepository')) { $repo = \AKSRT\Payment\OrderRepository::get_instance(); $order = $repo->resolve_by_out_trade_no($order_number) ?: $repo->get($order_number); if ($order) { $meta = is_array($order['meta_payload'] ?? null) ? $order['meta_payload'] : []; $sessions = is_array($meta['stripe_sessions'] ?? null) ? $meta['stripe_sessions'] : []; $session_id = sanitize_text_field((string) ($sessions[$order_number] ?? '')); if ($session_id === '' && $repo->get($order_number)) { $current_ref = \AKSRT\Payment\OrderRepository::provider_out_trade_no($order); $session_id = sanitize_text_field((string) ($sessions[$current_ref] ?? $meta['stripe_session_id'] ?? '')); } } } if ($session_id !== '') { try { return \Stripe\Checkout\Session::retrieve($session_id, ['api_key' => $config['secret_key']]); } catch (\Exception $e) { // Fall through to the scan below. } } if (!$allow_legacy_scan) { return null; } // Legacy orders created before session-id persistence can only be found // through Stripe's recent-session window. $sessions = \Stripe\Checkout\Session::all(['limit' => 100], ['api_key' => $config['secret_key']]); foreach ($sessions->data as $session) { if (($session->metadata->order_number ?? '') === $order_number) { return $session; } } return null; } private function get_config(): array { if (!class_exists('\AKSRT\Settings\Settings')) { return []; } $all = \AKSRT\Settings\Settings::get_all(); return [ 'secret_key' => (string) ($all['stripe_secret_key'] ?? ''), 'publishable_key' => (string) ($all['stripe_publishable_key'] ?? ''), 'webhook_secret' => (string) ($all['stripe_webhook_secret'] ?? ''), 'currency' => (string) ($all['stripe_currency'] ?? 'usd'), 'mode' => !empty($all['stripe_test_mode']) ? 'test' : 'live', ]; } private function create_client(array $config): void { \Stripe\Stripe::setApiKey($config['secret_key']); // 设置 API 版本 \Stripe\Stripe::setApiVersion('2023-10-16'); // 启用遥测(可选) if (!empty($config['enable_telemetry'])) { \Stripe\Stripe::setEnableTelemetry(true); } } /** * 获取支持的支付方式。 */ private function get_payment_methods(array $config): array { $methods = $config['payment_methods'] ?? ['card']; // 验证支付方式 $valid = ['card', 'alipay', 'wechat_pay', 'sepa_debit', 'ideal', 'bancontact', 'giropay', 'sofort']; return array_values(array_intersect($methods, $valid)) ?: ['card']; } private function from_smallest_unit(int $amount, string $currency): string { if ($amount < 0) { return '0.00'; } $zero_decimal = ['bif', 'clp', 'djf', 'gnf', 'jpy', 'kmf', 'krw', 'mga', 'pyg', 'rwf', 'ugx', 'vnd', 'vuv', 'xaf', 'xof', 'xpf']; if (in_array(strtolower($currency), $zero_decimal, true)) { if ($amount > intdiv(PHP_INT_MAX, 100)) { return '0.00'; } return \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, $amount * 100)->decimal(); } return \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, $amount)->decimal(); } /** * 将金额转换为最小单位(分/cent)。 */ private function to_smallest_unit(string $amount, string $currency): ?int { try { $parsed = \AKSRT\Ledger\AssetAmount::balance($amount); } catch (\InvalidArgumentException $e) { return null; } // 零小数货币(日元、韩元等) $zeroDecimal = ['bif', 'clp', 'djf', 'gnf', 'jpy', 'kmf', 'krw', 'mga', 'pyg', 'rwf', 'ugx', 'vnd', 'vuv', 'xaf', 'xof', 'xpf']; if (in_array(strtolower($currency), $zeroDecimal, true)) { return $parsed->minor() % 100 === 0 ? intdiv($parsed->minor(), 100) : null; } return $parsed->minor(); } }