425 lines
14 KiB
PHP
425 lines
14 KiB
PHP
<?php
|
|
/**
|
|
* WeChat Pay gateway via yansongda/pay (API v3).
|
|
*
|
|
* @package AKSRT
|
|
*/
|
|
|
|
namespace AKSRT\Payment\Gateways;
|
|
|
|
defined('ABSPATH') || exit;
|
|
|
|
use Yansongda\Pay\Pay;
|
|
|
|
class WechatPayGateway implements \AKSRT\Payment\GatewayInterface {
|
|
|
|
public function get_id(): string {
|
|
return 'wechatpay';
|
|
}
|
|
|
|
public function get_name(): string {
|
|
return __('微信支付', 'aksrt');
|
|
}
|
|
|
|
public function is_configured(): bool {
|
|
if (class_exists('\AKSRT\Settings\PaymentRuntime')
|
|
&& !\AKSRT\Settings\PaymentRuntime::is_gateway_enabled('wechatpay')) {
|
|
return false;
|
|
}
|
|
$c = $this->get_config();
|
|
return $c['mch_id'] !== ''
|
|
&& $c['appid'] !== ''
|
|
&& $c['api_v3_key'] !== ''
|
|
&& $c['private_key'] !== ''
|
|
&& $c['cert_path'] !== '';
|
|
}
|
|
|
|
public function create_payment(array $order): array {
|
|
if (!$this->is_configured()) {
|
|
return ['success' => false, 'message' => __('微信支付未配置', 'aksrt')];
|
|
}
|
|
|
|
$order_number = (string) ($order['order_number'] ?? '');
|
|
// Prefer attempt-specific out_trade_no so re-pay does not reuse a frozen provider order.
|
|
$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;
|
|
}
|
|
$subject = (string) ($order['subject'] ?? __('订单支付', 'aksrt'));
|
|
$payment_type = $this->get_config()['payment_type'];
|
|
try {
|
|
$total_fen = \AKSRT\Ledger\AssetAmount::balance(is_string($order['amount'] ?? null) ? trim($order['amount']) : '')->minor();
|
|
} catch (\InvalidArgumentException $e) {
|
|
$total_fen = 0;
|
|
}
|
|
|
|
if ($order_number === '' || $out_trade_no === '' || $total_fen <= 0) {
|
|
return ['success' => false, 'message' => __('订单参数无效', 'aksrt')];
|
|
}
|
|
|
|
try {
|
|
YansongdaPayFactory::boot_wechat();
|
|
|
|
$payload = [
|
|
'out_trade_no' => $out_trade_no,
|
|
'description' => mb_substr($subject, 0, 127),
|
|
'amount' => [
|
|
'total' => $total_fen,
|
|
'currency' => 'CNY',
|
|
],
|
|
];
|
|
|
|
switch ($payment_type) {
|
|
case 'h5':
|
|
$payload['scene_info'] = [
|
|
'payer_client_ip' => (string) ($_SERVER['REMOTE_ADDR'] ?? '127.0.0.1'),
|
|
'h5_info' => ['type' => 'Wap'],
|
|
];
|
|
$result = Pay::wechat()->h5($payload);
|
|
break;
|
|
case 'native':
|
|
default:
|
|
$result = Pay::wechat()->scan($payload);
|
|
break;
|
|
}
|
|
|
|
$data = YansongdaPayFactory::to_array($result);
|
|
$payment_data = array_merge($data, [
|
|
'order_number' => $order_number,
|
|
'out_trade_no' => $out_trade_no,
|
|
]);
|
|
|
|
$result_data = [
|
|
'success' => true,
|
|
'payment_data' => $payment_data,
|
|
'message' => __('微信支付订单已创建', 'aksrt'),
|
|
];
|
|
|
|
if ($payment_type === 'native' && !empty($data['code_url'])) {
|
|
$result_data['payment_url'] = (string) $data['code_url'];
|
|
} elseif ($payment_type === 'h5' && !empty($data['h5_url'])) {
|
|
$result_data['payment_url'] = (string) $data['h5_url'];
|
|
}
|
|
|
|
return $result_data;
|
|
} catch (\Throwable $e) {
|
|
return ['success' => false, 'message' => $e->getMessage()];
|
|
}
|
|
}
|
|
|
|
public function verify_callback(\WP_REST_Request $request): array {
|
|
try {
|
|
YansongdaPayFactory::boot_wechat();
|
|
|
|
// Pass raw body + headers so callback works under WP REST (php://input may be drained).
|
|
$raw = $request->get_body();
|
|
$headers = [
|
|
'Content-Type' => (string) ($request->get_header('content-type') ?: 'application/json'),
|
|
'Wechatpay-Serial' => (string) $request->get_header('wechatpay-serial'),
|
|
'Wechatpay-Timestamp' => (string) $request->get_header('wechatpay-timestamp'),
|
|
'Wechatpay-Nonce' => (string) $request->get_header('wechatpay-nonce'),
|
|
'Wechatpay-Signature' => (string) $request->get_header('wechatpay-signature'),
|
|
];
|
|
$callback = Pay::wechat()->callback([
|
|
'body' => $raw !== '' ? $raw : file_get_contents('php://input'),
|
|
'headers' => $headers,
|
|
]);
|
|
|
|
$data = YansongdaPayFactory::to_array($callback);
|
|
// Decrypted resource is nested under resource (array) after CallbackPlugin.
|
|
$resource = [];
|
|
if (isset($data['resource']) && is_array($data['resource'])) {
|
|
$resource = $data['resource'];
|
|
// ciphertext decrypt may leave plaintext fields at resource root or under ciphertext key.
|
|
if (isset($resource['ciphertext']) && is_array($resource['ciphertext'])) {
|
|
$resource = $resource['ciphertext'];
|
|
}
|
|
}
|
|
|
|
$order_number = (string) ($resource['out_trade_no'] ?? $data['out_trade_no'] ?? '');
|
|
$transaction_id = (string) ($resource['transaction_id'] ?? $data['transaction_id'] ?? '');
|
|
$trade_state = (string) ($resource['trade_state'] ?? $data['trade_state'] ?? '');
|
|
$event_type = (string) ($data['event_type'] ?? '');
|
|
|
|
$total_fen = (int) YansongdaPayFactory::dig($resource, 'amount.total', 0);
|
|
if ($total_fen <= 0) {
|
|
$total_fen = (int) YansongdaPayFactory::dig($data, 'amount.total', 0);
|
|
}
|
|
|
|
$paid = ($trade_state === 'SUCCESS') || ($event_type === 'TRANSACTION.SUCCESS');
|
|
|
|
// When event is pay success, resource has trade_state SUCCESS.
|
|
if ($event_type !== '' && $event_type !== 'TRANSACTION.SUCCESS' && $trade_state !== 'SUCCESS') {
|
|
$paid = false;
|
|
}
|
|
|
|
$config = $this->get_config();
|
|
$mch_id = (string) ($resource['mchid'] ?? $data['mchid'] ?? '');
|
|
$app_id = (string) ($resource['appid'] ?? $data['appid'] ?? '');
|
|
$currency = (string) YansongdaPayFactory::dig($resource, 'amount.currency', '');
|
|
if ($order_number === ''
|
|
|| ($paid && (
|
|
$mch_id === ''
|
|
|| $app_id === ''
|
|
|| $currency === ''
|
|
|| !hash_equals($config['mch_id'], $mch_id)
|
|
|| !hash_equals($config['appid'], $app_id)
|
|
|| $currency !== 'CNY'
|
|
|| $transaction_id === ''
|
|
|| $total_fen <= 0
|
|
))
|
|
|| (!$paid && (
|
|
($mch_id !== '' && !hash_equals($config['mch_id'], $mch_id))
|
|
|| ($app_id !== '' && !hash_equals($config['appid'], $app_id))
|
|
|| ($currency !== '' && $currency !== 'CNY')
|
|
))) {
|
|
return ['success' => false, 'message' => __('微信支付回调身份或订单数据无效', 'aksrt')];
|
|
}
|
|
|
|
return [
|
|
'success' => true,
|
|
'order_number' => $order_number,
|
|
'amount' => $this->fen_decimal($total_fen),
|
|
'currency' => strtoupper((string) YansongdaPayFactory::dig($data, 'amount.currency', 'CNY')),
|
|
'transaction_id' => $transaction_id,
|
|
'status' => $paid ? 'paid' : 'pending',
|
|
];
|
|
} catch (\Throwable $e) {
|
|
return ['success' => false, 'message' => $e->getMessage()];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Output WeChat v3 notify ACK (JSON).
|
|
*/
|
|
public function output_callback_response(array $result): void {
|
|
try {
|
|
YansongdaPayFactory::boot_wechat();
|
|
if (!empty($result['success'])) {
|
|
YansongdaPayFactory::emit_response(Pay::wechat()->success(), '{"code":"SUCCESS","message":"成功"}');
|
|
}
|
|
} catch (\Throwable $e) {
|
|
// fall through to fail body
|
|
}
|
|
|
|
if (!headers_sent()) {
|
|
http_response_code(500);
|
|
header('Content-Type: application/json; charset=utf-8');
|
|
}
|
|
echo wp_json_encode([
|
|
'code' => 'FAIL',
|
|
'message' => (string) ($result['message'] ?? 'FAIL'),
|
|
]);
|
|
exit;
|
|
}
|
|
|
|
public function query_status(string $order_number): array {
|
|
if (!$this->is_configured()) {
|
|
return ['success' => false, 'message' => __('微信支付未配置', 'aksrt')];
|
|
}
|
|
|
|
$out_trade_no = $this->resolve_out_trade_no($order_number);
|
|
|
|
try {
|
|
YansongdaPayFactory::boot_wechat();
|
|
$action = $this->query_action();
|
|
$result = Pay::wechat()->query([
|
|
'out_trade_no' => $out_trade_no,
|
|
'_action' => $action,
|
|
]);
|
|
$data = YansongdaPayFactory::to_array($result);
|
|
|
|
$trade_state = (string) ($data['trade_state'] ?? '');
|
|
$status = 'pending';
|
|
if ($trade_state === 'SUCCESS') {
|
|
$status = 'paid';
|
|
} elseif (in_array($trade_state, ['CLOSED', 'REVOKED'], true)) {
|
|
$status = 'cancelled';
|
|
} elseif ($trade_state === 'REFUND') {
|
|
$status = 'refunded';
|
|
}
|
|
|
|
return [
|
|
'success' => true,
|
|
// Keep provider out_trade_no so Gateway::complete_order can resolve the local order.
|
|
'order_number' => (string) ($data['out_trade_no'] ?? $out_trade_no),
|
|
'amount' => $this->fen_decimal((int) YansongdaPayFactory::dig($data, 'amount.total', 0)),
|
|
'currency' => strtoupper((string) YansongdaPayFactory::dig($data, 'amount.currency', 'CNY')),
|
|
'transaction_id' => (string) ($data['transaction_id'] ?? ''),
|
|
'status' => $status,
|
|
];
|
|
} catch (\Throwable $e) {
|
|
return ['success' => false, 'message' => $e->getMessage()];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Best-effort close of a previous provider trade before re-pay.
|
|
*/
|
|
public function close_trade(string $out_trade_no): void {
|
|
$out_trade_no = sanitize_text_field($out_trade_no);
|
|
if ($out_trade_no === '' || !$this->is_configured()) {
|
|
return;
|
|
}
|
|
try {
|
|
YansongdaPayFactory::boot_wechat();
|
|
Pay::wechat()->close([
|
|
'out_trade_no' => $out_trade_no,
|
|
'_action' => $this->query_action(),
|
|
]);
|
|
} catch (\Throwable $e) {
|
|
// ignore — new attempt still uses a fresh out_trade_no
|
|
}
|
|
}
|
|
|
|
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, 'status' => 'failed', 'message' => __('退款金额无效', 'aksrt')];
|
|
}
|
|
if ($refund_amount->minor() <= 0) {
|
|
return ['success' => false, 'status' => 'failed', 'message' => __('退款金额无效', 'aksrt')];
|
|
}
|
|
if (!$this->is_configured()) {
|
|
return ['success' => false, 'message' => __('微信支付未配置', 'aksrt')];
|
|
}
|
|
|
|
$out_trade_no = $this->resolve_out_trade_no($order_number);
|
|
|
|
try {
|
|
YansongdaPayFactory::boot_wechat();
|
|
$action = $this->query_action();
|
|
|
|
$query = Pay::wechat()->query([
|
|
'out_trade_no' => $out_trade_no,
|
|
'_action' => $action,
|
|
]);
|
|
$q = YansongdaPayFactory::to_array($query);
|
|
$total_fen = (int) YansongdaPayFactory::dig($q, 'amount.total', 0);
|
|
$refund_fen = $refund_amount->minor();
|
|
if ($total_fen <= 0) {
|
|
return ['success' => false, 'status' => 'failed', 'message' => __('无法确认原订单金额', 'aksrt')];
|
|
}
|
|
if ($refund_fen <= 0 || $refund_fen > $total_fen) {
|
|
return ['success' => false, 'status' => 'failed', 'message' => __('退款金额无效', 'aksrt')];
|
|
}
|
|
|
|
$refund_request_id = (string) preg_replace(
|
|
'/[^A-Za-z0-9_-]/',
|
|
'',
|
|
$request_id !== '' ? $request_id : 'REF_' . $order_number
|
|
);
|
|
$result = Pay::wechat()->refund([
|
|
'_action' => 'refund_' . $action,
|
|
'out_trade_no' => $out_trade_no,
|
|
'out_refund_no' => $refund_request_id,
|
|
'reason' => __('用户申请退款', 'aksrt'),
|
|
'amount' => [
|
|
'refund' => $refund_fen,
|
|
'total' => $total_fen,
|
|
'currency' => 'CNY',
|
|
],
|
|
]);
|
|
$data = YansongdaPayFactory::to_array($result);
|
|
|
|
$status = strtoupper((string) ($data['status'] ?? ''));
|
|
$normalized = $status === 'SUCCESS'
|
|
? 'succeeded'
|
|
: (in_array($status, ['CLOSED', 'ABNORMAL'], true) ? 'failed' : 'pending');
|
|
|
|
return [
|
|
'success' => true,
|
|
'status' => $normalized,
|
|
'refund_id' => (string) ($data['refund_id'] ?? $data['out_refund_no'] ?? ''),
|
|
'request_id' => $refund_request_id,
|
|
'message' => $normalized === 'succeeded' ? __('退款成功', 'aksrt') : __('退款请求已受理', 'aksrt'),
|
|
];
|
|
} catch (\Throwable $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' => __('微信退款查询参数无效', 'aksrt')];
|
|
}
|
|
|
|
try {
|
|
YansongdaPayFactory::boot_wechat();
|
|
$result = Pay::wechat()->query([
|
|
'out_refund_no' => $request_id,
|
|
'_action' => 'refund_' . $this->query_action(),
|
|
]);
|
|
$data = YansongdaPayFactory::to_array($result);
|
|
$status = strtoupper((string) ($data['status'] ?? ''));
|
|
$normalized = $status === 'SUCCESS'
|
|
? 'succeeded'
|
|
: (in_array($status, ['CLOSED', 'ABNORMAL'], true) ? 'failed' : 'pending');
|
|
return [
|
|
'success' => true,
|
|
'status' => $normalized,
|
|
'refund_id' => (string) ($data['refund_id'] ?? $data['out_refund_no'] ?? ''),
|
|
];
|
|
} catch (\Throwable $e) {
|
|
return ['success' => false, 'status' => 'unknown', 'message' => $e->getMessage()];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @return array{mch_id:string,appid:string,api_v3_key:string,private_key:string,cert_path:string,payment_type:string}
|
|
*/
|
|
private function get_config(): array {
|
|
$all = class_exists('\AKSRT\Settings\Settings')
|
|
? \AKSRT\Settings\Settings::get_all()
|
|
: [];
|
|
$type = sanitize_key((string) ($all['wechat_payment_type'] ?? 'native'));
|
|
if (!in_array($type, ['native', 'h5'], true)) {
|
|
$type = 'native';
|
|
}
|
|
return [
|
|
'mch_id' => (string) ($all['wechat_merchantid'] ?? ''),
|
|
'appid' => (string) ($all['wechat_appid'] ?? ''),
|
|
'api_v3_key' => (string) ($all['wechat_api_v3_key'] ?? ''),
|
|
'private_key' => (string) ($all['wechat_private_key_path'] ?? ''),
|
|
'cert_path' => (string) ($all['wechat_cert_path'] ?? ''),
|
|
'payment_type' => $type,
|
|
];
|
|
}
|
|
|
|
private function query_action(): string {
|
|
$map = [
|
|
'native' => 'native',
|
|
'h5' => 'h5',
|
|
];
|
|
$type = $this->get_config()['payment_type'];
|
|
return $map[$type] ?? 'native';
|
|
}
|
|
|
|
private function fen_decimal(int $amount): string {
|
|
return \AKSRT\Ledger\AssetAmount::from_minor(
|
|
\AKSRT\Ledger\AssetType::BALANCE,
|
|
max(0, $amount)
|
|
)->decimal();
|
|
}
|
|
|
|
/**
|
|
* Map local order_number → provider out_trade_no (supports resume attempts).
|
|
*/
|
|
private function resolve_out_trade_no(string $order_number): string {
|
|
if (class_exists('\AKSRT\Payment\OrderRepository')) {
|
|
$repo = \AKSRT\Payment\OrderRepository::get_instance();
|
|
if ($repo->resolve_by_out_trade_no($order_number) && !$repo->get($order_number)) {
|
|
return $order_number;
|
|
}
|
|
$order = $repo->get($order_number);
|
|
if ($order) {
|
|
return \AKSRT\Payment\OrderRepository::provider_out_trade_no($order);
|
|
}
|
|
}
|
|
return $order_number;
|
|
}
|
|
}
|