feat(payment): rebuild order details and asset records
This commit is contained in:
1 parent
476e2f961d
commit
7b55cfb190
57 files changed
+2080
-1294
No files matched your search
@@ -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; }
|
||||
}
|
||||
Reference in new issue
Block a user