refactor(after-sale): replace review decisions with direct status panel

售后面板改为状态下拉(待审核/处理中/已完成/已拒绝/已取消)加确认/取消按钮:取消仅收起手风琴,确认为纯状态更新;删除已通过状态、review() 决策机制及其专用退款流水线,无双路无迁移;举证图片并入申请说明列与详情整行。
This commit is contained in:
2026-10-08 11:05:08 +08:00
parent 80373746f8
commit 88669f6989
6 changed files with 101 additions and 496 deletions
+19 -367
View File
@@ -1,20 +1,13 @@
<?php
/** User applications and administrator review for full order refunds. */
/** User applications and administrator status management for order after-sales. */
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';
@@ -255,7 +248,7 @@ final class AfterSaleService {
$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];
$valid = [self::STATUS_PENDING, self::STATUS_PROCESSING, 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] ?? ''));
@@ -291,7 +284,7 @@ final class AfterSaleService {
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));
return (int) $wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM {$this->table()} WHERE user_id = %d AND status IN ('pending','processing')", $user_id));
}
/** A refund status transition is valid only for its own active application. */
@@ -300,371 +293,30 @@ final class AfterSaleService {
$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);
&& in_array((string) ($row['status'] ?? ''), [self::STATUS_PENDING, self::STATUS_PROCESSING], true);
}
public function review(int $id, string $decision, string $note = '') {
/**
* 管理员直接设置售后工单状态:纯状态更新,不触发订单、资产与支付渠道联动。
*/
public function set_status(int $id, string $status, 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;
$status = sanitize_key($status);
if (!in_array($status, [self::STATUS_PENDING, self::STATUS_PROCESSING, self::STATUS_COMPLETED, self::STATUS_REJECTED, self::STATUS_CANCELLED], true)) return new \WP_Error('aksrt_after_sale_status', __('售后状态无效', 'aksrt'), ['status' => 400]);
$row = $this->get($id);
if (!$row) return new \WP_Error('aksrt_after_sale_state', __('售后申请不存在', 'aksrt'), ['status' => 409]);
$note = sanitize_textarea_field($note);
$data = ['status' => $status];
if ($note !== '') $data['admin_note'] = $note;
if ($status !== self::STATUS_PENDING) $data['reviewed_at'] = current_time('mysql');
if (!$this->update($id, $data)) return new \WP_Error('aksrt_after_sale_db', __('售后状态保存失败', 'aksrt'), ['status' => 500]);
return $this->get($id);
}
/** @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);
$row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$this->table()} WHERE order_number = %s AND status IN ('pending','processing') ORDER BY id DESC LIMIT 1", $number), ARRAY_A);
return is_array($row) ? $this->normalize($row) : null;
}