Files
wordpress-theme-aksrt/src/Payment/Gateways/StripeGateway.php
T

443 lines
18 KiB
PHP

<?php
/**
* @package AKSRT
*/
namespace AKSRT\Payment\Gateways;
defined('ABSPATH') || exit;
class StripeGateway implements \AKSRT\Payment\GatewayInterface {
public function get_id(): string {
return 'stripe';
}
public function get_name(): string {
return __('Stripe', 'aksrt');
}
public function is_configured(): bool {
if (class_exists('\AKSRT\Settings\PaymentRuntime')
&& !\AKSRT\Settings\PaymentRuntime::is_gateway_enabled('stripe')) {
return false;
}
$config = $this->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<string,mixed> $order
* @return array<string,string>
*/
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();
}
}