feat(payment): rebuild order details and asset records

This commit is contained in:
kate522 committed 2026-10-05 16:42:25 +08:00
1 parent 476e2f961d
commit 7b55cfb190
57 files changed
+2080 -1294

No files matched your search

+554
View File
@@ -0,0 +1,554 @@
<?php
/** User applications and administrator review for full order refunds. */
namespace AKSRT\Payment;
use AKSRT\Core\DomainModel;
use AKSRT\Ledger\AssetAmount;
use AKSRT\Ledger\AssetType;
use AKSRT\Ledger\LedgerCommand;
use AKSRT\Ledger\LedgerService;
defined('ABSPATH') || exit;
final class AfterSaleService {
public const STATUS_PENDING = 'pending';
public const STATUS_PROCESSING = 'processing';
public const STATUS_APPROVED = 'approved';
public const STATUS_REJECTED = 'rejected';
public const STATUS_CANCELLED = 'cancelled';
public const STATUS_COMPLETED = 'completed';
private static ?self $instance = null;
public static function get_instance(): self { return self::$instance ??= new self(); }
public function init(): void { add_action('rest_api_init', [$this, 'register_routes']); }
public function register_routes(): void {
$number = '(?P<order_number>[0-9]{20}|[0-9]{22})';
register_rest_route('aksrt/v1', '/orders/' . $number . '/after-sale', [
['methods' => \WP_REST_Server::CREATABLE, 'callback' => [$this, 'rest_submit'], 'permission_callback' => [$this, 'require_user'], 'args' => [
'order_number' => ['required' => true, 'sanitize_callback' => 'sanitize_text_field'],
'reason' => ['required' => true, 'sanitize_callback' => 'sanitize_textarea_field'],
]],
['methods' => \WP_REST_Server::READABLE, 'callback' => [$this, 'rest_list'], 'permission_callback' => [$this, 'require_user'], 'args' => [
'order_number' => ['required' => true, 'sanitize_callback' => 'sanitize_text_field'],
]],
['methods' => 'DELETE', 'callback' => [$this, 'rest_cancel'], 'permission_callback' => [$this, 'require_user'], 'args' => [
'order_number' => ['required' => true, 'sanitize_callback' => 'sanitize_text_field'],
]],
]);
}
public function require_user(\WP_REST_Request $request) {
if (!is_user_logged_in()) return new \WP_Error('aksrt_need_login', __('请先登录', 'aksrt'), ['status' => 401]);
$nonce = (string) $request->get_header('X-WP-Nonce');
return $nonce !== '' && wp_verify_nonce($nonce, 'wp_rest') ? true : new \WP_Error('aksrt_rest_nonce', __('安全验证失败,请刷新页面后重试', 'aksrt'), ['status' => 403]);
}
public function rest_submit(\WP_REST_Request $request) {
$result = $this->create(get_current_user_id(), (string) $request->get_param('order_number'), (string) $request->get_param('reason'));
return is_wp_error($result) ? $result : rest_ensure_response(['success' => true, 'data' => $result]);
}
public function rest_list(\WP_REST_Request $request) {
$number = sanitize_text_field((string) $request->get_param('order_number'));
if (!OrderRepository::get_instance()->get_user_order(get_current_user_id(), $number)) return new \WP_Error('aksrt_order_missing', __('订单不存在', 'aksrt'), ['status' => 404]);
return rest_ensure_response(['success' => true, 'data' => $this->list_for_order($number)]);
}
public function rest_cancel(\WP_REST_Request $request) {
$result = $this->cancel_for_user(get_current_user_id(), (string) $request->get_param('order_number'));
return is_wp_error($result) ? $result : rest_ensure_response(['success' => true, 'data' => $result]);
}
public function create(int $user_id, string $order_number, string $reason) {
$reason = sanitize_textarea_field($reason);
if ($user_id <= 0 || !preg_match('/^(?:\d{20}|\d{22})$/', $order_number) || $reason === '' || mb_strlen($reason) > 2000) {
return new \WP_Error('aksrt_after_sale_invalid', __('请填写有效的售后原因', 'aksrt'), ['status' => 400]);
}
global $wpdb;
$lock = 'aksrt_after_sale_' . md5($order_number);
if ((int) $wpdb->get_var($wpdb->prepare('SELECT GET_LOCK(%s, 5)', $lock)) !== 1) return new \WP_Error('aksrt_after_sale_busy', __('订单正在处理中,请稍后重试', 'aksrt'), ['status' => 409]);
try {
$order = OrderRepository::get_instance()->get_user_order($user_id, $order_number);
if (!$order || (string) ($order['status'] ?? '') !== Order::STATUS_PAID) return new \WP_Error('aksrt_after_sale_order', __('只有已支付订单可以申请售后', 'aksrt'), ['status' => 409]);
$active = $this->find_active($order_number);
if ($active) {
if ((int) ($active['user_id'] ?? 0) === $user_id && hash_equals((string) ($active['reason'] ?? ''), $reason)) {
return $active;
}
return new \WP_Error('aksrt_after_sale_exists', __('该订单已有未结束售后申请', 'aksrt'), ['status' => 409]);
}
$amount = $this->refund_amount($order);
if ($amount === null) return new \WP_Error('aksrt_after_sale_amount', __('订单退款金额无效', 'aksrt'), ['status' => 400]);
$inserted = $wpdb->insert($this->table(), [
'order_number' => $order_number, 'user_id' => $user_id, 'aftersale_type' => 'refund',
'reason' => $reason, 'refund_amount' => $amount, 'status' => self::STATUS_PENDING,
'created_at' => current_time('mysql'),
], ['%s', '%d', '%s', '%s', '%s', '%s', '%s']);
return $inserted === 1 ? $this->get((int) $wpdb->insert_id) : new \WP_Error('aksrt_after_sale_db', __('售后申请保存失败', 'aksrt'), ['status' => 500]);
} finally {
$wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lock));
}
}
public function cancel_for_user(int $user_id, string $order_number) {
if ($user_id <= 0 || !preg_match('/^(?:\d{20}|\d{22})$/', $order_number)) return new \WP_Error('aksrt_after_sale_invalid', __('售后申请无效', 'aksrt'), ['status' => 400]);
global $wpdb;
$lock = 'aksrt_after_sale_' . md5($order_number);
if ((int) $wpdb->get_var($wpdb->prepare('SELECT GET_LOCK(%s, 5)', $lock)) !== 1) return new \WP_Error('aksrt_after_sale_busy', __('售后申请正在处理中,请稍后重试', 'aksrt'), ['status' => 409]);
try {
$row = $this->find_active($order_number);
if (!$row || (int) ($row['user_id'] ?? 0) !== $user_id) return new \WP_Error('aksrt_after_sale_missing', __('没有可取消的售后申请', 'aksrt'), ['status' => 404]);
if ((string) ($row['status'] ?? '') !== self::STATUS_PENDING) return new \WP_Error('aksrt_after_sale_state', __('售后申请已进入处理阶段,无法取消', 'aksrt'), ['status' => 409]);
if (!$this->update((int) $row['id'], ['status' => self::STATUS_CANCELLED])) return new \WP_Error('aksrt_after_sale_db', __('取消售后申请失败', 'aksrt'), ['status' => 500]);
return $this->get((int) $row['id']);
} finally {
$wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lock));
}
}
/** @return list<array<string,mixed>> */
public function list_for_order(string $order_number): array {
global $wpdb;
$rows = $wpdb->get_results($wpdb->prepare("SELECT * FROM {$this->table()} WHERE order_number = %s ORDER BY id DESC", $order_number), ARRAY_A);
return array_map([$this, 'normalize'], (array) $rows);
}
/** @return list<array<string,mixed>> */
public function admin_list(array $args = []): array {
global $wpdb;
$where = [];
$params = [];
$status = sanitize_key((string) ($args['status'] ?? ''));
$valid = [self::STATUS_PENDING, self::STATUS_PROCESSING, self::STATUS_APPROVED, self::STATUS_REJECTED, self::STATUS_CANCELLED, self::STATUS_COMPLETED];
if ($status !== '' && in_array($status, $valid, true)) { $where[] = 'status = %s'; $params[] = $status; }
foreach (['date_from' => '>=', 'date_to' => '<='] as $key => $operator) {
$date = sanitize_text_field((string) ($args[$key] ?? ''));
if ($date !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
$where[] = 'created_at ' . $operator . ' %s';
$params[] = $operator === '>=' ? $date . ' 00:00:00' : $date . ' 23:59:59';
}
}
$limit = max(1, min(100, (int) ($args['limit'] ?? 20)));
$offset = max(0, (int) ($args['offset'] ?? 0));
$sql = "SELECT * FROM {$this->table()}" . ($where ? ' WHERE ' . implode(' AND ', $where) : '') . ' ORDER BY id DESC LIMIT %d OFFSET %d';
$params[] = $limit; $params[] = $offset;
return array_map([$this, 'normalize'], (array) $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A));
}
public function count(array $args = []): int {
global $wpdb;
$status = sanitize_key((string) ($args['status'] ?? ''));
$where = [];
$params = [];
if ($status !== '') { $where[] = 'status = %s'; $params[] = $status; }
foreach (['date_from' => '>=', 'date_to' => '<='] as $key => $operator) {
$date = sanitize_text_field((string) ($args[$key] ?? ''));
if ($date !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
$where[] = 'created_at ' . $operator . ' %s';
$params[] = $operator === '>=' ? $date . ' 00:00:00' : $date . ' 23:59:59';
}
}
$sql = "SELECT COUNT(*) FROM {$this->table()}" . ($where ? ' WHERE ' . implode(' AND ', $where) : '');
return (int) ($params ? $wpdb->get_var($wpdb->prepare($sql, $params)) : $wpdb->get_var($sql));
}
public function count_user_open(int $user_id): int {
if ($user_id <= 0) return 0;
global $wpdb;
return (int) $wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM {$this->table()} WHERE user_id = %d AND status IN ('pending','processing','approved')", $user_id));
}
/** A refund status transition is valid only for its own active application. */
public function can_finalize_order_refund(int $after_sale_id, string $order_number): bool {
if ($after_sale_id <= 0 || $order_number === '') return false;
$row = $this->get($after_sale_id);
return is_array($row)
&& hash_equals((string) ($row['order_number'] ?? ''), $order_number)
&& in_array((string) ($row['status'] ?? ''), [self::STATUS_PENDING, self::STATUS_PROCESSING, self::STATUS_APPROVED], true);
}
public function review(int $id, string $decision, string $note = '') {
if (!current_user_can('manage_options')) return new \WP_Error('aksrt_after_sale_forbidden', __('权限不足', 'aksrt'), ['status' => 403]);
$decision = sanitize_key($decision);
if (!in_array($decision, ['approve', 'reject', 'complete'], true)) return new \WP_Error('aksrt_after_sale_decision', __('审核动作无效', 'aksrt'), ['status' => 400]);
global $wpdb;
$initial = $this->get($id);
if (!$initial) return new \WP_Error('aksrt_after_sale_state', __('售后申请已处理或不存在', 'aksrt'), ['status' => 409]);
$order_number = (string) ($initial['order_number'] ?? '');
$lock = 'aksrt_after_sale_' . md5($order_number);
if ((int) $wpdb->get_var($wpdb->prepare('SELECT GET_LOCK(%s, 5)', $lock)) !== 1) return new \WP_Error('aksrt_after_sale_busy', __('售后申请正在处理中,请稍后重试', 'aksrt'), ['status' => 409]);
try {
$row = $this->get($id);
if (!$row || !hash_equals($order_number, (string) ($row['order_number'] ?? '')) || !in_array((string) $row['status'], [self::STATUS_PENDING, self::STATUS_PROCESSING], true)) return new \WP_Error('aksrt_after_sale_state', __('售后申请已处理或不存在', 'aksrt'), ['status' => 409]);
$note = sanitize_textarea_field($note);
if ((string) $row['status'] === self::STATUS_PROCESSING) {
if ($decision === 'complete') {
$gateway_result = json_decode((string) ($row['gateway_result'] ?? ''), true);
if (!is_array($gateway_result) || sanitize_key((string) ($gateway_result['status'] ?? '')) !== 'manual') {
return new \WP_Error('aksrt_after_sale_manual_only', __('该申请不是待人工确认的退款', 'aksrt'), ['status' => 409]);
}
$order = OrderRepository::get_instance()->get((string) $row['order_number']);
if (!$order) return new \WP_Error('aksrt_after_sale_order', __('订单不存在', 'aksrt'), ['status' => 404]);
$finished = $this->finish_refund($row, $order, $note !== '' ? $note : (string) ($row['admin_note'] ?? ''));
return is_wp_error($finished) ? $finished : $this->get((int) $row['id']);
}
if ($decision === 'reject') {
$gateway_result = json_decode((string) ($row['gateway_result'] ?? ''), true);
if (!is_array($gateway_result) || sanitize_key((string) ($gateway_result['status'] ?? '')) !== 'manual') {
return new \WP_Error('aksrt_after_sale_processing', __('退款处理中,不能拒绝;请同步退款结果', 'aksrt'), ['status' => 409]);
}
$order = OrderRepository::get_instance()->get((string) $row['order_number']);
if (!$order) return new \WP_Error('aksrt_after_sale_order', __('订单不存在', 'aksrt'), ['status' => 404]);
$type = DomainModel::resolve_order_product_type((string) ($order['product_type'] ?? ''), (array) ($order['meta_payload'] ?? []));
$revoke = $this->assets_to_revoke($order, $type, (array) ($order['meta_payload'] ?? []));
if (is_wp_error($revoke)) return $revoke;
if ($revoke !== []) {
$compensated = $this->compensate_revoke($row, $order, $revoke, $note);
if (is_wp_error($compensated)) return $compensated;
}
if (!$this->update((int) $row['id'], ['status' => self::STATUS_REJECTED, 'admin_note' => $note !== '' ? $note : __('管理员确认未执行渠道退款,已恢复冻结资产', 'aksrt')])) {
return new \WP_Error('aksrt_after_sale_db', __('售后审核保存失败', 'aksrt'), ['status' => 500]);
}
return $this->get((int) $row['id']);
}
return $this->sync_gateway_refund($row, $note);
}
if ($decision === 'complete') return new \WP_Error('aksrt_after_sale_manual_only', __('只有待人工确认的退款可以直接完成', 'aksrt'), ['status' => 409]);
if ($decision === 'reject') {
if (!$this->update($id, ['status' => self::STATUS_REJECTED, 'admin_note' => $note, 'reviewed_at' => current_time('mysql')])) {
return new \WP_Error('aksrt_after_sale_db', __('售后审核保存失败', 'aksrt'), ['status' => 500]);
}
return $this->get($id);
}
return $this->approve($row, $note);
} finally {
$wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lock));
}
}
private function approve(array $row, string $note) {
$order_number = (string) $row['order_number'];
$order = OrderRepository::get_instance()->get($order_number);
if (!$order || (string) ($order['status'] ?? '') !== Order::STATUS_PAID) return new \WP_Error('aksrt_after_sale_order', __('订单已不是可退款状态', 'aksrt'), ['status' => 409]);
$type = DomainModel::resolve_order_product_type((string) ($order['product_type'] ?? ''), (array) ($order['meta_payload'] ?? []));
$meta = is_array($order['meta_payload'] ?? null) ? $order['meta_payload'] : [];
$method = sanitize_key((string) ($order['payment_method'] ?? ''));
if ($type === DomainModel::ORDER_PRODUCT_VIP && !$this->vip_reversal_is_safe($order, $meta)) return new \WP_Error('aksrt_after_sale_vip_snapshot', __('会员权益已变化或缺少快照,不能安全撤销', 'aksrt'), ['status' => 409]);
$revoke = $this->assets_to_revoke($order, $type, $meta);
if (is_wp_error($revoke)) return $revoke;
if ($method === 'card_pass') {
if (!$this->asset_entries_available($revoke)) return new \WP_Error('aksrt_after_sale_asset', __('当前资产不足,不能撤销卡密权益', 'aksrt'), ['status' => 409]);
return $this->commit_status_refund($row, $order, $note, $revoke);
}
if ($method === 'free') {
return $this->commit_status_refund($row, $order, $note, $revoke);
}
$refund_local = in_array($method, ['balance', 'points'], true);
if ($refund_local) {
$refund_asset = $method === 'points' ? AssetType::POINTS : AssetType::BALANCE;
$refund_minor = $refund_asset === AssetType::POINTS ? max(0, (int) ($order['amount'] ?? 0)) : $this->cash_minor((string) ($row['refund_amount'] ?? '0'));
if ($refund_minor <= 0) return new \WP_Error('aksrt_after_sale_amount', __('退款金额无效', 'aksrt'), ['status' => 400]);
$entries = $revoke;
// A points-purchase reversal already includes the original balance payment
// in assets_to_revoke(); do not return that payment twice.
if (!($type === DomainModel::ORDER_PRODUCT_POINTS && $method === 'balance')) {
$entries[] = ['user_id' => (int) $order['user_id'], 'asset' => $refund_asset->value, 'delta_minor' => $refund_minor, 'entry_type' => 'credit', 'remark' => sprintf(__('订单 %s 售后退款', 'aksrt'), $order_number)];
}
return $this->commit_local_refund($row, $order, $entries, $note);
}
$gateway = Gateway::get_instance()->get_gateway($method);
if (!$gateway || !method_exists($gateway, 'refund')) return new \WP_Error('aksrt_after_sale_gateway', __('支付渠道不可用,无法退款', 'aksrt'), ['status' => 409]);
$request_id = (string) ($row['gateway_request_id'] ?? '') ?: 'AS_' . (int) $row['id'];
if (!$this->asset_entries_available($revoke)) {
return new \WP_Error('aksrt_after_sale_asset', __('当前资产不足,不能审核通过退款', 'aksrt'), ['status' => 409]);
}
if (!$this->update((int) $row['id'], ['status' => self::STATUS_PROCESSING, 'admin_note' => $note, 'gateway_request_id' => $request_id, 'reviewed_at' => current_time('mysql')])) {
return new \WP_Error('aksrt_after_sale_db', __('售后状态保存失败,未发起退款', 'aksrt'), ['status' => 500]);
}
if ($revoke !== []) {
$held = $this->post_asset_entries($row, $order, $revoke, 'after-sale-revoke', 'after_sale_revoke', $note);
if (is_wp_error($held)) {
$this->update((int) $row['id'], ['status' => self::STATUS_PENDING, 'gateway_request_id' => null, 'reviewed_at' => null]);
return $held;
}
}
try {
$result = $gateway->refund($order_number, (string) $row['refund_amount'], $request_id);
} catch (\Throwable $error) {
$result = ['success' => false, 'status' => 'unknown', 'message' => $error->getMessage()];
}
if (!is_array($result)) {
$result = ['success' => false, 'status' => 'unknown', 'message' => __('支付渠道未返回有效退款结果', 'aksrt')];
}
$this->update((int) $row['id'], ['gateway_result' => wp_json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 'gateway_refund_id' => sanitize_text_field((string) ($result['refund_id'] ?? ''))]);
$status = sanitize_key((string) ($result['status'] ?? 'unknown'));
if ($status === 'failed') {
if ($revoke !== []) {
$compensated = $this->compensate_revoke($row, $order, $revoke, $note);
if (is_wp_error($compensated)) {
$this->update((int) $row['id'], ['admin_note' => __('渠道明确拒绝退款,但资产补偿失败;申请保持处理中,可再次同步重试补偿。', 'aksrt') . ' ' . $compensated->get_error_message()]);
return $this->get((int) $row['id']);
}
}
$this->update((int) $row['id'], ['status' => self::STATUS_REJECTED, 'admin_note' => sanitize_textarea_field((string) ($result['message'] ?? $note))]);
return $this->get((int) $row['id']);
}
// Missing, unknown, and pending provider outcomes are ambiguous. Keep the
// application processing and the asset clawback in place until query_refund
// explicitly confirms success or failure.
if (empty($result['success']) || $status !== 'succeeded') {
$this->update((int) $row['id'], ['admin_note' => sanitize_textarea_field((string) ($result['message'] ?? __('退款结果仍待支付渠道确认,请稍后同步或人工核对', 'aksrt')))]);
return $this->get((int) $row['id']);
}
$finished = $this->finish_refund($row, $order, $note);
return is_wp_error($finished) ? $finished : $this->get((int) $row['id']);
}
private function commit_local_refund(array $row, array $order, array $entries, string $note) {
$number = (string) $order['order_number'];
$commission = CommissionLedger::get_instance()->prepare_full_refund($number);
if (is_wp_error($commission)) return $commission;
$guard = static fn() => CommissionLedger::get_instance()->guard_full_refund($commission);
$mutation = function () use ($row, $order, $note, $commission) {
$commission_guard = CommissionLedger::get_instance()->guard_full_refund($commission);
if (is_wp_error($commission_guard) || $commission_guard !== true) return is_wp_error($commission_guard) ? $commission_guard : new \WP_Error('aksrt_commission_state', __('返佣记录状态已变化', 'aksrt'), ['status' => 409]);
$finished = $this->finish_refund($row, $order, $note, false);
if ($finished === true) {
LedgerService::get_instance()->after_commit(static function (): void {
OrderRepository::get_instance()->dispatch_managed_status_events();
});
}
return $finished;
};
$result = LedgerService::get_instance()->post(new LedgerCommand(
\AKSRT\Ledger\OperationKey::deterministic('after-sale-refund', [(int) $row['id']]), 'order_refund', $entries,
'order', $number, get_current_user_id(), ['order_number' => $number]
), $guard, $mutation);
if (is_wp_error($result)) return $result;
CommissionLedger::get_instance()->clawback_after_refund($commission);
return $this->get((int) $row['id']);
}
private function post_asset_entries(array $row, array $order, array $entries, string $prefix, string $reason, string $note) {
return LedgerService::get_instance()->post(new LedgerCommand(
\AKSRT\Ledger\OperationKey::deterministic($prefix, [(int) $row['id']]), $reason, $entries,
'order', (string) $order['order_number'], get_current_user_id(), ['order_number' => (string) $order['order_number'], 'after_sale_id' => (int) $row['id']]
));
}
private function commit_status_refund(array $row, array $order, string $note, array $entries = []) {
global $wpdb;
$number = (string) $order['order_number'];
$commission = CommissionLedger::get_instance()->prepare_full_refund($number);
if (is_wp_error($commission)) return $commission;
if ($entries !== []) {
$mutation = function () use ($row, $order, $note, $commission) {
$guard = CommissionLedger::get_instance()->guard_full_refund($commission);
if (is_wp_error($guard) || $guard !== true) return is_wp_error($guard) ? $guard : new \WP_Error('aksrt_commission_state', __('返佣记录状态已变化', 'aksrt'), ['status' => 409]);
$finished = $this->finish_refund($row, $order, $note, false);
if ($finished === true) {
LedgerService::get_instance()->after_commit(static function (): void { OrderRepository::get_instance()->dispatch_managed_status_events(); });
}
return $finished;
};
$result = LedgerService::get_instance()->post(new LedgerCommand(
\AKSRT\Ledger\OperationKey::deterministic('after-sale-card-refund', [(int) $row['id']]), 'order_refund', $entries,
'order', $number, get_current_user_id(), ['order_number' => $number]
), static fn() => CommissionLedger::get_instance()->guard_full_refund($commission), $mutation);
if (is_wp_error($result)) return $result;
CommissionLedger::get_instance()->clawback_after_refund($commission);
return $this->get((int) $row['id']);
}
if ($wpdb->query('START TRANSACTION') === false) return new \WP_Error('aksrt_after_sale_finish', __('无法开始退款状态更新', 'aksrt'), ['status' => 500]);
$guard = CommissionLedger::get_instance()->guard_full_refund($commission);
if (is_wp_error($guard) || $guard !== true) { $wpdb->query('ROLLBACK'); return is_wp_error($guard) ? $guard : new \WP_Error('aksrt_commission_state', __('返佣记录状态已变化', 'aksrt'), ['status' => 409]); }
$finished = $this->finish_order_and_application($row, $order, $note);
if (is_wp_error($finished) || !$finished) { $wpdb->query('ROLLBACK'); return is_wp_error($finished) ? $finished : new \WP_Error('aksrt_after_sale_finish', __('退款状态保存失败', 'aksrt'), ['status' => 500]); }
$applied = CommissionLedger::get_instance()->apply_full_refund($commission);
if (is_wp_error($applied) || $wpdb->query('COMMIT') === false) { $wpdb->query('ROLLBACK'); return is_wp_error($applied) ? $applied : new \WP_Error('aksrt_after_sale_finish', __('退款状态保存失败', 'aksrt'), ['status' => 500]); }
OrderRepository::get_instance()->dispatch_managed_status_events();
CommissionLedger::get_instance()->clawback_after_refund($commission);
return $this->get((int) $row['id']);
}
private function sync_gateway_refund(array $row, string $note) {
$order = OrderRepository::get_instance()->get((string) $row['order_number']);
$method = sanitize_key((string) ($order['payment_method'] ?? ''));
$gateway = Gateway::get_instance()->get_gateway($method);
$request_id = (string) ($row['gateway_request_id'] ?? '');
if (!$order || !$gateway || !method_exists($gateway, 'query_refund') || $request_id === '') return new \WP_Error('aksrt_after_sale_sync_unavailable', __('该支付渠道暂不支持自动查询退款状态', 'aksrt'), ['status' => 409]);
try {
$result = $gateway->query_refund($request_id, (string) $row['order_number']);
} catch (\Throwable $error) {
$result = ['success' => false, 'status' => 'unknown', 'message' => $error->getMessage()];
}
if (!is_array($result)) {
$result = ['success' => false, 'status' => 'unknown', 'message' => __('支付渠道未返回有效退款结果', 'aksrt')];
}
$this->update((int) $row['id'], ['gateway_result' => wp_json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 'gateway_refund_id' => sanitize_text_field((string) ($result['refund_id'] ?? $row['gateway_refund_id'] ?? ''))]);
$status = sanitize_key((string) ($result['status'] ?? 'unknown'));
if ($status === 'succeeded' && !empty($result['success'])) {
$finished = $this->finish_refund($row, $order, $note !== '' ? $note : (string) ($row['admin_note'] ?? ''));
return is_wp_error($finished) ? $finished : $this->get((int) $row['id']);
}
if ($status === 'failed' && !empty($result['success'])) {
$type = DomainModel::resolve_order_product_type((string) ($order['product_type'] ?? ''), (array) ($order['meta_payload'] ?? []));
$revoke = $this->assets_to_revoke($order, $type, (array) ($order['meta_payload'] ?? []));
if (is_wp_error($revoke)) {
$this->update((int) $row['id'], ['admin_note' => __('渠道明确拒绝退款,但无法确认资产补偿内容;申请保持处理中,请人工核对。', 'aksrt') . ' ' . $revoke->get_error_message()]);
return $this->get((int) $row['id']);
}
if ($revoke !== []) {
$compensated = $this->compensate_revoke($row, $order, $revoke, $note);
if (is_wp_error($compensated)) {
$this->update((int) $row['id'], ['admin_note' => __('渠道明确拒绝退款,但资产补偿失败;申请保持处理中,可再次同步重试补偿。', 'aksrt') . ' ' . $compensated->get_error_message()]);
return $this->get((int) $row['id']);
}
}
$this->update((int) $row['id'], ['status' => self::STATUS_REJECTED, 'admin_note' => $note !== '' ? $note : __('支付渠道退款失败,已恢复撤销的资产', 'aksrt')]);
} elseif ($status !== 'succeeded') {
$this->update((int) $row['id'], ['admin_note' => $note !== '' ? $note : __('退款结果仍待支付渠道确认,请稍后同步或人工核对', 'aksrt')]);
}
return $this->get((int) $row['id']);
}
private function compensate_revoke(array $row, array $order, array $entries, string $note): bool|\WP_Error {
$compensate = array_map(static function (array $entry): array { $entry['delta_minor'] = -(int) $entry['delta_minor']; $entry['entry_type'] = 'credit'; $entry['remark'] = __('退款渠道失败,恢复已扣回资产', 'aksrt'); return $entry; }, $entries);
$result = $this->post_asset_entries($row, $order, $compensate, 'after-sale-revoke-compensate', 'after_sale_revoke_compensate', $note);
return is_wp_error($result) ? $result : true;
}
private function assets_to_revoke(array $order, string $type, array $meta): array|\WP_Error {
$user_id = (int) ($order['user_id'] ?? 0);
if ($type === DomainModel::ORDER_PRODUCT_RECHARGE) {
$card_meta = is_array($meta['meta'] ?? null) ? $meta['meta'] : [];
try { $minor = AssetAmount::balance((string) ($meta['credited_amount'] ?? $meta['credit_amount'] ?? $card_meta['amount'] ?? ''))->minor(); } catch (\InvalidArgumentException $e) { return new \WP_Error('aksrt_after_sale_asset', __('充值到账金额缺失,不能退款', 'aksrt'), ['status' => 409]); }
return $this->debit_entry($user_id, AssetType::BALANCE, $minor, __('撤销充值余额', 'aksrt'));
}
if ($type === DomainModel::ORDER_PRODUCT_POINTS) {
$card_meta = is_array($meta['meta'] ?? null) ? $meta['meta'] : [];
$points = max(0, (int) ($meta['points'] ?? $card_meta['points'] ?? 0));
if ($points <= 0) return new \WP_Error('aksrt_after_sale_asset', __('购买积分数量缺失,不能退款', 'aksrt'), ['status' => 409]);
$entries = $this->debit_entry($user_id, AssetType::POINTS, $points, __('撤销订单发放积分', 'aksrt'));
if (sanitize_key((string) ($order['payment_method'] ?? '')) === 'balance') {
try { $paid = AssetAmount::balance((string) ($order['amount'] ?? ''))->minor(); } catch (\InvalidArgumentException $e) { return new \WP_Error('aksrt_after_sale_amount', __('积分订单支付金额无效', 'aksrt'), ['status' => 409]); }
$entries[] = ['user_id' => $user_id, 'asset' => AssetType::BALANCE->value, 'delta_minor' => $paid, 'entry_type' => 'credit', 'remark' => __('退回购买积分使用的余额', 'aksrt')];
}
return $entries;
}
return [];
}
private function debit_entry(int $user_id, AssetType $asset, int $minor, string $remark): array|\WP_Error {
if ($user_id <= 0 || $minor <= 0) return new \WP_Error('aksrt_after_sale_asset', __('待撤销资产金额无效', 'aksrt'), ['status' => 409]);
return [['user_id' => $user_id, 'asset' => $asset->value, 'delta_minor' => -$minor, 'entry_type' => 'debit', 'remark' => $remark]];
}
private function finish_refund(array $row, array $order, string $note, bool $manage_transaction = true) {
global $wpdb;
$number = (string) $order['order_number'];
$commission = CommissionLedger::get_instance()->prepare_full_refund($number);
if (is_wp_error($commission)) return $commission;
if ($manage_transaction && $wpdb->query('START TRANSACTION') === false) return new \WP_Error('aksrt_after_sale_finish', __('无法开始退款状态更新', 'aksrt'), ['status' => 500]);
$guarded = CommissionLedger::get_instance()->guard_full_refund($commission);
if (is_wp_error($guarded) || $guarded !== true) { if ($manage_transaction) $wpdb->query('ROLLBACK'); return is_wp_error($guarded) ? $guarded : new \WP_Error('aksrt_commission_state', __('返佣记录状态已变化', 'aksrt'), ['status' => 409]); }
$finished = $this->finish_order_and_application($row, $order, $note);
if (is_wp_error($finished) || !$finished) { if ($manage_transaction) $wpdb->query('ROLLBACK'); return is_wp_error($finished) ? $finished : new \WP_Error('aksrt_after_sale_finish', __('退款成功但本地订单更新失败,请重试同步', 'aksrt'), ['status' => 500]); }
$applied = CommissionLedger::get_instance()->apply_full_refund($commission);
if (is_wp_error($applied) || ($manage_transaction && $wpdb->query('COMMIT') === false)) { if ($manage_transaction) $wpdb->query('ROLLBACK'); return is_wp_error($applied) ? $applied : new \WP_Error('aksrt_after_sale_finish', __('退款状态保存失败', 'aksrt'), ['status' => 500]); }
if ($manage_transaction) {
OrderRepository::get_instance()->dispatch_managed_status_events();
CommissionLedger::get_instance()->clawback_after_refund($commission);
}
return true;
}
private function finish_order_and_application(array $row, array $order, string $note): bool|\WP_Error {
$number = (string) $order['order_number'];
if (!$this->revoke_vip_if_needed($order)) return new \WP_Error('aksrt_after_sale_vip', __('会员权益撤销失败', 'aksrt'), ['status' => 409]);
if (!OrderRepository::get_instance()->update_status($number, Order::STATUS_REFUNDED, ['allow_refund' => true, 'after_sale_id' => (int) $row['id'], 'transaction_managed' => true, 'note' => $note !== '' ? $note : __('售后退款完成', 'aksrt')])) return new \WP_Error('aksrt_after_sale_order', __('订单退款状态更新失败', 'aksrt'), ['status' => 409]);
return $this->update((int) $row['id'], ['status' => self::STATUS_COMPLETED, 'admin_note' => $note, 'completed_at' => current_time('mysql'), 'reviewed_at' => current_time('mysql')]);
}
private function vip_reversal_is_safe(array $order, array $meta): bool {
$before = is_array($meta['vip_before'] ?? null) ? $meta['vip_before'] : null;
$after = is_array($meta['vip_after'] ?? null) ? $meta['vip_after'] : null;
if (!$before || !$after || !class_exists('\\AKSRT\\Content\\VipService')) return false;
$current = \AKSRT\Content\VipService::get_instance()->get_membership_snapshot((int) $order['user_id']);
foreach (['level', 'expires', 'base_level', 'base_expires', 'expired_level'] as $key) {
if ((int) ($current[$key] ?? 0) !== (int) ($after[$key] ?? 0)) return false;
}
return true;
}
private function revoke_vip_if_needed(array $order): bool {
$type = DomainModel::resolve_order_product_type((string) ($order['product_type'] ?? ''), (array) ($order['meta_payload'] ?? []));
if ($type !== DomainModel::ORDER_PRODUCT_VIP) return true;
$meta = is_array($order['meta_payload'] ?? null) ? $order['meta_payload'] : [];
if (!$this->vip_reversal_is_safe($order, $meta)) return false;
$before = $meta['vip_before'];
$service = \AKSRT\Content\VipService::get_instance();
if ((int) ($before['level'] ?? 0) <= 0) {
$result = $service->mutate_membership((int) $order['user_id'], 'disable', [], ['reason' => 'order_refund', 'source' => 'after_sale']);
} elseif ((int) ($before['base_level'] ?? 0) === 0 && (int) ($before['base_expires'] ?? 0) === 0 && (int) ($before['expired_level'] ?? 0) === 0) {
$result = $service->mutate_membership((int) $order['user_id'], 'set', ['level' => (int) $before['level'], 'expires' => (int) $before['expires']], ['reason' => 'order_refund', 'source' => 'after_sale']);
} else {
return false;
}
return !is_wp_error($result);
}
private function refund_amount(array $order): ?string {
if ((string) ($order['currency'] ?? '') === 'points') return (string) max(0, (int) ($order['amount'] ?? 0));
try { return AssetAmount::balance((string) ($order['amount'] ?? '0'))->decimal(); } catch (\InvalidArgumentException $e) { return null; }
}
private function cash_minor(string $value): int { try { return AssetAmount::balance($value)->minor(); } catch (\InvalidArgumentException $e) { return 0; } }
/** @param array<int,array<string,mixed>> $entries */
private function asset_entries_available(array $entries): bool {
$required = [];
foreach ($entries as $entry) {
$delta = (int) ($entry['delta_minor'] ?? 0);
if ($delta < 0) {
$key = (int) ($entry['user_id'] ?? 0) . ':' . (string) ($entry['asset'] ?? '');
$required[$key] = ($required[$key] ?? 0) + abs($delta);
}
}
foreach ($required as $key => $amount) {
[$user_id, $asset] = explode(':', $key, 2);
$balance = (string) $asset === AssetType::POINTS->value
? \AKSRT\Credits\MetaAssetService::get_instance()->balance_points((int) $user_id)
: \AKSRT\Credits\MetaAssetService::get_instance()->balance_minor((int) $user_id);
if ($balance < (int) $amount) return false;
}
return true;
}
/** @param array<int,array<string,mixed>> $entries */
private function find_active(string $number): ?array {
global $wpdb;
$row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$this->table()} WHERE order_number = %s AND status IN ('pending','processing','approved') ORDER BY id DESC LIMIT 1", $number), ARRAY_A);
return is_array($row) ? $this->normalize($row) : null;
}
private function get(int $id): ?array {
global $wpdb;
$row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$this->table()} WHERE id = %d", $id), ARRAY_A);
return is_array($row) ? $this->normalize($row) : null;
}
private function update(int $id, array $data): bool {
global $wpdb;
$formats = array_map(static fn($value): string => is_int($value) ? '%d' : '%s', array_values($data));
return $wpdb->update($this->table(), $data, ['id' => $id], $formats, ['%d']) !== false;
}
private function table(): string { return \AKSRT\Core\DBMigrator::table('order_aftersale'); }
/** @param array<string,mixed> $row */
private function normalize(array $row): array { $row['id'] = (int) ($row['id'] ?? 0); $row['user_id'] = (int) ($row['user_id'] ?? 0); return $row; }
}