443 lines
18 KiB
PHP
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();
|
|
}
|
|
}
|