fix(payment): harden payment and refund chains
This commit is contained in:
@@ -203,7 +203,7 @@ export const oauthSchema = {
|
|||||||
icon: 'fab fa-alipay',
|
icon: 'fab fa-alipay',
|
||||||
fields: [
|
fields: [
|
||||||
{ key: 'alipay_appid', label: 'AppID', type: 'input', default: '' },
|
{ key: 'alipay_appid', label: 'AppID', type: 'input', default: '' },
|
||||||
{ key: 'alipay_privatekey', label: '应用私钥', type: 'input', default: '' },
|
{ key: 'alipay_privatekey', label: '应用私钥', type: 'textarea', rows: 3, default: '' },
|
||||||
{ key: 'alipay_publickey', label: '支付宝公钥', type: 'input', default: '' },
|
{ key: 'alipay_publickey', label: '支付宝公钥', type: 'input', default: '' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
"description": "AKSRT WordPress Theme",
|
"description": "AKSRT WordPress Theme",
|
||||||
"type": "wordpress-theme",
|
"type": "wordpress-theme",
|
||||||
"require": {
|
"require": {
|
||||||
"php": ">=7.4",
|
"php": ">=8.1",
|
||||||
"stripe/stripe-php": "^16.1",
|
"stripe/stripe-php": "^16.1",
|
||||||
"yansongda/pay": "^3.7",
|
"yansongda/pay": "^3.7",
|
||||||
"yurunsoft/yurun-oauth-login": "~3.0"
|
"yurunsoft/yurun-oauth-login": "~3.0"
|
||||||
|
|||||||
Generated
+2
-2
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "fe1258c91a142e623f464f5ddaef58f9",
|
"content-hash": "7b529608bb9672db0327c4448e99220a",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "guzzlehttp/psr7",
|
"name": "guzzlehttp/psr7",
|
||||||
@@ -1002,7 +1002,7 @@
|
|||||||
"prefer-stable": false,
|
"prefer-stable": false,
|
||||||
"prefer-lowest": false,
|
"prefer-lowest": false,
|
||||||
"platform": {
|
"platform": {
|
||||||
"php": ">=7.4"
|
"php": ">=8.1"
|
||||||
},
|
},
|
||||||
"platform-dev": {},
|
"platform-dev": {},
|
||||||
"plugin-api-version": "2.9.0"
|
"plugin-api-version": "2.9.0"
|
||||||
|
|||||||
+5
-1
@@ -111,7 +111,7 @@ add_filter('query_vars', function ($vars) {
|
|||||||
|
|
||||||
// 主题切换离开时:清理本主题注册的 cron 任务
|
// 主题切换离开时:清理本主题注册的 cron 任务
|
||||||
add_action('switch_theme', function () {
|
add_action('switch_theme', function () {
|
||||||
$hooks = ['aksrt_dl_token_cleanup', 'aksrt_email_verification_backfill_batch', 'aksrt_notification_cleanup'];
|
$hooks = ['aksrt_dl_token_cleanup', 'aksrt_email_verification_backfill_batch', 'aksrt_notification_cleanup', 'aksrt_reconcile_refunds', 'aksrt_after_sale_auto_cancel'];
|
||||||
foreach ($hooks as $hook) {
|
foreach ($hooks as $hook) {
|
||||||
$next = wp_next_scheduled($hook);
|
$next = wp_next_scheduled($hook);
|
||||||
while ($next) {
|
while ($next) {
|
||||||
@@ -321,6 +321,10 @@ if (class_exists('\AKSRT\Payment\Order')) {
|
|||||||
\AKSRT\Payment\Order::get_instance()->init();
|
\AKSRT\Payment\Order::get_instance()->init();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (class_exists('\AKSRT\Payment\AfterSale')) {
|
||||||
|
\AKSRT\Payment\AfterSale::get_instance()->init();
|
||||||
|
}
|
||||||
|
|
||||||
// Register built-in payment gateways
|
// Register built-in payment gateways
|
||||||
if (class_exists('\AKSRT\Payment\Gateway')) {
|
if (class_exists('\AKSRT\Payment\Gateway')) {
|
||||||
$gw = \AKSRT\Payment\Gateway::get_instance();
|
$gw = \AKSRT\Payment\Gateway::get_instance();
|
||||||
|
|||||||
@@ -41,20 +41,14 @@ class PayAfterSale {
|
|||||||
|
|
||||||
$ok = false;
|
$ok = false;
|
||||||
if ($order_number !== '' && class_exists('\AKSRT\Payment\AfterSale')) {
|
if ($order_number !== '' && class_exists('\AKSRT\Payment\AfterSale')) {
|
||||||
$user_id = isset($_POST['user_id']) ? (int) $_POST['user_id'] : 0;
|
|
||||||
if ($user_id <= 0 && class_exists('\AKSRT\Payment\OrderRepository')) {
|
|
||||||
$order = \AKSRT\Payment\OrderRepository::get_instance()->get($order_number);
|
|
||||||
$user_id = (int) ($order['user_id'] ?? 0);
|
|
||||||
}
|
|
||||||
$id = \AKSRT\Payment\AfterSale::get_instance()->create([
|
$id = \AKSRT\Payment\AfterSale::get_instance()->create([
|
||||||
'order_number' => $order_number,
|
'order_number' => $order_number,
|
||||||
'user_id' => $user_id,
|
|
||||||
'type' => sanitize_key((string) wp_unslash($_POST['type'] ?? 'refund')),
|
'type' => sanitize_key((string) wp_unslash($_POST['type'] ?? 'refund')),
|
||||||
'reason' => sanitize_textarea_field((string) wp_unslash($_POST['reason'] ?? '')),
|
'reason' => sanitize_textarea_field((string) wp_unslash($_POST['reason'] ?? '')),
|
||||||
'amount' => (float) ($_POST['amount'] ?? 0),
|
'amount' => (float) ($_POST['amount'] ?? 0),
|
||||||
'note' => sanitize_textarea_field((string) wp_unslash($_POST['note'] ?? '')),
|
'note' => sanitize_textarea_field((string) wp_unslash($_POST['note'] ?? '')),
|
||||||
]);
|
]);
|
||||||
$ok = $id > 0;
|
$ok = !is_wp_error($id) && $id > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
$msg = $ok ? 'after_sale_created' : 'after_sale_create_failed';
|
$msg = $ok ? 'after_sale_created' : 'after_sale_create_failed';
|
||||||
|
|||||||
@@ -258,7 +258,7 @@ class SchemaPayment {
|
|||||||
]],
|
]],
|
||||||
['key' => 'alipay', 'title' => '支付宝配置', 'collapsible' => true, 'expandWhen' => 'payment_alipay_enabled', 'dependsOn' => ['key' => 'payment_alipay_enabled', 'value' => true], 'fields' => [
|
['key' => 'alipay', 'title' => '支付宝配置', 'collapsible' => true, 'expandWhen' => 'payment_alipay_enabled', 'dependsOn' => ['key' => 'payment_alipay_enabled', 'value' => true], 'fields' => [
|
||||||
['key' => 'alipay_appid', 'label' => 'AppID', 'type' => 'input', 'default' => '', 'dependsOn' => ['key' => 'payment_alipay_enabled', 'value' => true]],
|
['key' => 'alipay_appid', 'label' => 'AppID', 'type' => 'input', 'default' => '', 'dependsOn' => ['key' => 'payment_alipay_enabled', 'value' => true]],
|
||||||
['key' => 'alipay_privatekey', 'label' => '应用私钥', 'type' => 'textarea', 'default' => '', 'dependsOn' => ['key' => 'payment_alipay_enabled', 'value' => true]],
|
['key' => 'alipay_privatekey', 'label' => '应用私钥', 'type' => 'textarea', 'sanitize_type' => 'code', 'default' => '', 'dependsOn' => ['key' => 'payment_alipay_enabled', 'value' => true]],
|
||||||
['key' => 'alipay_payment_type', 'label' => '支付方式', 'type' => 'select', 'default' => 'page', 'options' => [
|
['key' => 'alipay_payment_type', 'label' => '支付方式', 'type' => 'select', 'default' => 'page', 'options' => [
|
||||||
['value' => 'page', 'label' => '电脑网站支付(web)'],
|
['value' => 'page', 'label' => '电脑网站支付(web)'],
|
||||||
['value' => 'wap', 'label' => '手机网站支付(wap)'],
|
['value' => 'wap', 'label' => '手机网站支付(wap)'],
|
||||||
|
|||||||
@@ -318,7 +318,7 @@ class SchemaUsers {
|
|||||||
]],
|
]],
|
||||||
['key' => 'alipay', 'title' => '支付宝配置', 'fields' => [
|
['key' => 'alipay', 'title' => '支付宝配置', 'fields' => [
|
||||||
['key' => 'alipay_appid', 'label' => 'AppID', 'type' => 'input', 'default' => ''],
|
['key' => 'alipay_appid', 'label' => 'AppID', 'type' => 'input', 'default' => ''],
|
||||||
['key' => 'alipay_privatekey', 'label' => '应用私钥', 'type' => 'input', 'default' => ''],
|
['key' => 'alipay_privatekey', 'label' => '应用私钥', 'type' => 'textarea', 'sanitize_type' => 'code', 'default' => ''],
|
||||||
['key' => 'alipay_publickey', 'label' => '支付宝公钥', 'type' => 'input', 'default' => ''],
|
['key' => 'alipay_publickey', 'label' => '支付宝公钥', 'type' => 'input', 'default' => ''],
|
||||||
]],
|
]],
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -82,13 +82,12 @@ trait ShopControllerFulfillmentOperations {
|
|||||||
return $this->error(__('订单不存在', 'aksrt'), 404);
|
return $this->error(__('订单不存在', 'aksrt'), 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
$params['user_id'] = (int) ($params['user_id'] ?? ($order['user_id'] ?? 0));
|
unset($params['user_id'], $params['applicant_user_id']);
|
||||||
if ($params['user_id'] <= 0) {
|
|
||||||
return $this->error(__('订单未绑定有效用户', 'aksrt'));
|
|
||||||
}
|
|
||||||
|
|
||||||
$id = \AKSRT\Payment\AfterSale::get_instance()->create($params);
|
$id = \AKSRT\Payment\AfterSale::get_instance()->create($params);
|
||||||
|
|
||||||
|
if (is_wp_error($id)) {
|
||||||
|
return $this->error($id->get_error_message(), 422);
|
||||||
|
}
|
||||||
if ($id > 0) {
|
if ($id > 0) {
|
||||||
return $this->success([
|
return $this->success([
|
||||||
'message' => __('售后工单已创建', 'aksrt'),
|
'message' => __('售后工单已创建', 'aksrt'),
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ class DBMigrator {
|
|||||||
use DBMigratorPart3;
|
use DBMigratorPart3;
|
||||||
|
|
||||||
/** Current schema version; changing it re-runs dbDelta. */
|
/** Current schema version; changing it re-runs dbDelta. */
|
||||||
const SCHEMA_VERSION = '1.15.0';
|
const SCHEMA_VERSION = '1.16.0';
|
||||||
|
|
||||||
const OPT_KEY = 'aksrt_db_schema_version';
|
const OPT_KEY = 'aksrt_db_schema_version';
|
||||||
|
|
||||||
|
|||||||
@@ -337,18 +337,32 @@ trait DBMigratorPart2 {
|
|||||||
type VARCHAR(20) NOT NULL DEFAULT 'refund',
|
type VARCHAR(20) NOT NULL DEFAULT 'refund',
|
||||||
reason TEXT NOT NULL,
|
reason TEXT NOT NULL,
|
||||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||||
amount DECIMAL(10,2) NOT NULL DEFAULT 0,
|
amount DECIMAL(10,2) NOT NULL DEFAULT 0,
|
||||||
note TEXT DEFAULT NULL,
|
note TEXT DEFAULT NULL,
|
||||||
operator_id BIGINT UNSIGNED DEFAULT NULL,
|
operator_id BIGINT UNSIGNED DEFAULT NULL,
|
||||||
operator_note TEXT DEFAULT NULL,
|
operator_note TEXT DEFAULT NULL,
|
||||||
created_at DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
|
refund_request_id VARCHAR(64) DEFAULT NULL,
|
||||||
updated_at DATETIME DEFAULT NULL,
|
provider VARCHAR(32) NOT NULL DEFAULT '',
|
||||||
resolved_at DATETIME DEFAULT NULL,
|
provider_refund_id VARCHAR(128) NOT NULL DEFAULT '',
|
||||||
|
provider_status VARCHAR(24) NOT NULL DEFAULT 'not_started',
|
||||||
|
refund_effect_status VARCHAR(24) NOT NULL DEFAULT 'not_started',
|
||||||
|
refund_error TEXT DEFAULT NULL,
|
||||||
|
refund_attempts INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
refund_requested_at DATETIME DEFAULT NULL,
|
||||||
|
provider_updated_at DATETIME DEFAULT NULL,
|
||||||
|
refund_succeeded_at DATETIME DEFAULT NULL,
|
||||||
|
refund_effected_at DATETIME DEFAULT NULL,
|
||||||
|
order_status_before VARCHAR(20) NOT NULL DEFAULT '',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
|
||||||
|
updated_at DATETIME DEFAULT NULL,
|
||||||
|
resolved_at DATETIME DEFAULT NULL,
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uniq_refund_request (refund_request_id),
|
||||||
KEY idx_order (order_number),
|
KEY idx_order (order_number),
|
||||||
KEY idx_user (user_id),
|
KEY idx_user (user_id),
|
||||||
KEY idx_status (status),
|
KEY idx_status (status),
|
||||||
KEY idx_type (type),
|
KEY idx_type (type),
|
||||||
|
KEY idx_provider_status (provider_status),
|
||||||
KEY idx_created (created_at)
|
KEY idx_created (created_at)
|
||||||
) {$charset_collate};";
|
) {$charset_collate};";
|
||||||
dbDelta($sql_after_sale);
|
dbDelta($sql_after_sale);
|
||||||
|
|||||||
+462
-82
@@ -31,6 +31,17 @@ class AfterSale {
|
|||||||
const PROGRESS_WAITING_USER_RECEIVE = 3;
|
const PROGRESS_WAITING_USER_RECEIVE = 3;
|
||||||
const PROGRESS_DONE = 4;
|
const PROGRESS_DONE = 4;
|
||||||
|
|
||||||
|
public function init(): void {
|
||||||
|
add_action('aksrt_reconcile_refunds', [$this, 'reconcile_pending_refunds']);
|
||||||
|
add_action('aksrt_after_sale_auto_cancel', [$this, 'run_auto_cancel_cron']);
|
||||||
|
if (!wp_next_scheduled('aksrt_reconcile_refunds')) {
|
||||||
|
wp_schedule_event(time() + HOUR_IN_SECONDS, 'hourly', 'aksrt_reconcile_refunds');
|
||||||
|
}
|
||||||
|
if (!wp_next_scheduled('aksrt_after_sale_auto_cancel')) {
|
||||||
|
wp_schedule_event(time() + DAY_IN_SECONDS, 'daily', 'aksrt_after_sale_auto_cancel');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public static function get_instance(): self {
|
public static function get_instance(): self {
|
||||||
if (null === self::$instance) {
|
if (null === self::$instance) {
|
||||||
self::$instance = new self();
|
self::$instance = new self();
|
||||||
@@ -44,24 +55,89 @@ class AfterSale {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建售后工单
|
* 创建售后工单
|
||||||
|
*
|
||||||
|
* @return int|\WP_Error
|
||||||
*/
|
*/
|
||||||
public function create(array $data): int {
|
public function create(array $data) {
|
||||||
global $wpdb;
|
global $wpdb;
|
||||||
|
|
||||||
$order_number = sanitize_text_field((string) ($data['order_number'] ?? ''));
|
$order_number = sanitize_text_field((string) ($data['order_number'] ?? ''));
|
||||||
$user_id = (int) ($data['user_id'] ?? 0);
|
$applicant_id = (int) ($data['applicant_user_id'] ?? $data['user_id'] ?? 0);
|
||||||
$type = sanitize_key((string) ($data['type'] ?? self::TYPE_REFUND));
|
$type = sanitize_key((string) ($data['type'] ?? self::TYPE_REFUND));
|
||||||
$reason = sanitize_textarea_field((string) ($data['reason'] ?? ''));
|
$reason = sanitize_textarea_field((string) ($data['reason'] ?? ''));
|
||||||
$amount = (float) ($data['amount'] ?? 0);
|
$amount = round((float) ($data['amount'] ?? 0), 2);
|
||||||
$note = sanitize_textarea_field((string) ($data['note'] ?? ''));
|
$note = sanitize_textarea_field((string) ($data['note'] ?? ''));
|
||||||
|
|
||||||
if ($order_number === '' || $user_id <= 0 || $reason === '') {
|
if ($order_number === '' || $reason === '') {
|
||||||
return 0;
|
return new \WP_Error('aksrt_after_sale_invalid', __('售后申请参数不完整。', 'aksrt'));
|
||||||
|
}
|
||||||
|
if (!array_key_exists($type, self::get_valid_types())) {
|
||||||
|
return new \WP_Error('aksrt_after_sale_type', __('售后类型无效。', 'aksrt'));
|
||||||
|
}
|
||||||
|
if (!class_exists('AKSRT\\Payment\\OrderRepository')) {
|
||||||
|
return new \WP_Error('aksrt_after_sale_order_service', __('订单服务不可用。', 'aksrt'));
|
||||||
}
|
}
|
||||||
|
|
||||||
$valid_types = [self::TYPE_REFUND, self::TYPE_REFUND_RETURN, self::TYPE_EXCHANGE, self::TYPE_REPAIR, self::TYPE_WARRANTY, self::TYPE_INSURED_PRICE, self::TYPE_REPLACEMENT];
|
$order = OrderRepository::get_instance()->get($order_number);
|
||||||
if (!in_array($type, $valid_types, true)) {
|
if (!$order) {
|
||||||
$type = self::TYPE_REFUND;
|
return new \WP_Error('aksrt_after_sale_order', __('订单不存在。', 'aksrt'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$user_id = (int) ($order['user_id'] ?? 0);
|
||||||
|
if ($user_id <= 0 || ($applicant_id > 0 && $applicant_id !== $user_id)) {
|
||||||
|
return new \WP_Error('aksrt_after_sale_owner', __('无权为该订单申请售后。', 'aksrt'));
|
||||||
|
}
|
||||||
|
if (!$this->can_apply_after_sale($order, $type)) {
|
||||||
|
return new \WP_Error('aksrt_after_sale_state', __('当前订单状态不支持该售后类型。', 'aksrt'));
|
||||||
|
}
|
||||||
|
if (!$this->within_application_window($order, $type)) {
|
||||||
|
return new \WP_Error('aksrt_after_sale_expired', __('该订单已超过售后申请期限。', 'aksrt'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$refund_type = $this->is_refund_type($type);
|
||||||
|
if ($refund_type && $amount <= 0) {
|
||||||
|
return new \WP_Error('aksrt_after_sale_amount', __('退款金额必须大于零。', 'aksrt'));
|
||||||
|
}
|
||||||
|
if (!$refund_type) {
|
||||||
|
$amount = 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$creation_lock = 'aksrt_as_' . substr(hash('sha256', $order_number), 0, 48);
|
||||||
|
$lock_acquired = (int) $wpdb->get_var($wpdb->prepare('SELECT GET_LOCK(%s, 5)', $creation_lock));
|
||||||
|
if ($lock_acquired !== 1) {
|
||||||
|
return new \WP_Error('aksrt_after_sale_busy', __('该订单正在处理售后申请,请稍后重试。', 'aksrt'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$active_statuses = [self::STATUS_PENDING, self::STATUS_PROCESSING, self::STATUS_APPROVED];
|
||||||
|
$placeholders = implode(', ', array_fill(0, count($active_statuses), '%s'));
|
||||||
|
$active_sql = "SELECT COUNT(*) FROM {$this->table()} WHERE order_number = %s AND status IN ({$placeholders})";
|
||||||
|
$active = (int) $wpdb->get_var($wpdb->prepare($active_sql, array_merge([$order_number], $active_statuses)));
|
||||||
|
if ($active > 0) {
|
||||||
|
$wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $creation_lock));
|
||||||
|
return new \WP_Error('aksrt_after_sale_active', __('该订单已有处理中的售后申请。', 'aksrt'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($refund_type) {
|
||||||
|
$reserved_sql = "SELECT COALESCE(SUM(amount), 0) FROM {$this->table()}
|
||||||
|
WHERE order_number = %s
|
||||||
|
AND type IN (%s, %s, %s)
|
||||||
|
AND status IN (%s, %s, %s, %s)";
|
||||||
|
$reserved = (float) $wpdb->get_var($wpdb->prepare(
|
||||||
|
$reserved_sql,
|
||||||
|
$order_number,
|
||||||
|
self::TYPE_REFUND,
|
||||||
|
self::TYPE_REFUND_RETURN,
|
||||||
|
self::TYPE_INSURED_PRICE,
|
||||||
|
self::STATUS_PENDING,
|
||||||
|
self::STATUS_PROCESSING,
|
||||||
|
self::STATUS_APPROVED,
|
||||||
|
self::STATUS_COMPLETED
|
||||||
|
));
|
||||||
|
$remaining = max(0.0, round((float) ($order['amount'] ?? 0) - $reserved, 2));
|
||||||
|
if ($amount > $remaining) {
|
||||||
|
$wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $creation_lock));
|
||||||
|
return new \WP_Error('aksrt_after_sale_excess', __('退款金额超过订单剩余可退金额。', 'aksrt'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$result = $wpdb->insert($this->table(), [
|
$result = $wpdb->insert($this->table(), [
|
||||||
@@ -70,13 +146,15 @@ class AfterSale {
|
|||||||
'type' => $type,
|
'type' => $type,
|
||||||
'reason' => $reason,
|
'reason' => $reason,
|
||||||
'status' => self::STATUS_PENDING,
|
'status' => self::STATUS_PENDING,
|
||||||
'amount' => $amount,
|
'amount' => $amount,
|
||||||
'note' => $note,
|
'note' => $note,
|
||||||
'created_at' => current_time('mysql'),
|
'order_status_before' => sanitize_key((string) ($order['status'] ?? '')),
|
||||||
|
'created_at' => current_time('mysql'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $creation_lock));
|
||||||
if (!$result) {
|
if (!$result) {
|
||||||
return 0;
|
return new \WP_Error('aksrt_after_sale_db', __('售后申请创建失败。', 'aksrt'));
|
||||||
}
|
}
|
||||||
$ticket_id = (int) $wpdb->insert_id;
|
$ticket_id = (int) $wpdb->insert_id;
|
||||||
$this->append_event($ticket_id, 'created', __('用户提交售后申请', 'aksrt'), $user_id);
|
$this->append_event($ticket_id, 'created', __('用户提交售后申请', 'aksrt'), $user_id);
|
||||||
@@ -506,126 +584,241 @@ class AfterSale {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* R69: Execute actual refund — credit back user balance / points.
|
* R69: Execute local refund effects and sales rollback once.
|
||||||
|
*
|
||||||
|
* @return true|\WP_Error
|
||||||
*/
|
*/
|
||||||
private function execute_refund(?array $order, float $refund_amount): void {
|
private function execute_refund(?array $order, float $refund_amount) {
|
||||||
if (!$order || $refund_amount <= 0) {
|
if (!$order || $refund_amount <= 0) {
|
||||||
return;
|
return new \WP_Error('aksrt_refund_order', __('退款订单或金额无效。', 'aksrt'));
|
||||||
}
|
}
|
||||||
|
|
||||||
$user_id = (int) ($order['user_id'] ?? 0);
|
$user_id = (int) ($order['user_id'] ?? 0);
|
||||||
if ($user_id <= 0) {
|
if ($user_id <= 0) {
|
||||||
return;
|
return new \WP_Error('aksrt_refund_user', __('退款用户无效。', 'aksrt'));
|
||||||
}
|
}
|
||||||
|
|
||||||
$payment_method = (string) ($order['payment_method'] ?? 'balance');
|
$payment_method = (string) ($order['payment_method'] ?? 'balance');
|
||||||
$order_number = (string) ($order['order_number'] ?? '');
|
$order_number = (string) ($order['order_number'] ?? '');
|
||||||
|
|
||||||
// Balance refund: prefer cash wallet, fall back to points.
|
|
||||||
if ($payment_method === 'balance') {
|
if ($payment_method === 'balance') {
|
||||||
if (class_exists('\AKSRT\Credits\Balance') && \AKSRT\Credits\Balance::enabled()) {
|
if (!class_exists('\AKSRT\Credits\Balance') || !\AKSRT\Credits\Balance::enabled()) {
|
||||||
\AKSRT\Credits\Balance::add(
|
return new \WP_Error('aksrt_refund_balance', __('余额退款服务不可用。', 'aksrt'));
|
||||||
$user_id,
|
}
|
||||||
(float) $refund_amount,
|
$credited = \AKSRT\Credits\Balance::add(
|
||||||
'order_refund',
|
$user_id,
|
||||||
['type' => 'order', 'order' => $order_number],
|
$refund_amount,
|
||||||
sprintf(__('售后退款:订单 %s', 'aksrt'), $order_number)
|
'order_refund',
|
||||||
);
|
['type' => 'order', 'order' => $order_number],
|
||||||
} elseif (class_exists('\AKSRT\Credits\Credits')) {
|
sprintf(__('售后退款:订单 %s', 'aksrt'), $order_number)
|
||||||
\AKSRT\Credits\Credits::add(
|
);
|
||||||
$user_id,
|
if (is_wp_error($credited)) {
|
||||||
(int) round($refund_amount),
|
return $credited;
|
||||||
'order_refund',
|
}
|
||||||
['order_number' => $order_number, 'type' => 'refund'],
|
} elseif ($payment_method === 'points') {
|
||||||
sprintf(__('售后退款金额 ¥%s', 'aksrt'), number_format($refund_amount, 2))
|
if (!class_exists('\AKSRT\Credits\Credits') || floor($refund_amount) !== $refund_amount) {
|
||||||
);
|
return new \WP_Error('aksrt_refund_points', __('积分退款金额无效或积分服务不可用。', 'aksrt'));
|
||||||
|
}
|
||||||
|
$credited = \AKSRT\Credits\Credits::add(
|
||||||
|
$user_id,
|
||||||
|
(int) $refund_amount,
|
||||||
|
'order_refund',
|
||||||
|
['order_number' => $order_number, 'type' => 'refund'],
|
||||||
|
sprintf(__('售后退回积分:订单 %s', 'aksrt'), $order_number)
|
||||||
|
);
|
||||||
|
if (is_wp_error($credited)) {
|
||||||
|
return $credited;
|
||||||
|
}
|
||||||
|
} elseif (!in_array($payment_method, ['wechatpay', 'alipay', 'stripe'], true)) {
|
||||||
|
return new \WP_Error('aksrt_refund_gateway', __('当前支付方式不支持自动退款。', 'aksrt'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full-order refunds roll back sales once after the monetary refund succeeds.
|
||||||
|
if (abs((float) ($order['amount'] ?? 0) - $refund_amount) <= 0.01) {
|
||||||
|
$meta = json_decode((string) ($order['note'] ?? ''), true);
|
||||||
|
$items = is_array($meta) ? ($meta['items'] ?? []) : [];
|
||||||
|
if (empty($items) && !empty($order['product_id'])) {
|
||||||
|
$items = [['product_id' => (int) $order['product_id'], 'quantity' => 1]];
|
||||||
|
}
|
||||||
|
foreach ($items as $item) {
|
||||||
|
$pid = (int) ($item['product_id'] ?? 0);
|
||||||
|
$qty = max(1, (int) ($item['quantity'] ?? 1));
|
||||||
|
if ($pid <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$sales = (int) get_post_meta($pid, '_aksrt_sales_volume', true);
|
||||||
|
update_post_meta($pid, '_aksrt_sales_volume', max(0, $sales - $qty));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deduct sales_volume for refunded items
|
return true;
|
||||||
$meta = json_decode((string) ($order['note'] ?? ''), true);
|
}
|
||||||
$items = is_array($meta) ? ($meta['items'] ?? []) : [];
|
|
||||||
if (empty($items) && !empty($order['product_id'])) {
|
|
||||||
$items = [['product_id' => (int) $order['product_id'], 'quantity' => 1]];
|
|
||||||
}
|
|
||||||
foreach ($items as $item) {
|
|
||||||
$pid = (int) ($item['product_id'] ?? 0);
|
|
||||||
$qty = max(1, (int) ($item['quantity'] ?? 1));
|
|
||||||
if ($pid <= 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$sales = (int) get_post_meta($pid, '_aksrt_sales_volume', true);
|
|
||||||
update_post_meta($pid, '_aksrt_sales_volume', max(0, $sales - $qty));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
private function publish_refund_event(array $order, float $refund_amount): void {
|
||||||
* Fires after refund execution.
|
$order_number = (string) ($order['order_number'] ?? '');
|
||||||
*
|
$user_id = (int) ($order['user_id'] ?? 0);
|
||||||
* @param string $order_number
|
$payment_method = (string) ($order['payment_method'] ?? '');
|
||||||
* @param int $user_id
|
|
||||||
* @param float $refund_amount
|
|
||||||
* @param string $payment_method
|
|
||||||
*/
|
|
||||||
do_action('aksrt_shop_refund_executed', $order_number, $user_id, $refund_amount, $payment_method);
|
do_action('aksrt_shop_refund_executed', $order_number, $user_id, $refund_amount, $payment_method);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute an online-channel refund before completing the after-sale ticket.
|
* Execute and persist a refund before completing the after-sale ticket.
|
||||||
*/
|
*/
|
||||||
private function complete_refund(array $record): bool {
|
private function complete_refund(array $record): bool {
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
$type = (string) ($record['type'] ?? '');
|
$type = (string) ($record['type'] ?? '');
|
||||||
if (!in_array($type, [self::TYPE_REFUND, self::TYPE_REFUND_RETURN, self::TYPE_INSURED_PRICE], true)) {
|
if (!$this->is_refund_type($type)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!class_exists('AKSRT\\Payment\\OrderRepository')) {
|
if (!class_exists('AKSRT\\Payment\\OrderRepository')) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$ticket_id = (int) ($record['id'] ?? 0);
|
||||||
$order_number = sanitize_text_field((string) ($record['order_number'] ?? ''));
|
$order_number = sanitize_text_field((string) ($record['order_number'] ?? ''));
|
||||||
$order = OrderRepository::get_instance()->get($order_number);
|
$order = OrderRepository::get_instance()->get($order_number);
|
||||||
if (!$order) {
|
$refund_amount = (float) ($record['amount'] ?? 0);
|
||||||
|
if ($ticket_id <= 0 || !$order || $refund_amount <= 0) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$payment_method = (string) ($order['payment_method'] ?? 'balance');
|
$latest = $this->get_by_id($ticket_id) ?: $record;
|
||||||
if (!in_array($payment_method, ['wechatpay', 'alipay'], true)) {
|
if (($latest['refund_effect_status'] ?? '') === 'succeeded') {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (($latest['provider_status'] ?? '') === 'succeeded'
|
||||||
$refund_amount = (float) ($record['amount'] ?? 0);
|
&& ($latest['refund_effect_status'] ?? '') === 'processing') {
|
||||||
if ($refund_amount <= 0) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$request_id = sprintf(
|
$payment_method = sanitize_key((string) ($order['payment_method'] ?? 'balance'));
|
||||||
'AS_%d_%s',
|
$request_id = (string) ($latest['refund_request_id'] ?? '');
|
||||||
(int) ($record['id'] ?? 0),
|
if ($request_id === '') {
|
||||||
substr(hash('sha256', $order_number), 0, 16)
|
$request_id = sprintf('as_%d_%s', $ticket_id, substr(hash('sha256', $order_number), 0, 16));
|
||||||
);
|
}
|
||||||
return $this->refund_via_gateway($payment_method, $order_number, $refund_amount, $request_id);
|
|
||||||
|
$provider_status = (string) ($latest['provider_status'] ?? 'not_started');
|
||||||
|
if ($provider_status !== 'succeeded') {
|
||||||
|
$claimed = $wpdb->query($wpdb->prepare(
|
||||||
|
"UPDATE {$this->table()}
|
||||||
|
SET refund_request_id = %s, provider = %s, provider_status = 'submitting',
|
||||||
|
refund_attempts = refund_attempts + 1, refund_requested_at = COALESCE(refund_requested_at, %s),
|
||||||
|
provider_updated_at = %s, refund_error = NULL, updated_at = %s
|
||||||
|
WHERE id = %d AND provider_status IN ('not_started', 'failed', 'pending', 'unknown')",
|
||||||
|
$request_id,
|
||||||
|
$payment_method,
|
||||||
|
current_time('mysql'),
|
||||||
|
current_time('mysql'),
|
||||||
|
current_time('mysql'),
|
||||||
|
$ticket_id
|
||||||
|
));
|
||||||
|
if ($claimed !== 1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array($payment_method, ['wechatpay', 'alipay', 'stripe'], true)) {
|
||||||
|
$result = $this->refund_via_gateway($payment_method, $order_number, $refund_amount, $request_id);
|
||||||
|
$status = sanitize_key((string) ($result['status'] ?? (!empty($result['success']) ? 'unknown' : 'failed')));
|
||||||
|
if (!in_array($status, ['pending', 'succeeded', 'failed', 'unknown'], true)) {
|
||||||
|
$status = 'unknown';
|
||||||
|
}
|
||||||
|
$wpdb->update($this->table(), [
|
||||||
|
'refund_request_id' => sanitize_text_field((string) ($result['request_id'] ?? $request_id)),
|
||||||
|
'provider_refund_id' => sanitize_text_field((string) ($result['refund_id'] ?? '')),
|
||||||
|
'provider_status' => $status,
|
||||||
|
'refund_error' => empty($result['success']) ? sanitize_textarea_field((string) ($result['message'] ?? '')) : null,
|
||||||
|
'provider_updated_at' => current_time('mysql'),
|
||||||
|
'refund_succeeded_at' => $status === 'succeeded' ? current_time('mysql') : null,
|
||||||
|
'updated_at' => current_time('mysql'),
|
||||||
|
], ['id' => $ticket_id]);
|
||||||
|
if ($status !== 'succeeded') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} elseif (in_array($payment_method, ['balance', 'points'], true)) {
|
||||||
|
$wpdb->update($this->table(), [
|
||||||
|
'provider_status' => 'succeeded',
|
||||||
|
'provider_updated_at' => current_time('mysql'),
|
||||||
|
'refund_succeeded_at' => current_time('mysql'),
|
||||||
|
'updated_at' => current_time('mysql'),
|
||||||
|
], ['id' => $ticket_id]);
|
||||||
|
} else {
|
||||||
|
$wpdb->update($this->table(), [
|
||||||
|
'provider_status' => 'failed',
|
||||||
|
'refund_error' => __('当前支付方式不支持自动退款。', 'aksrt'),
|
||||||
|
'provider_updated_at' => current_time('mysql'),
|
||||||
|
'updated_at' => current_time('mysql'),
|
||||||
|
], ['id' => $ticket_id]);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$wpdb->query('START TRANSACTION');
|
||||||
|
$effect_claimed = $wpdb->query($wpdb->prepare(
|
||||||
|
"UPDATE {$this->table()}
|
||||||
|
SET refund_effect_status = 'processing', updated_at = %s
|
||||||
|
WHERE id = %d AND refund_effect_status IN ('not_started', 'failed')",
|
||||||
|
current_time('mysql'),
|
||||||
|
$ticket_id
|
||||||
|
));
|
||||||
|
if ($effect_claimed !== 1) {
|
||||||
|
$wpdb->query('ROLLBACK');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect = $this->execute_refund($order, $refund_amount);
|
||||||
|
if (is_wp_error($effect)) {
|
||||||
|
$wpdb->query('ROLLBACK');
|
||||||
|
$wpdb->update($this->table(), [
|
||||||
|
'refund_effect_status' => 'failed',
|
||||||
|
'refund_error' => $effect->get_error_message(),
|
||||||
|
'updated_at' => current_time('mysql'),
|
||||||
|
], ['id' => $ticket_id]);
|
||||||
|
$this->log_refund_error($order_number, $payment_method, $effect->get_error_message());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect_saved = $wpdb->update($this->table(), [
|
||||||
|
'refund_effect_status' => 'succeeded',
|
||||||
|
'refund_effected_at' => current_time('mysql'),
|
||||||
|
'refund_error' => null,
|
||||||
|
'updated_at' => current_time('mysql'),
|
||||||
|
], ['id' => $ticket_id]);
|
||||||
|
if ($effect_saved === false) {
|
||||||
|
$wpdb->query('ROLLBACK');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$committed = $wpdb->query('COMMIT');
|
||||||
|
if ($committed === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$this->publish_refund_event($order, $refund_amount);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function refund_via_gateway(string $payment_method, string $order_number, float $refund_amount, string $request_id): bool {
|
/**
|
||||||
|
* @return array{success:bool,status:string,refund_id?:string,message?:string}
|
||||||
|
*/
|
||||||
|
private function refund_via_gateway(string $payment_method, string $order_number, float $refund_amount, string $request_id): array {
|
||||||
$gateway = Gateway::get_instance()->get_gateway($payment_method);
|
$gateway = Gateway::get_instance()->get_gateway($payment_method);
|
||||||
if (!$gateway || !method_exists($gateway, 'refund')) {
|
if (!$gateway) {
|
||||||
$this->log_refund_error($order_number, $payment_method, 'gateway unavailable');
|
$this->log_refund_error($order_number, $payment_method, 'gateway unavailable');
|
||||||
return false;
|
return ['success' => false, 'status' => 'failed', 'message' => 'gateway unavailable'];
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$result = $gateway->refund($order_number, $refund_amount, $request_id);
|
$result = $gateway->refund($order_number, $refund_amount, $request_id);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$this->log_refund_error($order_number, $payment_method, $e->getMessage());
|
$this->log_refund_error($order_number, $payment_method, $e->getMessage());
|
||||||
return false;
|
return ['success' => false, 'status' => 'unknown', 'message' => $e->getMessage()];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (empty($result['success'])) {
|
if (empty($result['success'])) {
|
||||||
$this->log_refund_error($order_number, $payment_method, (string) ($result['message'] ?? 'unknown error'));
|
$this->log_refund_error($order_number, $payment_method, (string) ($result['message'] ?? 'unknown error'));
|
||||||
return false;
|
$result['status'] = $result['status'] ?? 'unknown';
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function log_refund_error(string $order_number, string $payment_method, string $message): void {
|
private function log_refund_error(string $order_number, string $payment_method, string $message): void {
|
||||||
@@ -656,6 +849,15 @@ class AfterSale {
|
|||||||
$repository = OrderRepository::get_instance();
|
$repository = OrderRepository::get_instance();
|
||||||
|
|
||||||
if (in_array($new_status, [self::STATUS_PROCESSING, self::STATUS_APPROVED], true)) {
|
if (in_array($new_status, [self::STATUS_PROCESSING, self::STATUS_APPROVED], true)) {
|
||||||
|
$order = $repository->get($order_number);
|
||||||
|
$current_status = sanitize_key((string) ($order['status'] ?? ''));
|
||||||
|
if ($current_status !== '' && $current_status !== Order::STATUS_REFUNDING) {
|
||||||
|
global $wpdb;
|
||||||
|
$wpdb->update($this->table(), [
|
||||||
|
'order_status_before' => $current_status,
|
||||||
|
'updated_at' => current_time('mysql'),
|
||||||
|
], ['id' => (int) ($record['id'] ?? 0)]);
|
||||||
|
}
|
||||||
$repository->update_status($order_number, Order::STATUS_REFUNDING, [
|
$repository->update_status($order_number, Order::STATUS_REFUNDING, [
|
||||||
'note' => $note,
|
'note' => $note,
|
||||||
]);
|
]);
|
||||||
@@ -664,17 +866,49 @@ class AfterSale {
|
|||||||
|
|
||||||
if ($new_status === self::STATUS_COMPLETED) {
|
if ($new_status === self::STATUS_COMPLETED) {
|
||||||
$order = $repository->get($order_number);
|
$order = $repository->get($order_number);
|
||||||
|
if (!$order) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (($order['status'] ?? '') !== Order::STATUS_REFUNDING) {
|
if (($order['status'] ?? '') !== Order::STATUS_REFUNDING) {
|
||||||
$repository->update_status($order_number, Order::STATUS_REFUNDING, [
|
$repository->update_status($order_number, Order::STATUS_REFUNDING, [
|
||||||
'note' => $note,
|
'note' => $note,
|
||||||
]);
|
]);
|
||||||
|
$order = $repository->get($order_number) ?: $order;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->execute_refund($order, (float) ($record['amount'] ?? 0));
|
$refund_amount = (float) ($record['amount'] ?? 0);
|
||||||
|
global $wpdb;
|
||||||
$repository->update_status($order_number, Order::STATUS_REFUNDED, [
|
$completed_other = (float) $wpdb->get_var($wpdb->prepare(
|
||||||
'note' => $note,
|
"SELECT COALESCE(SUM(amount), 0) FROM {$this->table()}
|
||||||
]);
|
WHERE order_number = %s AND id <> %d
|
||||||
|
AND type IN (%s, %s, %s) AND status = %s",
|
||||||
|
$order_number,
|
||||||
|
(int) ($record['id'] ?? 0),
|
||||||
|
self::TYPE_REFUND,
|
||||||
|
self::TYPE_REFUND_RETURN,
|
||||||
|
self::TYPE_INSURED_PRICE,
|
||||||
|
self::STATUS_COMPLETED
|
||||||
|
));
|
||||||
|
$total_refunded = round($completed_other + $refund_amount, 2);
|
||||||
|
if ($total_refunded + 0.01 >= (float) ($order['amount'] ?? 0)) {
|
||||||
|
$repository->update_status($order_number, Order::STATUS_REFUNDED, [
|
||||||
|
'note' => $note,
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
$previous_status = sanitize_key((string) ($record['order_status_before'] ?? ''));
|
||||||
|
if (!in_array($previous_status, [
|
||||||
|
Order::STATUS_PAID,
|
||||||
|
Order::STATUS_DELIVERED,
|
||||||
|
Order::STATUS_COMPLETED,
|
||||||
|
Order::STATUS_PENDING_REVIEW,
|
||||||
|
], true)) {
|
||||||
|
$previous_status = Order::STATUS_PAID;
|
||||||
|
}
|
||||||
|
$repository->update_status($order_number, $previous_status, [
|
||||||
|
'force' => true,
|
||||||
|
'note' => __('部分退款已完成。', 'aksrt'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -782,6 +1016,7 @@ class AfterSale {
|
|||||||
'status' => self::STATUS_CANCELLED,
|
'status' => self::STATUS_CANCELLED,
|
||||||
'updated_at' => current_time('mysql'),
|
'updated_at' => current_time('mysql'),
|
||||||
], ['id' => $ticket_id]);
|
], ['id' => $ticket_id]);
|
||||||
|
$this->restore_order_after_cancel($record);
|
||||||
|
|
||||||
$msg = $reason !== ''
|
$msg = $reason !== ''
|
||||||
? sprintf(__('用户取消售后:%s', 'aksrt'), sanitize_text_field($reason))
|
? sprintf(__('用户取消售后:%s', 'aksrt'), sanitize_text_field($reason))
|
||||||
@@ -903,6 +1138,33 @@ class AfterSale {
|
|||||||
* Shipping status window + type constraints (§6.8)
|
* Shipping status window + type constraints (§6.8)
|
||||||
* ============================================================ */
|
* ============================================================ */
|
||||||
|
|
||||||
|
private function is_refund_type(string $type): bool {
|
||||||
|
return in_array($type, [
|
||||||
|
self::TYPE_REFUND,
|
||||||
|
self::TYPE_REFUND_RETURN,
|
||||||
|
self::TYPE_INSURED_PRICE,
|
||||||
|
], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function within_application_window(array $order, string $type): bool {
|
||||||
|
$days = (int) ($this->get_type_timeouts()[$type] ?? 0);
|
||||||
|
if ($days <= 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$base = (string) ($order['completed_at']
|
||||||
|
?? $order['delivered_at']
|
||||||
|
?? $order['paid_at']
|
||||||
|
?? $order['created_at']
|
||||||
|
?? '');
|
||||||
|
$timestamp = $base !== '' ? strtotime($base) : false;
|
||||||
|
if ($timestamp === false) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $timestamp >= current_time('timestamp') - ($days * DAY_IN_SECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
public function can_apply_after_sale(array $order, string $type): bool {
|
public function can_apply_after_sale(array $order, string $type): bool {
|
||||||
$status = (string) ($order['status'] ?? '');
|
$status = (string) ($order['status'] ?? '');
|
||||||
if (!in_array($status, [
|
if (!in_array($status, [
|
||||||
@@ -944,6 +1206,123 @@ class AfterSale {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function restore_order_after_cancel(array $record): void {
|
||||||
|
if (!$this->is_refund_type((string) ($record['type'] ?? ''))
|
||||||
|
|| !class_exists('AKSRT\\Payment\\OrderRepository')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$order_number = sanitize_text_field((string) ($record['order_number'] ?? ''));
|
||||||
|
$order = OrderRepository::get_instance()->get($order_number);
|
||||||
|
if (!$order || ($order['status'] ?? '') !== Order::STATUS_REFUNDING) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$previous = sanitize_key((string) ($record['order_status_before'] ?? ''));
|
||||||
|
if (!in_array($previous, [
|
||||||
|
Order::STATUS_PAID,
|
||||||
|
Order::STATUS_DELIVERED,
|
||||||
|
Order::STATUS_COMPLETED,
|
||||||
|
Order::STATUS_PENDING_REVIEW,
|
||||||
|
], true)) {
|
||||||
|
$previous = Order::STATUS_PAID;
|
||||||
|
}
|
||||||
|
OrderRepository::get_instance()->update_status($order_number, $previous, [
|
||||||
|
'force' => true,
|
||||||
|
'note' => __('售后申请已取消,恢复订单状态。', 'aksrt'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reconcile_pending_refunds(): void {
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
$submitting_cutoff = date('Y-m-d H:i:s', current_time('timestamp') - (15 * MINUTE_IN_SECONDS));
|
||||||
|
$rows = $wpdb->get_results(
|
||||||
|
$wpdb->prepare(
|
||||||
|
"SELECT * FROM {$this->table()}
|
||||||
|
WHERE (
|
||||||
|
provider_status IN ('pending', 'unknown')
|
||||||
|
OR (provider_status = 'submitting' AND provider_updated_at < %s)
|
||||||
|
OR (provider_status = 'succeeded' AND refund_effect_status = 'processing' AND updated_at < %s)
|
||||||
|
)
|
||||||
|
AND status IN ('processing', 'approved')
|
||||||
|
ORDER BY provider_updated_at ASC
|
||||||
|
LIMIT 50",
|
||||||
|
$submitting_cutoff,
|
||||||
|
$submitting_cutoff
|
||||||
|
),
|
||||||
|
ARRAY_A
|
||||||
|
);
|
||||||
|
foreach ((array) $rows as $record) {
|
||||||
|
$provider_status = (string) ($record['provider_status'] ?? '');
|
||||||
|
$effect_status = (string) ($record['refund_effect_status'] ?? '');
|
||||||
|
if ($provider_status === 'succeeded' && $effect_status === 'processing') {
|
||||||
|
$wpdb->update($this->table(), [
|
||||||
|
'refund_effect_status' => 'failed',
|
||||||
|
'refund_error' => __('本地退款效果状态不确定,请人工核对余额或积分流水后重试。', 'aksrt'),
|
||||||
|
'updated_at' => current_time('mysql'),
|
||||||
|
], ['id' => (int) $record['id']]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$provider = sanitize_key((string) ($record['provider'] ?? ''));
|
||||||
|
$request_id = (string) ($record['refund_request_id'] ?? '');
|
||||||
|
$gateway = Gateway::get_instance()->get_gateway($provider);
|
||||||
|
if (!$gateway || $request_id === '' || !method_exists($gateway, 'query_refund')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = $gateway->query_refund(
|
||||||
|
$request_id,
|
||||||
|
(string) ($record['order_number'] ?? '')
|
||||||
|
);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$status = sanitize_key((string) ($result['status'] ?? 'unknown'));
|
||||||
|
if (!in_array($status, ['pending', 'succeeded', 'failed', 'unknown'], true)) {
|
||||||
|
$status = 'unknown';
|
||||||
|
}
|
||||||
|
$wpdb->update($this->table(), [
|
||||||
|
'provider_status' => $status,
|
||||||
|
'provider_refund_id' => sanitize_text_field((string) ($result['refund_id'] ?? $record['provider_refund_id'] ?? '')),
|
||||||
|
'refund_error' => $status === 'failed' ? sanitize_textarea_field((string) ($result['message'] ?? '')) : null,
|
||||||
|
'provider_updated_at' => current_time('mysql'),
|
||||||
|
'refund_succeeded_at' => $status === 'succeeded' ? current_time('mysql') : null,
|
||||||
|
'updated_at' => current_time('mysql'),
|
||||||
|
], ['id' => (int) $record['id']]);
|
||||||
|
|
||||||
|
if ($status === 'unknown'
|
||||||
|
&& (int) ($record['refund_attempts'] ?? 0) < 3
|
||||||
|
&& in_array($provider_status, ['submitting', 'unknown'], true)) {
|
||||||
|
$wpdb->update($this->table(), [
|
||||||
|
'provider_status' => 'failed',
|
||||||
|
'provider_updated_at' => current_time('mysql'),
|
||||||
|
'refund_error' => __('未查询到退款记录,将使用原请求号幂等重试。', 'aksrt'),
|
||||||
|
'updated_at' => current_time('mysql'),
|
||||||
|
], ['id' => (int) $record['id']]);
|
||||||
|
$fresh = $this->get_by_id((int) $record['id']);
|
||||||
|
if ($fresh) {
|
||||||
|
$this->complete_refund($fresh);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($status === 'succeeded') {
|
||||||
|
$fresh = $this->get_by_id((int) $record['id']);
|
||||||
|
if ($fresh && $this->complete_refund($fresh)) {
|
||||||
|
$this->update_status(
|
||||||
|
(int) $record['id'],
|
||||||
|
self::STATUS_COMPLETED,
|
||||||
|
0,
|
||||||
|
__('系统对账确认退款成功。', 'aksrt')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
* Auto-cancel Cron (§6.8)
|
* Auto-cancel Cron (§6.8)
|
||||||
* ============================================================ */
|
* ============================================================ */
|
||||||
@@ -988,6 +1367,7 @@ class AfterSale {
|
|||||||
self::TYPE_REFUND => __('仅退款', 'aksrt'),
|
self::TYPE_REFUND => __('仅退款', 'aksrt'),
|
||||||
self::TYPE_REFUND_RETURN => __('退货退款', 'aksrt'),
|
self::TYPE_REFUND_RETURN => __('退货退款', 'aksrt'),
|
||||||
self::TYPE_EXCHANGE => __('换货', 'aksrt'),
|
self::TYPE_EXCHANGE => __('换货', 'aksrt'),
|
||||||
|
self::TYPE_REPAIR => __('维修', 'aksrt'),
|
||||||
self::TYPE_REPLACEMENT => __('补发', 'aksrt'),
|
self::TYPE_REPLACEMENT => __('补发', 'aksrt'),
|
||||||
self::TYPE_WARRANTY => __('保修', 'aksrt'),
|
self::TYPE_WARRANTY => __('保修', 'aksrt'),
|
||||||
self::TYPE_INSURED_PRICE => __('保价', 'aksrt'),
|
self::TYPE_INSURED_PRICE => __('保价', 'aksrt'),
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ class BalanceGateway implements GatewayInterface {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function refund(string $order_number, float $amount): array {
|
public function refund(string $order_number, float $amount, string $request_id = ''): array {
|
||||||
$order_repo = OrderRepository::get_instance();
|
$order_repo = OrderRepository::get_instance();
|
||||||
$order = $order_repo->get($order_number);
|
$order = $order_repo->get($order_number);
|
||||||
|
|
||||||
|
|||||||
@@ -162,13 +162,8 @@ class Gateway {
|
|||||||
// Alipay synchronous returns generally omit trade_status; query after signature verification.
|
// Alipay synchronous returns generally omit trade_status; query after signature verification.
|
||||||
$query = $gateway->query_status($order_number);
|
$query = $gateway->query_status($order_number);
|
||||||
if (!empty($query['success']) && ($query['status'] ?? '') === 'paid') {
|
if (!empty($query['success']) && ($query['status'] ?? '') === 'paid') {
|
||||||
$done = $this->complete_order($gateway_id, [
|
$query['order_number'] = $order_number;
|
||||||
'order_number' => $order_number,
|
if ($this->complete_order($gateway_id, $query)) {
|
||||||
'amount' => 0,
|
|
||||||
'transaction_id' => $query['transaction_id'] ?? '',
|
|
||||||
'status' => 'paid',
|
|
||||||
]);
|
|
||||||
if ($done) {
|
|
||||||
$status = 'success';
|
$status = 'success';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -180,8 +175,13 @@ class Gateway {
|
|||||||
if ($paypal_order_id !== '') {
|
if ($paypal_order_id !== '') {
|
||||||
$cap = $gateway->capture_order($paypal_order_id);
|
$cap = $gateway->capture_order($paypal_order_id);
|
||||||
if (!empty($cap['success']) && ($cap['status'] ?? '') === 'paid') {
|
if (!empty($cap['success']) && ($cap['status'] ?? '') === 'paid') {
|
||||||
$order_number = $order_number ?: sanitize_text_field((string) ($cap['order_number'] ?? ''));
|
$captured_order = sanitize_text_field((string) ($cap['order_number'] ?? ''));
|
||||||
$cap['order_number'] = $order_number;
|
if ($captured_order === ''
|
||||||
|
|| ($order_number !== '' && !hash_equals($order_number, $captured_order))) {
|
||||||
|
$this->redirect_to_orders($order_number, 'pending');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$order_number = $captured_order;
|
||||||
if ($this->complete_order($gateway_id, $cap)) {
|
if ($this->complete_order($gateway_id, $cap)) {
|
||||||
$status = 'success';
|
$status = 'success';
|
||||||
}
|
}
|
||||||
@@ -191,13 +191,10 @@ class Gateway {
|
|||||||
// 其余网关:主动查询订单状态兜底
|
// 其余网关:主动查询订单状态兜底
|
||||||
$q = $gateway->query_status($order_number);
|
$q = $gateway->query_status($order_number);
|
||||||
if (!empty($q['success']) && ($q['status'] ?? '') === 'paid') {
|
if (!empty($q['success']) && ($q['status'] ?? '') === 'paid') {
|
||||||
$done = $this->complete_order($gateway_id, [
|
if (empty($q['order_number'])) {
|
||||||
'order_number' => $order_number,
|
$q['order_number'] = $order_number;
|
||||||
'amount' => 0, // 由网关 API 已确认到账,金额校验交给 notify 路径
|
}
|
||||||
'transaction_id' => $q['transaction_id'] ?? '',
|
if ($this->complete_order($gateway_id, $q)) {
|
||||||
'status' => 'paid',
|
|
||||||
]);
|
|
||||||
if ($done) {
|
|
||||||
$status = 'success';
|
$status = 'success';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -238,6 +235,32 @@ class Gateway {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$stored_gateway = sanitize_key((string) ($order['payment_method'] ?? ''));
|
||||||
|
if ($stored_gateway === '' || !hash_equals($stored_gateway, sanitize_key($gateway_id))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provider-confirmed external payments must include an exact amount and transaction ID.
|
||||||
|
$expected = (float) ($order['amount'] ?? 0);
|
||||||
|
$paid_amount = (float) ($verify['amount'] ?? 0);
|
||||||
|
$transaction_id = sanitize_text_field((string) ($verify['transaction_id'] ?? ''));
|
||||||
|
if ($expected <= 0
|
||||||
|
|| $paid_amount <= 0
|
||||||
|
|| $transaction_id === ''
|
||||||
|
|| abs($expected - $paid_amount) > 0.01) {
|
||||||
|
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||||
|
error_log(sprintf(
|
||||||
|
'[AKSRT Payment] invalid completion order=%s expected=%.2f paid=%.2f transaction=%s gateway=%s',
|
||||||
|
$order_number,
|
||||||
|
$expected,
|
||||||
|
$paid_amount,
|
||||||
|
$transaction_id !== '' ? 'present' : 'missing',
|
||||||
|
$gateway_id
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
$current = (string) ($order['status'] ?? '');
|
$current = (string) ($order['status'] ?? '');
|
||||||
|
|
||||||
// 幂等:已支付/已交付/已完成视为成功,不重复处理(回调可能多次到达)
|
// 幂等:已支付/已交付/已完成视为成功,不重复处理(回调可能多次到达)
|
||||||
@@ -249,19 +272,9 @@ class Gateway {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 金额校验,防止前端/回调金额被篡改
|
|
||||||
$expected = (float) ($order['amount'] ?? 0);
|
|
||||||
$paid_amount = (float) ($verify['amount'] ?? 0);
|
|
||||||
if ($expected > 0 && $paid_amount > 0 && abs($expected - $paid_amount) > 0.01) {
|
|
||||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
|
||||||
error_log(sprintf('[AKSRT Payment] amount mismatch order=%s expected=%.2f paid=%.2f gateway=%s', $order_number, $expected, $paid_amount, $gateway_id));
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $repo->update_status($order_number, Order::STATUS_PAID, [
|
return $repo->update_status($order_number, Order::STATUS_PAID, [
|
||||||
'payment_method' => $gateway_id,
|
'payment_method' => $gateway_id,
|
||||||
'payment_transaction_id' => sanitize_text_field((string) ($verify['transaction_id'] ?? '')),
|
'payment_transaction_id' => $transaction_id,
|
||||||
'note' => __('支付网关回调确认到账。', 'aksrt'),
|
'note' => __('支付网关回调确认到账。', 'aksrt'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,9 +40,10 @@ interface GatewayInterface {
|
|||||||
*
|
*
|
||||||
* @param string $order_number 订单号
|
* @param string $order_number 订单号
|
||||||
* @param float $amount 退款金额
|
* @param float $amount 退款金额
|
||||||
* @return array{success: bool, refund_id?: string, message?: string}
|
* @param string $request_id 幂等退款请求号
|
||||||
|
* @return array{success: bool, status?: string, refund_id?: string, request_id?: string, message?: string}
|
||||||
*/
|
*/
|
||||||
public function refund(string $order_number, float $amount): array;
|
public function refund(string $order_number, float $amount, string $request_id = ''): array;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取网关显示名称
|
* 获取网关显示名称
|
||||||
|
|||||||
@@ -52,34 +52,25 @@ class AlipayGateway implements \AKSRT\Payment\GatewayInterface {
|
|||||||
YansongdaPayFactory::boot_alipay();
|
YansongdaPayFactory::boot_alipay();
|
||||||
|
|
||||||
$payload = [
|
$payload = [
|
||||||
|
'_method' => 'GET',
|
||||||
'out_trade_no' => $order_number,
|
'out_trade_no' => $order_number,
|
||||||
'total_amount' => number_format($amount, 2, '.', ''),
|
'total_amount' => number_format($amount, 2, '.', ''),
|
||||||
'subject' => mb_substr($subject, 0, 256),
|
'subject' => mb_substr($subject, 0, 256),
|
||||||
];
|
];
|
||||||
|
|
||||||
$result = $payment_type === 'wap'
|
$result = $payment_type === 'wap'
|
||||||
? Pay::alipay()->wap($payload)
|
? Pay::alipay()->h5($payload)
|
||||||
: Pay::alipay()->web($payload);
|
: Pay::alipay()->web($payload);
|
||||||
|
|
||||||
$payment_url = YansongdaPayFactory::extract_payment_url($result);
|
$payment_url = YansongdaPayFactory::extract_payment_url($result);
|
||||||
$html = YansongdaPayFactory::extract_html_body($result);
|
if ($payment_url === '') {
|
||||||
|
|
||||||
// Prefer Location / action URL; if only auto-submit HTML, expose as payment_data for frontend.
|
|
||||||
if ($payment_url === '' && $html !== '' && preg_match('#action=[\'"]([^\'"]+)[\'"]#i', $html, $m)) {
|
|
||||||
$payment_url = html_entity_decode($m[1], ENT_QUOTES);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($payment_url === '' && $html === '') {
|
|
||||||
return ['success' => false, 'message' => __('支付宝支付链接生成失败', 'aksrt')];
|
return ['success' => false, 'message' => __('支付宝支付链接生成失败', 'aksrt')];
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'payment_url' => $payment_url,
|
'payment_url' => $payment_url,
|
||||||
'payment_data' => array_filter([
|
'payment_data' => ['order_number' => $order_number],
|
||||||
'order_number' => $order_number,
|
|
||||||
'html' => $html !== '' ? $html : null,
|
|
||||||
]),
|
|
||||||
'message' => __('支付宝支付链接已生成', 'aksrt'),
|
'message' => __('支付宝支付链接已生成', 'aksrt'),
|
||||||
];
|
];
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
@@ -103,14 +94,23 @@ class AlipayGateway implements \AKSRT\Payment\GatewayInterface {
|
|||||||
$data_obj = Pay::alipay()->callback($callback_input);
|
$data_obj = Pay::alipay()->callback($callback_input);
|
||||||
$data = YansongdaPayFactory::to_array($data_obj);
|
$data = YansongdaPayFactory::to_array($data_obj);
|
||||||
|
|
||||||
|
$config = $this->get_config();
|
||||||
$trade_status = (string) ($data['trade_status'] ?? '');
|
$trade_status = (string) ($data['trade_status'] ?? '');
|
||||||
$paid = in_array($trade_status, ['TRADE_SUCCESS', 'TRADE_FINISHED'], true);
|
$paid = in_array($trade_status, ['TRADE_SUCCESS', 'TRADE_FINISHED'], true);
|
||||||
|
$order_number = (string) ($data['out_trade_no'] ?? '');
|
||||||
|
$trade_no = (string) ($data['trade_no'] ?? '');
|
||||||
|
|
||||||
|
if (($data['app_id'] ?? '') !== $config['app_id']
|
||||||
|
|| $order_number === ''
|
||||||
|
|| ($paid && ($trade_no === '' || (float) ($data['total_amount'] ?? 0) <= 0))) {
|
||||||
|
return ['success' => false, 'message' => __('支付宝回调身份或订单数据无效', 'aksrt')];
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'order_number' => (string) ($data['out_trade_no'] ?? ''),
|
'order_number' => $order_number,
|
||||||
'amount' => (float) ($data['total_amount'] ?? 0),
|
'amount' => (float) ($data['total_amount'] ?? 0),
|
||||||
'transaction_id' => (string) ($data['trade_no'] ?? ''),
|
'transaction_id' => $trade_no,
|
||||||
'status' => $paid ? 'paid' : 'pending',
|
'status' => $paid ? 'paid' : 'pending',
|
||||||
];
|
];
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
@@ -157,8 +157,10 @@ class AlipayGateway implements \AKSRT\Payment\GatewayInterface {
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'status' => $status,
|
'order_number' => (string) ($data['out_trade_no'] ?? $order_number),
|
||||||
|
'amount' => (float) ($data['total_amount'] ?? 0),
|
||||||
'transaction_id' => (string) ($data['trade_no'] ?? ''),
|
'transaction_id' => (string) ($data['trade_no'] ?? ''),
|
||||||
|
'status' => $status,
|
||||||
];
|
];
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
return ['success' => false, 'message' => $e->getMessage()];
|
return ['success' => false, 'message' => $e->getMessage()];
|
||||||
@@ -166,13 +168,20 @@ class AlipayGateway implements \AKSRT\Payment\GatewayInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public function refund(string $order_number, float $amount, string $request_id = ''): array {
|
public function refund(string $order_number, float $amount, string $request_id = ''): array {
|
||||||
|
if ($amount <= 0) {
|
||||||
|
return ['success' => false, 'status' => 'failed', 'message' => __('退款金额无效', 'aksrt')];
|
||||||
|
}
|
||||||
if (!$this->is_configured()) {
|
if (!$this->is_configured()) {
|
||||||
return ['success' => false, 'message' => __('支付宝未配置', 'aksrt')];
|
return ['success' => false, 'message' => __('支付宝未配置', 'aksrt')];
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
YansongdaPayFactory::boot_alipay();
|
YansongdaPayFactory::boot_alipay();
|
||||||
$refund_request_id = sanitize_key($request_id !== '' ? $request_id : $order_number . '_refund');
|
$refund_request_id = (string) preg_replace(
|
||||||
|
'/[^A-Za-z0-9_-]/',
|
||||||
|
'',
|
||||||
|
$request_id !== '' ? $request_id : $order_number . '_refund'
|
||||||
|
);
|
||||||
$result = Pay::alipay()->refund([
|
$result = Pay::alipay()->refund([
|
||||||
'out_trade_no' => $order_number,
|
'out_trade_no' => $order_number,
|
||||||
'refund_amount' => number_format($amount, 2, '.', ''),
|
'refund_amount' => number_format($amount, 2, '.', ''),
|
||||||
@@ -181,16 +190,51 @@ class AlipayGateway implements \AKSRT\Payment\GatewayInterface {
|
|||||||
]);
|
]);
|
||||||
$data = YansongdaPayFactory::to_array($result);
|
$data = YansongdaPayFactory::to_array($result);
|
||||||
|
|
||||||
|
$fund_change = strtoupper((string) ($data['fund_change'] ?? ''));
|
||||||
|
$status = ($fund_change === 'Y' || (float) ($data['refund_fee'] ?? 0) > 0)
|
||||||
|
? 'succeeded'
|
||||||
|
: 'unknown';
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'refund_id' => (string) ($data['trade_no'] ?? $data['out_trade_no'] ?? ''),
|
'status' => $status,
|
||||||
'message' => __('退款成功', 'aksrt'),
|
'refund_id' => (string) ($data['trade_no'] ?? $data['out_trade_no'] ?? ''),
|
||||||
|
'request_id' => $refund_request_id,
|
||||||
|
'message' => $status === 'succeeded' ? __('退款成功', 'aksrt') : __('退款结果待确认', 'aksrt'),
|
||||||
];
|
];
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
return ['success' => false, 'message' => $e->getMessage()];
|
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_alipay();
|
||||||
|
$params = ['out_request_no' => $request_id, '_action' => 'refund'];
|
||||||
|
if ($order_number !== '') {
|
||||||
|
$params['out_trade_no'] = $order_number;
|
||||||
|
}
|
||||||
|
$result = Pay::alipay()->query($params);
|
||||||
|
$data = YansongdaPayFactory::to_array($result);
|
||||||
|
$refund_status = strtoupper((string) ($data['refund_status'] ?? ''));
|
||||||
|
$refund_amount = (float) ($data['refund_amount'] ?? 0);
|
||||||
|
$status = ($refund_status === 'REFUND_SUCCESS' || $refund_amount > 0)
|
||||||
|
? 'succeeded'
|
||||||
|
: ($refund_status === 'REFUND_FAIL' ? 'failed' : 'pending');
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'status' => $status,
|
||||||
|
'refund_id' => (string) ($data['trade_no'] ?? $data['out_request_no'] ?? ''),
|
||||||
|
];
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return ['success' => false, 'status' => 'unknown', 'message' => $e->getMessage()];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{app_id:string,private_key:string,app_public_cert:string,alipay_public_cert:string,alipay_root_cert:string,payment_type:string}
|
* @return array{app_id:string,private_key:string,app_public_cert:string,alipay_public_cert:string,alipay_root_cert:string,payment_type:string}
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -84,34 +84,13 @@ class PayPalGateway implements \AKSRT\Payment\GatewayInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public function verify_callback(\WP_REST_Request $request): array {
|
public function verify_callback(\WP_REST_Request $request): array {
|
||||||
try {
|
// PayPal orders are finalized only by the authenticated capture call in handle_return().
|
||||||
$body = json_decode($request->get_body(), true);
|
// Never treat an unsigned CHECKOUT.ORDER.APPROVED webhook as payment completion.
|
||||||
$eventType = $body['event_type'] ?? '';
|
return [
|
||||||
|
'success' => false,
|
||||||
if ($eventType !== 'CHECKOUT.ORDER.APPROVED') {
|
'status' => 'pending',
|
||||||
return ['success' => false, 'message' => __('非支付完成事件', 'aksrt')];
|
'message' => __('PayPal 支付需在返回流程中完成捕获确认', 'aksrt'),
|
||||||
}
|
];
|
||||||
|
|
||||||
$resource = $body['resource'] ?? [];
|
|
||||||
$orderNumber = '';
|
|
||||||
$amount = 0;
|
|
||||||
|
|
||||||
foreach ($resource['purchase_units'] ?? [] as $unit) {
|
|
||||||
$orderNumber = $unit['reference_id'] ?? '';
|
|
||||||
$amount = (float) ($unit['amount']['value'] ?? 0);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'success' => true,
|
|
||||||
'order_number' => $orderNumber,
|
|
||||||
'amount' => $amount,
|
|
||||||
'transaction_id' => $resource['id'] ?? '',
|
|
||||||
'status' => 'paid',
|
|
||||||
];
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
return ['success' => false, 'message' => $e->getMessage()];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function query_status(string $order_number): array {
|
public function query_status(string $order_number): array {
|
||||||
@@ -122,7 +101,7 @@ class PayPalGateway implements \AKSRT\Payment\GatewayInterface {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function refund(string $order_number, float $amount): array {
|
public function refund(string $order_number, float $amount, string $request_id = ''): array {
|
||||||
return [
|
return [
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'message' => __('PayPal 退款需要通过后台手动处理', 'aksrt'),
|
'message' => __('PayPal 退款需要通过后台手动处理', 'aksrt'),
|
||||||
|
|||||||
@@ -110,7 +110,8 @@ class StripeGateway implements \AKSRT\Payment\GatewayInterface {
|
|||||||
|
|
||||||
$session = $event->data->object;
|
$session = $event->data->object;
|
||||||
$orderNumber = $session->metadata->order_number ?? '';
|
$orderNumber = $session->metadata->order_number ?? '';
|
||||||
$amount = ($session->amount_total ?? 0) / 100;
|
$currency = strtolower((string) ($session->currency ?? $config['currency'] ?? 'usd'));
|
||||||
|
$amount = $this->from_smallest_unit((int) ($session->amount_total ?? 0), $currency);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'success' => true,
|
'success' => true,
|
||||||
@@ -160,10 +161,19 @@ class StripeGateway implements \AKSRT\Payment\GatewayInterface {
|
|||||||
$status = 'cancelled';
|
$status = 'cancelled';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$transaction_id = is_string($found->payment_intent)
|
||||||
|
? $found->payment_intent
|
||||||
|
: (string) ($found->payment_intent->id ?? '');
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'success' => true,
|
'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'))
|
||||||
|
),
|
||||||
'status' => $status,
|
'status' => $status,
|
||||||
'transaction_id' => $found->payment_intent ?? '',
|
'transaction_id' => $transaction_id,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,7 +183,7 @@ class StripeGateway implements \AKSRT\Payment\GatewayInterface {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function refund(string $order_number, float $amount): array {
|
public function refund(string $order_number, float $amount, string $request_id = ''): array {
|
||||||
if (!$this->is_configured()) {
|
if (!$this->is_configured()) {
|
||||||
return ['success' => false, 'message' => __('Stripe 未配置', 'aksrt')];
|
return ['success' => false, 'message' => __('Stripe 未配置', 'aksrt')];
|
||||||
}
|
}
|
||||||
@@ -208,16 +218,26 @@ class StripeGateway implements \AKSRT\Payment\GatewayInterface {
|
|||||||
|
|
||||||
$currency = strtolower($config['currency'] ?? 'usd');
|
$currency = strtolower($config['currency'] ?? 'usd');
|
||||||
|
|
||||||
|
$options = ['api_key' => $config['secret_key']];
|
||||||
|
if ($request_id !== '') {
|
||||||
|
$options['idempotency_key'] = $request_id;
|
||||||
|
}
|
||||||
$refund = \Stripe\Refund::create([
|
$refund = \Stripe\Refund::create([
|
||||||
'payment_intent' => $paymentIntentId,
|
'payment_intent' => $paymentIntentId,
|
||||||
'amount' => $this->to_smallest_unit($amount, $currency),
|
'amount' => $this->to_smallest_unit($amount, $currency),
|
||||||
'reason' => 'requested_by_customer',
|
'reason' => 'requested_by_customer',
|
||||||
], ['api_key' => $config['secret_key']]);
|
'metadata' => [
|
||||||
|
'aksrt_request_id' => $request_id,
|
||||||
|
'order_number' => $order_number,
|
||||||
|
],
|
||||||
|
], $options);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'refund_id' => $refund->id,
|
'status' => ($refund->status ?? '') === 'succeeded' ? 'succeeded' : 'pending',
|
||||||
'message' => __('退款成功', 'aksrt'),
|
'refund_id' => $refund->id,
|
||||||
|
'request_id' => $request_id,
|
||||||
|
'message' => __('退款请求已提交', 'aksrt'),
|
||||||
];
|
];
|
||||||
} catch (\Stripe\Exception\ApiErrorException $e) {
|
} catch (\Stripe\Exception\ApiErrorException $e) {
|
||||||
return ['success' => false, 'message' => $e->getMessage()];
|
return ['success' => false, 'message' => $e->getMessage()];
|
||||||
@@ -226,6 +246,34 @@ class StripeGateway implements \AKSRT\Payment\GatewayInterface {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(前端使用)。
|
* 获取 Stripe Publishable Key(前端使用)。
|
||||||
*/
|
*/
|
||||||
@@ -271,6 +319,15 @@ class StripeGateway implements \AKSRT\Payment\GatewayInterface {
|
|||||||
return array_values(array_intersect($methods, $valid)) ?: ['card'];
|
return array_values(array_intersect($methods, $valid)) ?: ['card'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function from_smallest_unit(int $amount, string $currency): float {
|
||||||
|
$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)) {
|
||||||
|
return (float) $amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $amount / 100;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 将金额转换为最小单位(分/cent)。
|
* 将金额转换为最小单位(分/cent)。
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -142,6 +142,29 @@ class WechatPayGateway implements \AKSRT\Payment\GatewayInterface {
|
|||||||
$paid = false;
|
$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 [
|
return [
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'order_number' => $order_number,
|
'order_number' => $order_number,
|
||||||
@@ -204,8 +227,10 @@ class WechatPayGateway implements \AKSRT\Payment\GatewayInterface {
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'status' => $status,
|
'order_number' => (string) ($data['out_trade_no'] ?? $order_number),
|
||||||
|
'amount' => ((int) YansongdaPayFactory::dig($data, 'amount.total', 0)) / 100,
|
||||||
'transaction_id' => (string) ($data['transaction_id'] ?? ''),
|
'transaction_id' => (string) ($data['transaction_id'] ?? ''),
|
||||||
|
'status' => $status,
|
||||||
];
|
];
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
return ['success' => false, 'message' => $e->getMessage()];
|
return ['success' => false, 'message' => $e->getMessage()];
|
||||||
@@ -229,13 +254,17 @@ class WechatPayGateway implements \AKSRT\Payment\GatewayInterface {
|
|||||||
$total_fen = (int) YansongdaPayFactory::dig($q, 'amount.total', 0);
|
$total_fen = (int) YansongdaPayFactory::dig($q, 'amount.total', 0);
|
||||||
$refund_fen = (int) round($amount * 100);
|
$refund_fen = (int) round($amount * 100);
|
||||||
if ($total_fen <= 0) {
|
if ($total_fen <= 0) {
|
||||||
$total_fen = $refund_fen;
|
return ['success' => false, 'status' => 'failed', 'message' => __('无法确认原订单金额', 'aksrt')];
|
||||||
}
|
}
|
||||||
if ($refund_fen <= 0) {
|
if ($refund_fen <= 0 || $refund_fen > $total_fen) {
|
||||||
return ['success' => false, 'message' => __('退款金额无效', 'aksrt')];
|
return ['success' => false, 'status' => 'failed', 'message' => __('退款金额无效', 'aksrt')];
|
||||||
}
|
}
|
||||||
|
|
||||||
$refund_request_id = sanitize_key($request_id !== '' ? $request_id : 'REF_' . $order_number);
|
$refund_request_id = (string) preg_replace(
|
||||||
|
'/[^A-Za-z0-9_-]/',
|
||||||
|
'',
|
||||||
|
$request_id !== '' ? $request_id : 'REF_' . $order_number
|
||||||
|
);
|
||||||
$result = Pay::wechat()->refund([
|
$result = Pay::wechat()->refund([
|
||||||
'_action' => 'refund_' . $action,
|
'_action' => 'refund_' . $action,
|
||||||
'out_trade_no' => $order_number,
|
'out_trade_no' => $order_number,
|
||||||
@@ -249,16 +278,49 @@ class WechatPayGateway implements \AKSRT\Payment\GatewayInterface {
|
|||||||
]);
|
]);
|
||||||
$data = YansongdaPayFactory::to_array($result);
|
$data = YansongdaPayFactory::to_array($result);
|
||||||
|
|
||||||
|
$status = strtoupper((string) ($data['status'] ?? ''));
|
||||||
|
$normalized = $status === 'SUCCESS'
|
||||||
|
? 'succeeded'
|
||||||
|
: (in_array($status, ['CLOSED', 'ABNORMAL'], true) ? 'failed' : 'pending');
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'refund_id' => (string) ($data['refund_id'] ?? $data['out_refund_no'] ?? ''),
|
'status' => $normalized,
|
||||||
'message' => __('退款成功', 'aksrt'),
|
'refund_id' => (string) ($data['refund_id'] ?? $data['out_refund_no'] ?? ''),
|
||||||
|
'request_id' => $refund_request_id,
|
||||||
|
'message' => $normalized === 'succeeded' ? __('退款成功', 'aksrt') : __('退款请求已受理', 'aksrt'),
|
||||||
];
|
];
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
return ['success' => false, 'message' => $e->getMessage()];
|
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}
|
* @return array{mch_id:string,appid:string,api_v3_key:string,private_key:string,cert_path:string,payment_type:string}
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ class YansongdaPayFactory {
|
|||||||
*/
|
*/
|
||||||
public static function wechat_from_settings(): array {
|
public static function wechat_from_settings(): array {
|
||||||
$all = self::settings();
|
$all = self::settings();
|
||||||
$notify = home_url('/wp-json/aksrt/v1/payment/wechatpay/notify');
|
$notify = rest_url('aksrt/v1/payment/wechatpay/notify');
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'mch_id' => (string) ($all['wechat_merchantid'] ?? ''),
|
'mch_id' => (string) ($all['wechat_merchantid'] ?? ''),
|
||||||
@@ -75,8 +75,8 @@ class YansongdaPayFactory {
|
|||||||
*/
|
*/
|
||||||
public static function alipay_from_settings(): array {
|
public static function alipay_from_settings(): array {
|
||||||
$all = self::settings();
|
$all = self::settings();
|
||||||
$notify = home_url('/wp-json/aksrt/v1/payment/alipay/notify');
|
$notify = rest_url('aksrt/v1/payment/alipay/notify');
|
||||||
$return = home_url('/wp-json/aksrt/v1/payment/alipay/return');
|
$return = rest_url('aksrt/v1/payment/alipay/return');
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'app_id' => (string) ($all['alipay_appid'] ?? ''),
|
'app_id' => (string) ($all['alipay_appid'] ?? ''),
|
||||||
@@ -159,13 +159,9 @@ class YansongdaPayFactory {
|
|||||||
return $loc;
|
return $loc;
|
||||||
}
|
}
|
||||||
$body = (string) $result->getBody();
|
$body = (string) $result->getBody();
|
||||||
if (preg_match('#action=[\'"]([^\'"]+)[\'"]#i', $body, $m)) {
|
if (preg_match('#^https?://[^\s"\'<>]+$#', trim($body), $m)) {
|
||||||
return html_entity_decode($m[1], ENT_QUOTES);
|
|
||||||
}
|
|
||||||
if (preg_match('#https?://[^\s"\'<>]+#', $body, $m)) {
|
|
||||||
return $m[0];
|
return $m[0];
|
||||||
}
|
}
|
||||||
// Auto-submit HTML form: callers may open as document; store empty payment_url and put HTML in payment_data.
|
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
if (is_object($result)) {
|
if (is_object($result)) {
|
||||||
|
|||||||
@@ -236,9 +236,29 @@ trait OrderRepositoryCore {
|
|||||||
$format[] = '%s';
|
$format[] = '%s';
|
||||||
}
|
}
|
||||||
|
|
||||||
$result = $wpdb->update($this->table(), $update_data, ['order_number' => $order_number], $format, ['%s']);
|
// Compare-and-swap the status so concurrent callbacks cannot both win the
|
||||||
|
// first paid transition and trigger fulfillment twice.
|
||||||
|
$result = $wpdb->update(
|
||||||
|
$this->table(),
|
||||||
|
$update_data,
|
||||||
|
['order_number' => $order_number, 'status' => $current_status],
|
||||||
|
$format,
|
||||||
|
['%s', '%s']
|
||||||
|
);
|
||||||
|
|
||||||
if ($result !== false) {
|
if ($result === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($result === 0) {
|
||||||
|
if (!empty($extra['require_transition'])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$latest = $this->get($order_number);
|
||||||
|
return is_array($latest) && ($latest['status'] ?? '') === $new_status;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($result > 0) {
|
||||||
// 到账事件:让充值/VIP/商品履约等挂接(单一履约入口)。
|
// 到账事件:让充值/VIP/商品履约等挂接(单一履约入口)。
|
||||||
// 仅在「首次」转为 paid 时触发,避免回调多次到达重复履约。
|
// 仅在「首次」转为 paid 时触发,避免回调多次到达重复履约。
|
||||||
if ($new_status === \AKSRT\Core\DomainModel::ORDER_STATUS_PAID && $current_status !== \AKSRT\Core\DomainModel::ORDER_STATUS_PAID) {
|
if ($new_status === \AKSRT\Core\DomainModel::ORDER_STATUS_PAID && $current_status !== \AKSRT\Core\DomainModel::ORDER_STATUS_PAID) {
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package AKSRT
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace AKSRT\Payment;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cross-request lock for creating a payment attempt for one order.
|
||||||
|
*/
|
||||||
|
class PaymentAttemptLock {
|
||||||
|
|
||||||
|
private const TTL = 120;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return string|false Lock token, or false when another request owns it.
|
||||||
|
*/
|
||||||
|
public static function acquire(string $order_number) {
|
||||||
|
$key = self::key($order_number);
|
||||||
|
$token = wp_generate_uuid4();
|
||||||
|
$value = [
|
||||||
|
'token' => $token,
|
||||||
|
'expires' => time() + self::TTL,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (add_option($key, $value, '', false)) {
|
||||||
|
return $token;
|
||||||
|
}
|
||||||
|
|
||||||
|
$current = get_option($key, []);
|
||||||
|
if (is_array($current) && (int) ($current['expires'] ?? 0) < time()) {
|
||||||
|
global $wpdb;
|
||||||
|
$serialized_current = maybe_serialize($current);
|
||||||
|
$serialized_value = maybe_serialize($value);
|
||||||
|
$updated = $wpdb->query($wpdb->prepare(
|
||||||
|
"UPDATE {$wpdb->options}
|
||||||
|
SET option_value = %s
|
||||||
|
WHERE option_name = %s AND option_value = %s",
|
||||||
|
$serialized_value,
|
||||||
|
$key,
|
||||||
|
$serialized_current
|
||||||
|
));
|
||||||
|
if ($updated === 1) {
|
||||||
|
wp_cache_delete($key, 'options');
|
||||||
|
return $token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function release(string $order_number, string $token): void {
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
$key = self::key($order_number);
|
||||||
|
$current = get_option($key, []);
|
||||||
|
if (!is_array($current) || !hash_equals((string) ($current['token'] ?? ''), $token)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$deleted = $wpdb->query($wpdb->prepare(
|
||||||
|
"DELETE FROM {$wpdb->options} WHERE option_name = %s AND option_value = %s",
|
||||||
|
$key,
|
||||||
|
maybe_serialize($current)
|
||||||
|
));
|
||||||
|
if ($deleted === 1) {
|
||||||
|
wp_cache_delete($key, 'options');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function key(string $order_number): string {
|
||||||
|
return 'aksrt_pay_lock_' . hash('sha256', $order_number);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -308,7 +308,17 @@ class UserOrders {
|
|||||||
return ['success' => false, 'message' => __('订单不存在。', 'aksrt')];
|
return ['success' => false, 'message' => __('订单不存在。', 'aksrt')];
|
||||||
}
|
}
|
||||||
|
|
||||||
$payment_method = sanitize_key($payment_method ?: (string) ($order['payment_method'] ?? 'balance'));
|
$status = (string) ($order['status'] ?? '');
|
||||||
|
if (!in_array($status, [Order::STATUS_CREATED, Order::STATUS_PENDING], true)) {
|
||||||
|
return ['success' => false, 'message' => __('当前订单状态不可支付。', 'aksrt')];
|
||||||
|
}
|
||||||
|
|
||||||
|
$stored_method = sanitize_key((string) ($order['payment_method'] ?? ''));
|
||||||
|
$payment_method = sanitize_key($payment_method ?: ($stored_method ?: 'balance'));
|
||||||
|
if ($stored_method !== '' && $payment_method !== $stored_method) {
|
||||||
|
return ['success' => false, 'message' => __('支付方式与订单不一致,请重新下单。', 'aksrt')];
|
||||||
|
}
|
||||||
|
|
||||||
$gateway_manager = class_exists(Gateway::class) ? Gateway::get_instance() : null;
|
$gateway_manager = class_exists(Gateway::class) ? Gateway::get_instance() : null;
|
||||||
$gateway = $gateway_manager ? $gateway_manager->get_gateway($payment_method) : null;
|
$gateway = $gateway_manager ? $gateway_manager->get_gateway($payment_method) : null;
|
||||||
if (!$gateway) {
|
if (!$gateway) {
|
||||||
@@ -318,39 +328,68 @@ class UserOrders {
|
|||||||
return ['success' => false, 'message' => __('该支付方式尚未配置,请联系站长。', 'aksrt')];
|
return ['success' => false, 'message' => __('该支付方式尚未配置,请联系站长。', 'aksrt')];
|
||||||
}
|
}
|
||||||
|
|
||||||
$order['subject'] = $order['subject'] ?? sprintf(__('订单 %s', 'aksrt'), $order_number);
|
$lock = class_exists(PaymentAttemptLock::class)
|
||||||
$payment_result = $gateway->create_payment($order);
|
? PaymentAttemptLock::acquire($order_number)
|
||||||
if (empty($payment_result['success'])) {
|
: false;
|
||||||
return ['success' => false, 'message' => $payment_result['message'] ?? __('支付失败。', 'aksrt')];
|
if ($lock === false) {
|
||||||
|
return ['success' => false, 'message' => __('该订单正在发起支付,请稍后重试。', 'aksrt')];
|
||||||
}
|
}
|
||||||
|
|
||||||
$code_url = $payment_result['payment_data']['code_url'] ?? '';
|
try {
|
||||||
if (!empty($payment_result['payment_url']) || $code_url !== '') {
|
$order['subject'] = $order['subject'] ?? sprintf(__('订单 %s', 'aksrt'), $order_number);
|
||||||
|
$payment_result = $gateway->create_payment($order);
|
||||||
|
if (empty($payment_result['success'])) {
|
||||||
|
return ['success' => false, 'message' => $payment_result['message'] ?? __('支付失败。', 'aksrt')];
|
||||||
|
}
|
||||||
|
|
||||||
|
$code_url = $payment_result['payment_data']['code_url'] ?? '';
|
||||||
|
if (!empty($payment_result['payment_url']) || $code_url !== '') {
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'data' => [
|
||||||
|
'redirect' => true,
|
||||||
|
'payment_url' => (string) ($payment_result['payment_url'] ?? ''),
|
||||||
|
'qr_content' => (string) $code_url,
|
||||||
|
'order_number' => $order_number,
|
||||||
|
'message' => $payment_result['message'] ?? __('请在新页面完成支付。', 'aksrt'),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$paid = $repo->update_status($order_number, Order::STATUS_PAID, [
|
||||||
|
'payment_method' => $payment_method,
|
||||||
|
'payment_transaction_id' => sanitize_text_field((string) ($payment_result['transaction_id'] ?? '')),
|
||||||
|
'note' => __('用户在前台完成支付。', 'aksrt'),
|
||||||
|
'require_transition' => true,
|
||||||
|
]);
|
||||||
|
if (!$paid) {
|
||||||
|
$compensation = $gateway->refund(
|
||||||
|
$order_number,
|
||||||
|
(float) ($order['amount'] ?? 0),
|
||||||
|
'PAY_ROLLBACK_' . substr(hash('sha256', $order_number), 0, 24)
|
||||||
|
);
|
||||||
|
if (empty($compensation['success'])) {
|
||||||
|
error_log(sprintf(
|
||||||
|
'[AKSRT Payment] rollback failed order=%s gateway=%s: %s',
|
||||||
|
$order_number,
|
||||||
|
$payment_method,
|
||||||
|
(string) ($compensation['message'] ?? 'unknown error')
|
||||||
|
));
|
||||||
|
return ['success' => false, 'message' => __('订单状态已变化,扣款补偿失败,请联系管理员处理。', 'aksrt')];
|
||||||
|
}
|
||||||
|
return ['success' => false, 'message' => __('订单状态已变化,扣款已退回。', 'aksrt')];
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'data' => [
|
'data' => [
|
||||||
'redirect' => true,
|
'order' => $this->get_order_detail_view($user_id, $order_number),
|
||||||
'payment_url' => (string) ($payment_result['payment_url'] ?? ''),
|
'message' => $payment_result['message'] ?? __('支付成功。', 'aksrt'),
|
||||||
'qr_content' => (string) $code_url,
|
|
||||||
'order_number' => $order_number,
|
|
||||||
'message' => $payment_result['message'] ?? __('请在新页面完成支付。', 'aksrt'),
|
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
} finally {
|
||||||
|
PaymentAttemptLock::release($order_number, $lock);
|
||||||
}
|
}
|
||||||
|
|
||||||
$repo->update_status($order_number, Order::STATUS_PAID, [
|
|
||||||
'payment_method' => $payment_method,
|
|
||||||
'payment_transaction_id' => sanitize_text_field((string) ($payment_result['transaction_id'] ?? '')),
|
|
||||||
'note' => __('用户在前台完成支付。', 'aksrt'),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'success' => true,
|
|
||||||
'data' => [
|
|
||||||
'order' => $this->get_order_detail_view($user_id, $order_number),
|
|
||||||
'message' => $payment_result['message'] ?? __('支付成功。', 'aksrt'),
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function empty_stats(): array {
|
private function empty_stats(): array {
|
||||||
|
|||||||
@@ -184,8 +184,21 @@ class CheckoutService {
|
|||||||
return ['success' => false, 'message' => __('订单不存在。', 'aksrt')];
|
return ['success' => false, 'message' => __('订单不存在。', 'aksrt')];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 未显式指定时,使用订单创建时选定的支付方式。
|
$status = (string) ($order['status'] ?? '');
|
||||||
$payment_method = sanitize_key($payment_method ?: (string) ($order['payment_method'] ?? 'balance'));
|
if (!in_array($status, [
|
||||||
|
\AKSRT\Payment\Order::STATUS_CREATED,
|
||||||
|
\AKSRT\Payment\Order::STATUS_PENDING,
|
||||||
|
], true)) {
|
||||||
|
return ['success' => false, 'message' => __('当前订单状态不可支付。', 'aksrt')];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Payment attempts are bound to the method selected when the order was created.
|
||||||
|
$stored_method = sanitize_key((string) ($order['payment_method'] ?? ''));
|
||||||
|
$payment_method = sanitize_key($payment_method ?: ($stored_method ?: 'balance'));
|
||||||
|
if ($stored_method !== '' && $payment_method !== $stored_method) {
|
||||||
|
return ['success' => false, 'message' => __('支付方式与订单不一致,请重新下单。', 'aksrt')];
|
||||||
|
}
|
||||||
|
|
||||||
$gateway_manager = class_exists('\AKSRT\Payment\Gateway') ? \AKSRT\Payment\Gateway::get_instance() : null;
|
$gateway_manager = class_exists('\AKSRT\Payment\Gateway') ? \AKSRT\Payment\Gateway::get_instance() : null;
|
||||||
$gateway = $gateway_manager ? $gateway_manager->get_gateway($payment_method) : null;
|
$gateway = $gateway_manager ? $gateway_manager->get_gateway($payment_method) : null;
|
||||||
|
|
||||||
@@ -197,8 +210,16 @@ class CheckoutService {
|
|||||||
return ['success' => false, 'message' => __('该支付方式尚未配置,请联系站长。', 'aksrt')];
|
return ['success' => false, 'message' => __('该支付方式尚未配置,请联系站长。', 'aksrt')];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 携带订单号与摘要,供网关生成支付单。
|
$lock = class_exists('\AKSRT\Payment\PaymentAttemptLock')
|
||||||
$order['subject'] = $order['subject'] ?? sprintf(__('订单 %s', 'aksrt'), $order_number);
|
? \AKSRT\Payment\PaymentAttemptLock::acquire($order_number)
|
||||||
|
: false;
|
||||||
|
if ($lock === false) {
|
||||||
|
return ['success' => false, 'message' => __('该订单正在发起支付,请稍后重试。', 'aksrt')];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 携带订单号与摘要,供网关生成支付单。
|
||||||
|
$order['subject'] = $order['subject'] ?? sprintf(__('订单 %s', 'aksrt'), $order_number);
|
||||||
|
|
||||||
// Points payment: synchronous deduction via \AKSRT\Credits\Credits
|
// Points payment: synchronous deduction via \AKSRT\Credits\Credits
|
||||||
if ($payment_method === 'points') {
|
if ($payment_method === 'points') {
|
||||||
@@ -227,11 +248,29 @@ class CheckoutService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 同步型网关(站内余额):create_payment 已实际扣款,标记已支付(触发 aksrt_order_paid 履约)。
|
// 同步型网关(站内余额):create_payment 已实际扣款,标记已支付(触发 aksrt_order_paid 履约)。
|
||||||
$repo->update_status($order_number, \AKSRT\Payment\Order::STATUS_PAID, [
|
$paid = $repo->update_status($order_number, \AKSRT\Payment\Order::STATUS_PAID, [
|
||||||
'payment_method' => $payment_method,
|
'payment_method' => $payment_method,
|
||||||
'payment_transaction_id' => sanitize_text_field((string) ($payment_result['transaction_id'] ?? '')),
|
'payment_transaction_id' => sanitize_text_field((string) ($payment_result['transaction_id'] ?? '')),
|
||||||
'note' => __('用户在前台完成支付。', 'aksrt'),
|
'note' => __('用户在前台完成支付。', 'aksrt'),
|
||||||
|
'require_transition' => true,
|
||||||
]);
|
]);
|
||||||
|
if (!$paid) {
|
||||||
|
$compensation = $gateway->refund(
|
||||||
|
$order_number,
|
||||||
|
(float) ($order['amount'] ?? 0),
|
||||||
|
'PAY_ROLLBACK_' . substr(hash('sha256', $order_number), 0, 24)
|
||||||
|
);
|
||||||
|
if (empty($compensation['success'])) {
|
||||||
|
error_log(sprintf(
|
||||||
|
'[AKSRT Payment] rollback failed order=%s gateway=%s: %s',
|
||||||
|
$order_number,
|
||||||
|
$payment_method,
|
||||||
|
(string) ($compensation['message'] ?? 'unknown error')
|
||||||
|
));
|
||||||
|
return ['success' => false, 'message' => __('订单状态已变化,扣款补偿失败,请联系管理员处理。', 'aksrt')];
|
||||||
|
}
|
||||||
|
return ['success' => false, 'message' => __('订单状态已变化,扣款已退回。', 'aksrt')];
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'success' => true,
|
'success' => true,
|
||||||
@@ -240,6 +279,9 @@ class CheckoutService {
|
|||||||
'message' => $payment_result['message'] ?? __('支付成功。', 'aksrt'),
|
'message' => $payment_result['message'] ?? __('支付成功。', 'aksrt'),
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
} finally {
|
||||||
|
\AKSRT\Payment\PaymentAttemptLock::release($order_number, $lock);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function pay_with_points(int $user_id, string $order_number, array $order): array {
|
private function pay_with_points(int $user_id, string $order_number, array $order): array {
|
||||||
@@ -269,11 +311,30 @@ class CheckoutService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$repo = \AKSRT\Payment\OrderRepository::get_instance();
|
$repo = \AKSRT\Payment\OrderRepository::get_instance();
|
||||||
$repo->update_status($order_number, \AKSRT\Payment\Order::STATUS_PAID, [
|
$paid = $repo->update_status($order_number, \AKSRT\Payment\Order::STATUS_PAID, [
|
||||||
'payment_method' => 'points',
|
'payment_method' => 'points',
|
||||||
'payment_transaction_id' => 'points_' . $order_number,
|
'payment_transaction_id' => 'points_' . $order_number,
|
||||||
'note' => sprintf(__('积分支付 %d 积分。', 'aksrt'), $amount),
|
'note' => sprintf(__('积分支付 %d 积分。', 'aksrt'), $amount),
|
||||||
|
'require_transition' => true,
|
||||||
]);
|
]);
|
||||||
|
if (!$paid) {
|
||||||
|
$compensation = \AKSRT\Credits\Credits::add(
|
||||||
|
$user_id,
|
||||||
|
$amount,
|
||||||
|
'shop_points_pay_rollback',
|
||||||
|
['order' => $order_number],
|
||||||
|
sprintf(__('订单 %s 支付失败退回积分', 'aksrt'), $order_number)
|
||||||
|
);
|
||||||
|
if (is_wp_error($compensation)) {
|
||||||
|
error_log(sprintf(
|
||||||
|
'[AKSRT Payment] points rollback failed order=%s: %s',
|
||||||
|
$order_number,
|
||||||
|
$compensation->get_error_message()
|
||||||
|
));
|
||||||
|
return ['success' => false, 'message' => __('订单状态已变化,积分补偿失败,请联系管理员处理。', 'aksrt')];
|
||||||
|
}
|
||||||
|
return ['success' => false, 'message' => __('订单状态已变化,积分已退回。', 'aksrt')];
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'success' => true,
|
'success' => true,
|
||||||
|
|||||||
@@ -28,14 +28,17 @@ trait Part1 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$ticket_id = \AKSRT\Payment\AfterSale::get_instance()->create([
|
$ticket_id = \AKSRT\Payment\AfterSale::get_instance()->create([
|
||||||
'order_number' => $order_number,
|
'order_number' => $order_number,
|
||||||
'user_id' => $user_id,
|
'applicant_user_id' => $user_id,
|
||||||
'type' => $type,
|
'type' => $type,
|
||||||
'reason' => $reason,
|
'reason' => $reason,
|
||||||
'amount' => (float) ($order['amount'] ?? 0),
|
'amount' => (float) ($payload['amount'] ?? $order['amount'] ?? 0),
|
||||||
'note' => sanitize_textarea_field((string) ($payload['note'] ?? '')),
|
'note' => sanitize_textarea_field((string) ($payload['note'] ?? '')),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
if (is_wp_error($ticket_id)) {
|
||||||
|
return ['success' => false, 'message' => $ticket_id->get_error_message()];
|
||||||
|
}
|
||||||
if ($ticket_id <= 0) {
|
if ($ticket_id <= 0) {
|
||||||
return ['success' => false, 'message' => __('售后申请提交失败。', 'aksrt')];
|
return ['success' => false, 'message' => __('售后申请提交失败。', 'aksrt')];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user