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; }
|
||||
}
|
||||
@@ -10,7 +10,7 @@ defined('ABSPATH') || exit;
|
||||
/**
|
||||
* 站内余额支付网关(内置实现)。
|
||||
*
|
||||
* 仅扣统一资产账本中的现金余额。
|
||||
* 余额由 user meta 管理,LedgerService 在统一入口路由到资产服务。
|
||||
*/
|
||||
|
||||
class BalanceGateway implements GatewayInterface {
|
||||
@@ -54,7 +54,6 @@ class BalanceGateway implements GatewayInterface {
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return ['success' => false, 'message' => __('支付金额无效', 'aksrt')];
|
||||
}
|
||||
$transaction_code = 'BAL_' . $order_number;
|
||||
$guard = static function () use ($order_number, $user_id, $amount_minor) {
|
||||
global $wpdb;
|
||||
$table = \AKSRT\Core\DBMigrator::table('order');
|
||||
@@ -88,10 +87,9 @@ class BalanceGateway implements GatewayInterface {
|
||||
sprintf(__('订单 %s 支付', 'aksrt'), $order_number),
|
||||
$user_id,
|
||||
['order_number' => $order_number],
|
||||
static function () use ($order_number, $transaction_code, $order) {
|
||||
static function () use ($order_number, $order) {
|
||||
$paid = OrderRepository::get_instance()->update_status($order_number, Order::STATUS_PAID, [
|
||||
'payment_method' => 'balance',
|
||||
'payment_transaction_id' => $transaction_code,
|
||||
'note' => (string) ($order['_ledger_paid_note'] ?? __('余额支付成功', 'aksrt')),
|
||||
'require_transition' => true,
|
||||
]);
|
||||
@@ -110,9 +108,7 @@ class BalanceGateway implements GatewayInterface {
|
||||
}
|
||||
return [
|
||||
'success' => true,
|
||||
'transaction_id' => $transaction_code,
|
||||
'ledger_transaction_id' => $result->transaction_id,
|
||||
'flow_number' => $result->flow_number,
|
||||
'transaction_id' => '',
|
||||
'replayed' => $result->replayed,
|
||||
'order_committed' => true,
|
||||
'message' => __('余额支付成功', 'aksrt'),
|
||||
@@ -140,141 +136,8 @@ class BalanceGateway implements GatewayInterface {
|
||||
}
|
||||
|
||||
public function refund(string $order_number, string $amount, string $request_id = ''): array {
|
||||
$order_repo = OrderRepository::get_instance();
|
||||
$order = $order_repo->get($order_number);
|
||||
|
||||
if (!$order) {
|
||||
return ['success' => false, 'message' => __('订单不存在', 'aksrt')];
|
||||
}
|
||||
|
||||
$user_id = (int) $order['user_id'];
|
||||
if (!$this->balance_enabled()) {
|
||||
return ['success' => false, 'message' => __('余额功能未启用', 'aksrt')];
|
||||
}
|
||||
|
||||
$request_id = sanitize_text_field($request_id);
|
||||
try {
|
||||
$amount_minor = \AKSRT\Ledger\AssetAmount::balance($amount)->minor();
|
||||
if ($amount_minor <= 0) {
|
||||
return ['success' => false, 'message' => __('退款金额无效', 'aksrt')];
|
||||
}
|
||||
$operation_key = \AKSRT\Ledger\OperationKey::deterministic('order-refund', [$request_id !== '' ? $request_id : $order_number]);
|
||||
$history = \AKSRT\Ledger\LedgerQuery::get_instance()->history(['operation_key' => $operation_key, 'page' => 1, 'per_page' => 1]);
|
||||
if (!empty($history['items'][0])) {
|
||||
$committed = $history['items'][0];
|
||||
$refund_entry = null;
|
||||
foreach ((array) ($committed['entries'] ?? []) as $entry) {
|
||||
if ((int) ($entry['user_id'] ?? 0) === $user_id
|
||||
&& (string) ($entry['asset'] ?? '') === \AKSRT\Ledger\AssetType::BALANCE->value
|
||||
&& (string) ($entry['entry_type'] ?? '') === 'refund'
|
||||
) {
|
||||
$refund_entry = $entry;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ((string) ($committed['reason'] ?? '') !== 'order_refund'
|
||||
|| (string) ($committed['ref_type'] ?? '') !== 'order'
|
||||
|| (string) ($committed['ref_key'] ?? '') !== $order_number
|
||||
|| !is_array($refund_entry)
|
||||
|| (int) ($refund_entry['delta_minor'] ?? 0) !== $amount_minor
|
||||
) {
|
||||
return ['success' => false, 'message' => __('相同退款标识已用于其他请求', 'aksrt')];
|
||||
}
|
||||
return [
|
||||
'success' => true,
|
||||
'refund_id' => 'BAL_REF_' . $order_number,
|
||||
'transaction_id' => (int) ($committed['id'] ?? 0),
|
||||
'ledger_transaction_id' => (int) ($committed['id'] ?? 0),
|
||||
'replayed' => true,
|
||||
'balance' => \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id),
|
||||
'message' => __('退款成功', 'aksrt'),
|
||||
];
|
||||
}
|
||||
$commission = CommissionLedger::get_instance();
|
||||
$commission_plan = $commission->prepare_full_refund($order_number);
|
||||
if (is_wp_error($commission_plan)) {
|
||||
return ['success' => false, 'message' => $commission_plan->get_error_message()];
|
||||
}
|
||||
$guard = static function () use ($order_number, $user_id, $amount_minor, $commission, $commission_plan) {
|
||||
global $wpdb;
|
||||
$table = \AKSRT\Core\DBMigrator::table('order');
|
||||
$current = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT user_id, amount, payment_method, status FROM {$table} WHERE order_number = %s FOR UPDATE",
|
||||
$order_number
|
||||
), ARRAY_A);
|
||||
try {
|
||||
$order_minor = is_array($current)
|
||||
? \AKSRT\Ledger\AssetAmount::balance((string) ($current['amount'] ?? ''))->minor()
|
||||
: -1;
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
$order_minor = -1;
|
||||
}
|
||||
if (!is_array($current)
|
||||
|| (int) ($current['user_id'] ?? 0) !== $user_id
|
||||
|| sanitize_key((string) ($current['payment_method'] ?? '')) !== 'balance'
|
||||
|| $order_minor !== $amount_minor
|
||||
|| !in_array((string) ($current['status'] ?? ''), [Order::STATUS_PAID, Order::STATUS_REFUNDING], true)
|
||||
) {
|
||||
return new \WP_Error('aksrt_order_refund_conflict', __('退款订单状态或金额已变化', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
return $commission->guard_full_refund($commission_plan);
|
||||
};
|
||||
$mutation = static function () use ($order_number, $commission, $commission_plan) {
|
||||
$repo = OrderRepository::get_instance();
|
||||
$current = $repo->get($order_number);
|
||||
if (!is_array($current)) {
|
||||
return new \WP_Error('aksrt_order_refund_failed', __('退款订单不存在', 'aksrt'), ['status' => 404]);
|
||||
}
|
||||
if ((string) ($current['status'] ?? '') !== Order::STATUS_REFUNDING
|
||||
&& !$repo->update_status($order_number, Order::STATUS_REFUNDING, ['note' => __('余额退款处理中', 'aksrt')])) {
|
||||
return new \WP_Error('aksrt_order_refund_failed', __('退款状态更新失败', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
if (!$repo->update_status($order_number, Order::STATUS_REFUNDED, ['note' => __('余额退款已完成', 'aksrt')])) {
|
||||
return new \WP_Error('aksrt_order_refund_failed', __('退款状态更新失败', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
return $commission->apply_full_refund($commission_plan);
|
||||
};
|
||||
// 只退买家余额。佣金收回不再作为分录混入本事务:即时入账模型下佣金在
|
||||
// 独立 COMMISSION 池,收回可能超出池余额(转为欠款),若作为分录会因池
|
||||
// 不足而中止买家退款。故 apply_full_refund 仅登记佣金明细账的冲正,真正
|
||||
// 的池收回 + 欠款挂账由 clawback_after_refund 在本事务提交后独立执行。
|
||||
$entries = [[
|
||||
'user_id' => $user_id,
|
||||
'asset' => \AKSRT\Ledger\AssetType::BALANCE->value,
|
||||
'delta_minor' => $amount_minor,
|
||||
'entry_type' => 'refund',
|
||||
'remark' => sprintf(__('订单 %s 退款', 'aksrt'), $order_number),
|
||||
]];
|
||||
$result = \AKSRT\Ledger\LedgerService::get_instance()->post(new \AKSRT\Ledger\LedgerCommand(
|
||||
$operation_key,
|
||||
'order_refund',
|
||||
$entries,
|
||||
'order',
|
||||
$order_number,
|
||||
0,
|
||||
['request_id' => $request_id, 'commission_id' => (int) ($commission_plan['id'] ?? 0)]
|
||||
), $guard, $mutation);
|
||||
if (is_wp_error($result)) {
|
||||
return ['success' => false, 'message' => $result->get_error_message() ?: __('退款失败', 'aksrt')];
|
||||
}
|
||||
// 退款事务已提交:从佣金池收回本单佣金,不足部分挂账为欠款(非关键路径)。
|
||||
if (!$result->replayed) {
|
||||
$commission->clawback_after_refund($commission_plan);
|
||||
}
|
||||
return [
|
||||
'success' => true,
|
||||
'refund_id' => 'BAL_REF_' . $order_number,
|
||||
'transaction_id' => $result->transaction_id,
|
||||
'ledger_transaction_id' => $result->transaction_id,
|
||||
'flow_number' => $result->flow_number,
|
||||
'replayed' => $result->replayed,
|
||||
'balance' => \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id),
|
||||
'message' => __('退款成功', 'aksrt'),
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
return ['success' => false, 'message' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
return ['success' => false, 'status' => 'failed', 'message' => __('余额订单售后请通过用户中心申请并由管理员审核', 'aksrt')];
|
||||
}
|
||||
|
||||
private function balance_enabled(): bool {
|
||||
if (!class_exists('\AKSRT\Settings\Settings')) {
|
||||
|
||||
+12
-44
@@ -403,7 +403,6 @@ class ChargeCard {
|
||||
if (is_wp_error($order_number)) {
|
||||
return $order_number;
|
||||
}
|
||||
$transaction_number = $this->card_pass_transaction_number();
|
||||
$guard = function () use ($card_id, $user_id, $submitted_card, $type) {
|
||||
global $wpdb;
|
||||
$current = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$this->table()} WHERE id = %d FOR UPDATE", $card_id), ARRAY_A);
|
||||
@@ -424,9 +423,9 @@ class ChargeCard {
|
||||
}
|
||||
return true;
|
||||
};
|
||||
$mutation = function () use ($user_id, $type, $meta, $card_id, $order_number, $operation_key, $transaction_number) {
|
||||
$mutation = function () use ($user_id, $type, $meta, $card_id, $order_number, $operation_key) {
|
||||
global $wpdb;
|
||||
$created = $this->create_redeem_order($user_id, $type, $meta, $card_id, '', $operation_key, $order_number, Order::STATUS_PAID, $transaction_number);
|
||||
$created = $this->create_redeem_order($user_id, $type, $meta, $card_id, '', $operation_key, $order_number, Order::STATUS_PAID);
|
||||
if ($created !== $order_number) {
|
||||
return new \WP_Error('aksrt_order_create_failed', __('创建订单失败', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
@@ -464,21 +463,12 @@ class ChargeCard {
|
||||
if (is_array($existing_order) && (string) ($existing_order['order_number'] ?? '') !== '') {
|
||||
$order_number = (string) $existing_order['order_number'];
|
||||
}
|
||||
} else {
|
||||
// 账本命令必须保持确定性(payload_hash 含 metadata,塞入随机订单号会破坏幂等重放),
|
||||
// 因此在账本提交之后再把流水行挂到订单上。
|
||||
\AKSRT\Finance\FinancialFlowService::get_instance()->attach_order_to_ledger_flow(
|
||||
(int) $result->transaction_id,
|
||||
$order_number,
|
||||
$transaction_number
|
||||
);
|
||||
}
|
||||
$balance = $result->balance($user_id, $asset);
|
||||
return [
|
||||
'success' => true,
|
||||
'type' => $type,
|
||||
'order_number' => $order_number,
|
||||
'transaction_id' => $result->transaction_id,
|
||||
'replayed' => $result->replayed,
|
||||
'message' => $asset === \AKSRT\Ledger\AssetType::BALANCE
|
||||
? sprintf(__('卡密充值成功,充值金额:%s 元', 'aksrt'), \AKSRT\Ledger\AssetAmount::from_minor($asset, $amount_minor)->decimal())
|
||||
@@ -516,8 +506,6 @@ class ChargeCard {
|
||||
if (is_wp_error($order_number)) {
|
||||
return $order_number;
|
||||
}
|
||||
$transaction_number = $this->card_pass_transaction_number();
|
||||
|
||||
for ($attempt = 1; $attempt <= 3; $attempt++) {
|
||||
if ($wpdb->query('START TRANSACTION') === false) {
|
||||
return new \WP_Error('aksrt_card_db', __('无法开启卡密兑换事务', 'aksrt'), ['status' => 500]);
|
||||
@@ -566,6 +554,8 @@ class ChargeCard {
|
||||
$current_level = max(0, (int) get_user_meta($user_id, 'aksrt_vip_level', true));
|
||||
$current_expiry = max(0, (int) get_user_meta($user_id, 'aksrt_vip_expires', true));
|
||||
$activation_base = max($current_expiry, time());
|
||||
$vip_service = \AKSRT\Content\VipService::get_instance();
|
||||
$vip_before = $vip_service->get_membership_snapshot($user_id);
|
||||
$created = $this->create_redeem_order(
|
||||
$user_id,
|
||||
self::TYPE_VIP,
|
||||
@@ -574,29 +564,13 @@ class ChargeCard {
|
||||
(string) ($current['password'] ?? ''),
|
||||
$operation_key,
|
||||
$order_number,
|
||||
Order::STATUS_PAID,
|
||||
$transaction_number
|
||||
Order::STATUS_PAID
|
||||
);
|
||||
if ($created !== $order_number) {
|
||||
throw new \RuntimeException('vip_card_order_create_failed');
|
||||
}
|
||||
|
||||
// 会员卡没有账本参与(入账的是 usermeta 而非账本资产),流水要单独补一条,
|
||||
// 且它不产生任何现金/资产分录(0 元凭证)。同一事务内写入,失败即整体回滚。
|
||||
$flow_number = \AKSRT\Finance\FinancialFlowService::get_instance()->record_card_pass_payment([
|
||||
'order_number' => $order_number,
|
||||
'user_id' => $user_id,
|
||||
'amount' => '0.00',
|
||||
'currency' => 'CNY',
|
||||
'status' => Order::STATUS_PAID,
|
||||
'payment_method' => 'card_pass',
|
||||
'payment_transaction_id' => $transaction_number,
|
||||
], true);
|
||||
if (is_wp_error($flow_number)) {
|
||||
throw new \RuntimeException('vip_card_flow_failed');
|
||||
}
|
||||
|
||||
\AKSRT\Content\VipService::get_instance()->activate_vip($user_id, $level, $days);
|
||||
$vip_service->activate_vip($user_id, $level, $days);
|
||||
$actual_level = max(0, (int) get_user_meta($user_id, 'aksrt_vip_level', true));
|
||||
$actual_expiry = max(0, (int) get_user_meta($user_id, 'aksrt_vip_expires', true));
|
||||
$permanent_preserved = $days > 0 && $current_level > 0 && $level <= $current_level && $current_expiry === 0;
|
||||
@@ -606,6 +580,10 @@ class ChargeCard {
|
||||
if ($actual_level < max($current_level, $level) || !$expiry_valid) {
|
||||
throw new \RuntimeException('vip_card_fulfillment_failed');
|
||||
}
|
||||
$vip_after = $vip_service->get_membership_snapshot($user_id);
|
||||
if (!OrderRepository::get_instance()->merge_meta($order_number, ['vip_before' => $vip_before, 'vip_after' => $vip_after])) {
|
||||
throw new \RuntimeException('vip_card_snapshot_failed');
|
||||
}
|
||||
|
||||
$used = $wpdb->query($wpdb->prepare(
|
||||
"UPDATE {$this->table()} SET status = %s, used_by = %d, used_at = %s, order_number = %s, balance = 0 WHERE id = %d AND status = %s",
|
||||
@@ -639,7 +617,6 @@ class ChargeCard {
|
||||
'success' => true,
|
||||
'type' => self::TYPE_VIP,
|
||||
'order_number' => $order_number,
|
||||
'transaction_id' => 0,
|
||||
'replayed' => false,
|
||||
'message' => $message,
|
||||
'level' => $level,
|
||||
@@ -699,7 +676,6 @@ class ChargeCard {
|
||||
'success' => true,
|
||||
'type' => self::TYPE_VIP,
|
||||
'order_number' => (string) $order['order_number'],
|
||||
'transaction_id' => 0,
|
||||
'replayed' => true,
|
||||
'message' => $this->vip_card_message($level, $days),
|
||||
'level' => $level,
|
||||
@@ -742,18 +718,10 @@ class ChargeCard {
|
||||
return $order_number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 卡密核销订单的渠道交易号:与资金流水号同源,统一走流水号分配器,
|
||||
* 不再消费任何后台前缀配置。
|
||||
*/
|
||||
private function card_pass_transaction_number(): string {
|
||||
return \AKSRT\Finance\BusinessNumber::allocate();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $meta
|
||||
*/
|
||||
private function create_redeem_order(int $user_id, string $type, array $meta, int $card_id, string $password, string $request_key = '', string $order_number = '', string $status = '', string $transaction_number = ''): string {
|
||||
private function create_redeem_order(int $user_id, string $type, array $meta, int $card_id, string $password, string $request_key = '', string $order_number = '', string $status = ''): string {
|
||||
if (!class_exists('AKSRT\\Payment\\OrderRepository') || !class_exists('\AKSRT\Core\DomainModel')) {
|
||||
return '';
|
||||
}
|
||||
@@ -791,7 +759,7 @@ class ChargeCard {
|
||||
'currency' => $currency,
|
||||
'status' => $order_status,
|
||||
'payment_method' => 'card_pass',
|
||||
'payment_transaction_id' => $order_status === Order::STATUS_PAID ? $transaction_number : '',
|
||||
'payment_transaction_id' => '',
|
||||
'paid_at' => $order_status === Order::STATUS_PAID ? current_time('mysql') : null,
|
||||
'note' => is_string($note) ? $note : '',
|
||||
'request_key' => $request_key,
|
||||
|
||||
@@ -10,6 +10,7 @@ final class CommissionLedger {
|
||||
private function __construct() {}
|
||||
public function init(): void {
|
||||
add_action('rest_api_init', [$this, 'register_routes']);
|
||||
add_action('aksrt_order_commission', [$this, 'record_order_transition']);
|
||||
// 佣金已改为即时入账,历史遗留的结算 cron 需主动清理(无兼容层)。
|
||||
$timestamp = wp_next_scheduled('aksrt_affiliate_settle_due');
|
||||
if ($timestamp) {
|
||||
@@ -45,7 +46,14 @@ final class CommissionLedger {
|
||||
if ($currency === \AKSRT\Ledger\AssetType::POINTS->value) return 0;
|
||||
if (!\AKSRT\Settings\Settings::affiliate_enabled()) return 0;
|
||||
$type = sanitize_key((string) ($order['product_type'] ?? ''));
|
||||
$map = ['vip' => 'membership', 'resource' => 'content'];
|
||||
$map = [
|
||||
'vip' => 'membership',
|
||||
'resource' => 'content',
|
||||
'reading' => 'content',
|
||||
'download' => 'content',
|
||||
'image' => 'content',
|
||||
'video' => 'content',
|
||||
];
|
||||
$order_type = $map[$type] ?? $type;
|
||||
$settings = \AKSRT\Settings\Settings::get_affiliate_settings();
|
||||
$order_no = sanitize_text_field((string) ($order['order_number'] ?? ''));
|
||||
|
||||
@@ -250,7 +250,7 @@ class AlipayGateway implements \AKSRT\Payment\GatewayInterface {
|
||||
|
||||
public function query_refund(string $request_id, string $order_number = ''): array {
|
||||
if (!$this->is_configured() || $request_id === '') {
|
||||
return ['success' => false, 'status' => 'failed', 'message' => __('支付宝退款查询参数无效', 'aksrt')];
|
||||
return ['success' => false, 'status' => 'unknown', 'message' => __('支付宝退款查询不可用,结果待确认', 'aksrt')];
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -225,7 +225,7 @@ class EpayGateway implements \AKSRT\Payment\GatewayInterface {
|
||||
public function refund(string $order_number, string $amount, string $request_id = ''): array {
|
||||
return [
|
||||
'success' => false,
|
||||
'status' => 'failed',
|
||||
'status' => 'manual',
|
||||
'message' => __('易支付退款请在商户后台人工处理', 'aksrt'),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -148,6 +148,7 @@ class PayPalGateway implements \AKSRT\Payment\GatewayInterface {
|
||||
public function refund(string $order_number, string $amount, string $request_id = ''): array {
|
||||
return [
|
||||
'success' => false,
|
||||
'status' => 'manual',
|
||||
'message' => __('PayPal 退款需要通过后台手动处理', 'aksrt'),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -275,7 +275,7 @@ class StripeGateway implements \AKSRT\Payment\GatewayInterface {
|
||||
|
||||
public function query_refund(string $request_id, string $order_number = ''): array {
|
||||
if (!$this->is_configured() || $request_id === '') {
|
||||
return ['success' => false, 'status' => 'failed', 'message' => __('Stripe 退款查询参数无效', 'aksrt')];
|
||||
return ['success' => false, 'status' => 'unknown', 'message' => __('Stripe 退款查询不可用,结果待确认', 'aksrt')];
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -344,7 +344,7 @@ class WechatPayGateway implements \AKSRT\Payment\GatewayInterface {
|
||||
|
||||
public function query_refund(string $request_id, string $order_number = ''): array {
|
||||
if (!$this->is_configured() || $request_id === '') {
|
||||
return ['success' => false, 'status' => 'failed', 'message' => __('微信退款查询参数无效', 'aksrt')];
|
||||
return ['success' => false, 'status' => 'unknown', 'message' => __('微信退款查询不可用,结果待确认', 'aksrt')];
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -11,7 +11,6 @@ class Order {
|
||||
|
||||
public const STATUS_PENDING = \AKSRT\Core\DomainModel::ORDER_STATUS_PENDING;
|
||||
public const STATUS_PAID = \AKSRT\Core\DomainModel::ORDER_STATUS_PAID;
|
||||
public const STATUS_REFUNDING = \AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDING;
|
||||
public const STATUS_REFUNDED = \AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDED;
|
||||
public const STATUS_CLOSED = \AKSRT\Core\DomainModel::ORDER_STATUS_CLOSED;
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ final class OrderCenterQuery {
|
||||
public const STATUS_ALL = 'all';
|
||||
public const STATUS_PENDING = 'pending';
|
||||
public const STATUS_PAID = 'paid';
|
||||
public const STATUS_REFUNDING = 'refunding';
|
||||
public const STATUS_REFUNDED = 'refunded';
|
||||
public const STATUS_CLOSED = 'closed';
|
||||
|
||||
@@ -24,7 +23,6 @@ final class OrderCenterQuery {
|
||||
self::STATUS_ALL,
|
||||
self::STATUS_PENDING,
|
||||
self::STATUS_PAID,
|
||||
self::STATUS_REFUNDING,
|
||||
self::STATUS_REFUNDED,
|
||||
self::STATUS_CLOSED,
|
||||
];
|
||||
@@ -44,6 +42,9 @@ final class OrderCenterQuery {
|
||||
*/
|
||||
public static function from_array(array $raw, int $default_per_page = 20): self {
|
||||
$status = sanitize_key((string) ($raw['status'] ?? self::STATUS_ALL));
|
||||
if ($status === 'refunding') {
|
||||
$status = self::STATUS_PAID;
|
||||
}
|
||||
if (!in_array($status, self::STATUS_GROUPS, true)) {
|
||||
$status = self::STATUS_ALL;
|
||||
}
|
||||
@@ -92,7 +93,10 @@ final class OrderCenterQuery {
|
||||
/** @return string[] */
|
||||
public function database_statuses(): array {
|
||||
if ($this->status() === self::STATUS_ALL) {
|
||||
return array_keys(DomainModel::order_status_labels());
|
||||
return array_merge(array_keys(DomainModel::order_status_labels()), ['refunding']);
|
||||
}
|
||||
if ($this->status() === self::STATUS_PAID) {
|
||||
return [self::STATUS_PAID, 'refunding'];
|
||||
}
|
||||
return [$this->status()];
|
||||
}
|
||||
|
||||
@@ -193,8 +193,31 @@ trait OrderRepositoryAdmin {
|
||||
return ['items' => [], 'total' => 0];
|
||||
}
|
||||
|
||||
$where = ['product_type = %s', 'product_id > 0'];
|
||||
$params = [$product_type];
|
||||
$content_types = [
|
||||
\AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE,
|
||||
\AKSRT\Core\DomainModel::ORDER_PRODUCT_READING,
|
||||
\AKSRT\Core\DomainModel::ORDER_PRODUCT_DOWNLOAD,
|
||||
\AKSRT\Core\DomainModel::ORDER_PRODUCT_IMAGE,
|
||||
\AKSRT\Core\DomainModel::ORDER_PRODUCT_VIDEO,
|
||||
];
|
||||
$legacy_pay_types = [
|
||||
\AKSRT\Core\DomainModel::ORDER_PRODUCT_READING => \AKSRT\Core\DomainModel::PAY_TYPE_READING,
|
||||
\AKSRT\Core\DomainModel::ORDER_PRODUCT_DOWNLOAD => \AKSRT\Core\DomainModel::PAY_TYPE_DOWNLOAD,
|
||||
\AKSRT\Core\DomainModel::ORDER_PRODUCT_IMAGE => \AKSRT\Core\DomainModel::PAY_TYPE_GALLERY,
|
||||
\AKSRT\Core\DomainModel::ORDER_PRODUCT_VIDEO => \AKSRT\Core\DomainModel::PAY_TYPE_VIDEO,
|
||||
];
|
||||
$where = ['product_id > 0'];
|
||||
$params = [];
|
||||
if ($product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE) {
|
||||
$where[] = 'product_type IN (' . implode(', ', array_fill(0, count($content_types), '%s')) . ')';
|
||||
array_push($params, ...$content_types);
|
||||
} elseif (isset($legacy_pay_types[$product_type])) {
|
||||
$where[] = "(product_type = %s OR (product_type = %s AND note LIKE %s))";
|
||||
array_push($params, $product_type, \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE, '%"pay_type":"' . $wpdb->esc_like($legacy_pay_types[$product_type]) . '"%');
|
||||
} else {
|
||||
$where[] = 'product_type = %s';
|
||||
$params[] = $product_type;
|
||||
}
|
||||
|
||||
$status_placeholders = implode(', ', array_fill(0, count($status_in), '%s'));
|
||||
$where[] = "status IN ({$status_placeholders})";
|
||||
@@ -236,14 +259,14 @@ trait OrderRepositoryAdmin {
|
||||
$offset = max(0, (int) ($args['offset'] ?? 0));
|
||||
|
||||
$list_sql = "SELECT product_id,
|
||||
product_type,
|
||||
MIN(product_type) AS product_type,
|
||||
currency,
|
||||
COUNT(*) AS sales_count,
|
||||
AVG(amount) AS avg_amount,
|
||||
SUM(amount) AS revenue
|
||||
FROM {$table}
|
||||
{$where_sql}
|
||||
GROUP BY product_id, product_type, currency
|
||||
GROUP BY product_id, currency
|
||||
ORDER BY {$orderby_col} {$order_dir}
|
||||
LIMIT %d OFFSET %d";
|
||||
$list_params = $params;
|
||||
@@ -252,6 +275,7 @@ trait OrderRepositoryAdmin {
|
||||
|
||||
$rows = $wpdb->get_results($wpdb->prepare($list_sql, $list_params), ARRAY_A);
|
||||
$items = [];
|
||||
$aggregate = [];
|
||||
foreach ((array) $rows as $row) {
|
||||
$sales_count = max(1, (int) ($row['sales_count'] ?? 0));
|
||||
$is_points = (string) ($row['currency'] ?? '') === 'points';
|
||||
@@ -269,12 +293,37 @@ trait OrderRepositoryAdmin {
|
||||
intdiv($revenue_minor + intdiv($sales_count, 2), $sales_count)
|
||||
);
|
||||
}
|
||||
$items[] = [
|
||||
$pid = (int) ($row['product_id'] ?? 0);
|
||||
if (!isset($aggregate[$pid])) {
|
||||
$aggregate[$pid] = [
|
||||
'product_id' => (int) ($row['product_id'] ?? 0),
|
||||
'product_type' => (string) ($row['product_type'] ?? $product_type),
|
||||
'sales_count' => (int) ($row['sales_count'] ?? 0),
|
||||
'avg_amount' => $avg_amount,
|
||||
'revenue' => $revenue,
|
||||
'product_type' => $product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE ? $product_type : (string) ($row['product_type'] ?? $product_type),
|
||||
'sales_count' => 0,
|
||||
'avg_amount' => 0,
|
||||
'revenue_minor' => 0,
|
||||
'currency' => (string) ($row['currency'] ?? 'CNY'),
|
||||
];
|
||||
}
|
||||
$aggregate[$pid]['sales_count'] += (int) ($row['sales_count'] ?? 0);
|
||||
if ((string) ($row['currency'] ?? '') === 'points') {
|
||||
$aggregate[$pid]['revenue_minor'] += (int) $revenue;
|
||||
} else {
|
||||
$aggregate[$pid]['revenue_minor'] += \AKSRT\Ledger\AssetAmount::balance_minor((string) $revenue);
|
||||
}
|
||||
}
|
||||
foreach ($aggregate as $item) {
|
||||
$count = max(1, (int) $item['sales_count']);
|
||||
$is_points = $item['currency'] === 'points';
|
||||
$revenue = $is_points
|
||||
? (int) $item['revenue_minor']
|
||||
: \AKSRT\Ledger\AssetAmount::decimal_from_minor((int) $item['revenue_minor']);
|
||||
$average_minor = intdiv((int) $item['revenue_minor'] + intdiv($count, 2), $count);
|
||||
$items[] = [
|
||||
'product_id' => (int) $item['product_id'],
|
||||
'product_type' => (string) $item['product_type'],
|
||||
'sales_count' => (int) $item['sales_count'],
|
||||
'avg_amount' => $is_points ? $average_minor : \AKSRT\Ledger\AssetAmount::decimal_from_minor($average_minor),
|
||||
'revenue' => $revenue,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -344,17 +393,39 @@ trait OrderRepositoryAdmin {
|
||||
$status = sanitize_key((string) ($args['status'] ?? ''));
|
||||
$valid_statuses = array_keys(\AKSRT\Core\DomainModel::order_status_labels());
|
||||
if ($status !== '' && $status !== 'all' && in_array($status, $valid_statuses, true)) {
|
||||
$where[] = 'status = %s';
|
||||
$params[] = $status;
|
||||
if ($status === 'paid') {
|
||||
$where[] = "status IN (%s, 'refunding')";
|
||||
$params[] = $status;
|
||||
} else {
|
||||
$where[] = 'status = %s';
|
||||
$params[] = $status;
|
||||
}
|
||||
} else {
|
||||
$where[] = 'status IN (' . implode(', ', array_fill(0, count($valid_statuses), '%s')) . ')';
|
||||
array_push($params, ...$valid_statuses);
|
||||
$where[] = "status IN (" . implode(', ', array_fill(0, count($valid_statuses), '%s')) . ", 'refunding')";
|
||||
array_push($params, ...$valid_statuses);
|
||||
}
|
||||
|
||||
$product_type = sanitize_key((string) ($args['product_type'] ?? ''));
|
||||
if ($product_type !== '') {
|
||||
$where[] = 'product_type = %s';
|
||||
$params[] = $product_type;
|
||||
if ($product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE) {
|
||||
$types = \AKSRT\Core\DomainModel::resource_order_product_types();
|
||||
$where[] = 'product_type IN (' . implode(', ', array_fill(0, count($types), '%s')) . ')';
|
||||
array_push($params, ...$types);
|
||||
} else {
|
||||
$legacy_pay_types = [
|
||||
\AKSRT\Core\DomainModel::ORDER_PRODUCT_READING => \AKSRT\Core\DomainModel::PAY_TYPE_READING,
|
||||
\AKSRT\Core\DomainModel::ORDER_PRODUCT_DOWNLOAD => \AKSRT\Core\DomainModel::PAY_TYPE_DOWNLOAD,
|
||||
\AKSRT\Core\DomainModel::ORDER_PRODUCT_IMAGE => \AKSRT\Core\DomainModel::PAY_TYPE_GALLERY,
|
||||
\AKSRT\Core\DomainModel::ORDER_PRODUCT_VIDEO => \AKSRT\Core\DomainModel::PAY_TYPE_VIDEO,
|
||||
];
|
||||
if (isset($legacy_pay_types[$product_type])) {
|
||||
$where[] = "(product_type = %s OR (product_type = %s AND note LIKE %s))";
|
||||
array_push($params, $product_type, \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE, '%"pay_type":"' . $wpdb->esc_like($legacy_pay_types[$product_type]) . '"%');
|
||||
} else {
|
||||
$where[] = 'product_type = %s';
|
||||
$params[] = $product_type;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$product_id = (int) ($args['product_id'] ?? 0);
|
||||
@@ -383,24 +454,35 @@ trait OrderRepositoryAdmin {
|
||||
}
|
||||
|
||||
if ($search !== '') {
|
||||
$flow_table = \AKSRT\Core\DBMigrator::table('financial_flow');
|
||||
$flow_match = "EXISTS (SELECT 1 FROM {$flow_table} ff WHERE ff.order_number = " . $this->table() . '.order_number AND ff.flow_number LIKE %s)';
|
||||
// Numeric search may mean user_id or product_id; also match order or flow number / type.
|
||||
if (ctype_digit($search)) {
|
||||
$where[] = "(order_number LIKE %s OR product_type LIKE %s OR user_id = %d OR product_id = %d OR {$flow_match})";
|
||||
$like = '%' . $wpdb->esc_like($search) . '%';
|
||||
$params[] = $like;
|
||||
$params[] = $like;
|
||||
$params[] = (int) $search;
|
||||
$params[] = (int) $search;
|
||||
$params[] = $like;
|
||||
} else {
|
||||
$where[] = "(order_number LIKE %s OR product_type LIKE %s OR payment_method LIKE %s OR {$flow_match})";
|
||||
$like = '%' . $wpdb->esc_like($search) . '%';
|
||||
$params[] = $like;
|
||||
$params[] = $like;
|
||||
$params[] = $like;
|
||||
$params[] = $like;
|
||||
$user_table = $wpdb->users;
|
||||
$user_match = "EXISTS (SELECT 1 FROM {$user_table} u WHERE u.ID = " . $this->table() . '.user_id AND (u.user_login LIKE %s OR u.user_email LIKE %s OR u.display_name LIKE %s))';
|
||||
$post_table = $wpdb->posts;
|
||||
$product_match = "EXISTS (SELECT 1 FROM {$post_table} p WHERE p.ID = " . $this->table() . '.product_id AND p.post_title LIKE %s)';
|
||||
$meta_match = "note LIKE %s";
|
||||
// Search matches the public order number, account, product title, and stored product label.
|
||||
if (ctype_digit($search)) {
|
||||
$where[] = "(order_number LIKE %s OR product_type LIKE %s OR user_id = %d OR product_id = %d OR {$user_match} OR {$product_match} OR {$meta_match})";
|
||||
$like = '%' . $wpdb->esc_like($search) . '%';
|
||||
$params[] = $like;
|
||||
$params[] = $like;
|
||||
$params[] = (int) $search;
|
||||
$params[] = (int) $search;
|
||||
array_push($params, $like, $like, $like, $like, $like);
|
||||
} else {
|
||||
$where[] = "(order_number LIKE %s OR product_type LIKE %s OR payment_method LIKE %s OR {$user_match} OR {$product_match} OR {$meta_match})";
|
||||
$like = '%' . $wpdb->esc_like($search) . '%';
|
||||
$params[] = $like;
|
||||
$params[] = $like;
|
||||
$params[] = $like;
|
||||
array_push($params, $like, $like, $like, $like, $like);
|
||||
}
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -243,7 +243,7 @@ trait OrderRepositoryCore {
|
||||
* their existing user-wide semantics, so changing a filter does not change the
|
||||
* summary cards. The subquery avoids four independent COUNT statements.
|
||||
*
|
||||
* @return array{total:int,stats:array{total:int,pending:int,paid:int}}
|
||||
* @return array{total:int,stats:array<string,int>}
|
||||
*/
|
||||
public function get_user_order_center_summary(int $user_id, array $args = []): array {
|
||||
global $wpdb;
|
||||
@@ -251,7 +251,7 @@ trait OrderRepositoryCore {
|
||||
if ($user_id <= 0) {
|
||||
return [
|
||||
'total' => 0,
|
||||
'stats' => ['total' => 0, 'pending' => 0, 'paid' => 0],
|
||||
'stats' => ['total' => 0, 'pending' => 0, 'paid' => 0, 'refunded' => 0, 'closed' => 0, 'after_sale_pending' => 0],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -264,12 +264,15 @@ trait OrderRepositoryCore {
|
||||
(SELECT COUNT(*) FROM {$this->table()} {$parts['where']}) AS filtered_total,
|
||||
COUNT(*) AS known_total,
|
||||
SUM(CASE WHEN status = %s THEN 1 ELSE 0 END) AS pending,
|
||||
SUM(CASE WHEN status = %s THEN 1 ELSE 0 END) AS paid
|
||||
SUM(CASE WHEN status = %s THEN 1 ELSE 0 END) AS paid,
|
||||
SUM(CASE WHEN status = 'refunding' THEN 1 ELSE 0 END) AS paid_legacy,
|
||||
SUM(CASE WHEN status = %s THEN 1 ELSE 0 END) AS refunded,
|
||||
SUM(CASE WHEN status = %s THEN 1 ELSE 0 END) AS closed
|
||||
FROM {$this->table()}
|
||||
WHERE user_id = %d AND status IN ({$known_placeholders})";
|
||||
$params = array_merge(
|
||||
$parts['params'],
|
||||
[Order::STATUS_PENDING, Order::STATUS_PAID],
|
||||
[Order::STATUS_PENDING, Order::STATUS_PAID, Order::STATUS_REFUNDED, Order::STATUS_CLOSED],
|
||||
[$user_id],
|
||||
$known_statuses
|
||||
);
|
||||
@@ -280,7 +283,10 @@ trait OrderRepositoryCore {
|
||||
'stats' => [
|
||||
'total' => (int) ($row['known_total'] ?? 0),
|
||||
'pending' => (int) ($row['pending'] ?? 0),
|
||||
'paid' => (int) ($row['paid'] ?? 0),
|
||||
'paid' => (int) ($row['paid'] ?? 0) + (int) ($row['paid_legacy'] ?? 0),
|
||||
'refunded' => (int) ($row['refunded'] ?? 0),
|
||||
'closed' => (int) ($row['closed'] ?? 0),
|
||||
'after_sale_pending' => class_exists(AfterSaleService::class) ? AfterSaleService::get_instance()->count_user_open($user_id) : 0,
|
||||
],
|
||||
];
|
||||
}
|
||||
@@ -340,8 +346,16 @@ trait OrderRepositoryCore {
|
||||
|
||||
|
||||
|
||||
public function update_status(string $order_number, string $new_status, array $extra = []): bool {
|
||||
public function update_status(string $order_number, string $new_status, array $extra = []): bool {
|
||||
global $wpdb;
|
||||
if ($new_status === \AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDED && empty($extra['allow_refund'])) {
|
||||
return false;
|
||||
}
|
||||
if ($new_status === \AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDED
|
||||
&& (!class_exists(AfterSaleService::class) || !AfterSaleService::get_instance()->can_finalize_order_refund((int) ($extra['after_sale_id'] ?? 0), $order_number))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$preflight = $this->get($order_number);
|
||||
if (!$preflight) {
|
||||
@@ -349,7 +363,7 @@ trait OrderRepositoryCore {
|
||||
}
|
||||
if (
|
||||
$new_status === \AKSRT\Core\DomainModel::ORDER_STATUS_PAID
|
||||
&& (string) ($preflight['status'] ?? '') !== \AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDING
|
||||
&& (string) ($preflight['status'] ?? '') !== 'refunding'
|
||||
&& sanitize_key((string) ($preflight['product_type'] ?? '')) === \AKSRT\Core\DomainModel::ORDER_PRODUCT_RECHARGE
|
||||
&& class_exists('\AKSRT\Credits\BalanceService')
|
||||
) {
|
||||
@@ -367,7 +381,8 @@ trait OrderRepositoryCore {
|
||||
if ($root_call) {
|
||||
$this->status_after_commit_events = [];
|
||||
}
|
||||
$owns_transaction = $root_call && !$ledger_active;
|
||||
$transaction_managed = !empty($extra['transaction_managed']);
|
||||
$owns_transaction = $root_call && !$ledger_active && !$transaction_managed;
|
||||
if ($owns_transaction && $wpdb->query('START TRANSACTION') === false) {
|
||||
return false;
|
||||
}
|
||||
@@ -398,6 +413,8 @@ trait OrderRepositoryCore {
|
||||
\AKSRT\Ledger\LedgerService::get_instance()->after_commit(function () use ($events): void {
|
||||
$this->dispatch_status_events($events);
|
||||
});
|
||||
} elseif ($transaction_managed) {
|
||||
$this->status_after_commit_events = $events;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
@@ -425,14 +442,18 @@ trait OrderRepositoryCore {
|
||||
$valid = \AKSRT\Core\DomainModel::order_status_labels();
|
||||
if (!isset($valid[$new_status])) return false;
|
||||
|
||||
$order_row = $wpdb->get_row($wpdb->prepare(
|
||||
$order_row = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT * FROM {$this->table()} WHERE order_number = %s FOR UPDATE",
|
||||
$order_number
|
||||
), ARRAY_A);
|
||||
$database_status = is_array($order_row) ? sanitize_key((string) ($order_row['status'] ?? '')) : '';
|
||||
$order = is_array($order_row) ? $this->normalize_row($order_row) : null;
|
||||
if (!$order) return false;
|
||||
|
||||
$current_status = (string) ($order['status'] ?? '');
|
||||
$current_status = (string) ($order['status'] ?? '');
|
||||
if ($database_status === 'refunding' && $new_status === \AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDED) {
|
||||
$current_status = \AKSRT\Core\DomainModel::ORDER_STATUS_PAID;
|
||||
}
|
||||
|
||||
if (!\AKSRT\Core\DomainModel::can_transition_order_status($current_status, $new_status)) {
|
||||
return false;
|
||||
@@ -457,12 +478,7 @@ trait OrderRepositoryCore {
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
in_array($new_status, [
|
||||
\AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDING,
|
||||
\AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDED,
|
||||
], true)
|
||||
) {
|
||||
if ($new_status === \AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDED) {
|
||||
$existing_paid = is_string($order['paid_at'] ?? null) ? trim((string) $order['paid_at']) : '';
|
||||
if ($existing_paid === '' || str_starts_with($existing_paid, '0000-00-00')) {
|
||||
if (!isset($update_data['paid_at'])) {
|
||||
@@ -502,7 +518,7 @@ trait OrderRepositoryCore {
|
||||
$result = $wpdb->update(
|
||||
$this->table(),
|
||||
$update_data,
|
||||
['order_number' => $order_number, 'status' => $current_status],
|
||||
['order_number' => $order_number, 'status' => $database_status],
|
||||
$format,
|
||||
['%s', '%s']
|
||||
);
|
||||
@@ -550,26 +566,13 @@ trait OrderRepositoryCore {
|
||||
}
|
||||
}
|
||||
|
||||
if (!$ledger_active) {
|
||||
$flow_number = \AKSRT\Finance\FinancialFlowService::get_instance()->record_order_payment($paid_order, true);
|
||||
if (is_wp_error($flow_number)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (class_exists('\AKSRT\Payment\CommissionLedger')) {
|
||||
$commission = \AKSRT\Payment\CommissionLedger::get_instance()->record_order_transition(
|
||||
$order_number,
|
||||
$new_status,
|
||||
$paid_order
|
||||
);
|
||||
if (is_wp_error($commission)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
$this->status_after_commit_events[] = [
|
||||
'hook' => 'aksrt_order_commission',
|
||||
'args' => [$order_number, $new_status, $paid_order],
|
||||
];
|
||||
|
||||
$product_type = sanitize_key((string) ($paid_order['product_type'] ?? ''));
|
||||
if ($product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE && class_exists('\AKSRT\Content\Pay')) {
|
||||
if (\AKSRT\Core\DomainModel::is_resource_product_type($product_type) && class_exists('\AKSRT\Content\Pay')) {
|
||||
$fulfilled = \AKSRT\Content\Pay::get_instance()->fulfill_paid_order($order_number, $paid_order);
|
||||
if (is_wp_error($fulfilled)) {
|
||||
return false;
|
||||
@@ -594,16 +597,6 @@ trait OrderRepositoryCore {
|
||||
];
|
||||
}
|
||||
|
||||
if ($new_status === \AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDED
|
||||
&& $current_status === \AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDING
|
||||
&& !$ledger_active
|
||||
) {
|
||||
$refunded_order = array_merge($order, $update_data, ['status' => $new_status]);
|
||||
$flow_number = \AKSRT\Finance\FinancialFlowService::get_instance()->record_order_refund($refunded_order, true);
|
||||
if (is_wp_error($flow_number)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $result !== false;
|
||||
@@ -681,11 +674,10 @@ trait OrderRepositoryCore {
|
||||
$sql = "SELECT
|
||||
COUNT(*) as total,
|
||||
SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending,
|
||||
SUM(CASE WHEN status = 'paid' THEN 1 ELSE 0 END) as paid,
|
||||
SUM(CASE WHEN status = 'refunding' THEN 1 ELSE 0 END) as refunding,
|
||||
SUM(CASE WHEN status IN ('paid','refunding') THEN 1 ELSE 0 END) as paid,
|
||||
SUM(CASE WHEN status = 'refunded' THEN 1 ELSE 0 END) as refunded,
|
||||
SUM(CASE WHEN status = 'closed' THEN 1 ELSE 0 END) as closed,
|
||||
SUM(CASE WHEN status = 'paid' THEN amount ELSE 0 END) as revenue
|
||||
SUM(CASE WHEN status IN ('paid','refunding') THEN amount ELSE 0 END) as revenue
|
||||
FROM {$this->table()} {$where}";
|
||||
|
||||
if (!empty($params)) {
|
||||
@@ -698,13 +690,66 @@ trait OrderRepositoryCore {
|
||||
'total' => (int) ($row['total'] ?? 0),
|
||||
'pending' => (int) ($row['pending'] ?? 0),
|
||||
'paid' => (int) ($row['paid'] ?? 0),
|
||||
'refunding' => (int) ($row['refunding'] ?? 0),
|
||||
'refunded' => (int) ($row['refunded'] ?? 0),
|
||||
'closed' => (int) ($row['closed'] ?? 0),
|
||||
'revenue' => $this->cash_decimal((string) ($row['revenue'] ?? '0')),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function get_admin_order_stats(array $args = []): array {
|
||||
global $wpdb;
|
||||
$this->expire_pending_orders();
|
||||
$where = [];
|
||||
$params = [];
|
||||
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';
|
||||
}
|
||||
}
|
||||
$where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
|
||||
$sql = "SELECT COUNT(*) AS total,
|
||||
SUM(status = 'pending') AS pending,
|
||||
SUM(status IN ('paid','refunding')) AS paid,
|
||||
SUM(status = 'refunded') AS refunded,
|
||||
SUM(status = 'closed') AS closed,
|
||||
SUM(CASE WHEN status IN ('paid','refunding') AND currency <> 'points' THEN amount ELSE 0 END) AS revenue
|
||||
FROM {$this->table()} {$where_sql}";
|
||||
$row = $params ? $wpdb->get_row($wpdb->prepare($sql, $params), ARRAY_A) : $wpdb->get_row($sql, ARRAY_A);
|
||||
$type_sql = "SELECT product_type, note, COUNT(*) AS total FROM {$this->table()} {$where_sql} GROUP BY product_type, note";
|
||||
$type_rows = $params ? $wpdb->get_results($wpdb->prepare($type_sql, $params), ARRAY_A) : $wpdb->get_results($type_sql, ARRAY_A);
|
||||
$by_type = array_fill_keys(array_keys(\AKSRT\Core\DomainModel::order_product_type_labels()), 0);
|
||||
foreach ((array) $type_rows as $type_row) {
|
||||
$meta = json_decode((string) ($type_row['note'] ?? ''), true);
|
||||
$type = \AKSRT\Core\DomainModel::resolve_order_product_type((string) ($type_row['product_type'] ?? ''), is_array($meta) ? $meta : []);
|
||||
if (isset($by_type[$type])) {
|
||||
$by_type[$type] += (int) ($type_row['total'] ?? 0);
|
||||
}
|
||||
}
|
||||
return [
|
||||
'total' => (int) ($row['total'] ?? 0),
|
||||
'pending' => (int) ($row['pending'] ?? 0),
|
||||
'paid' => (int) ($row['paid'] ?? 0),
|
||||
'refunded' => (int) ($row['refunded'] ?? 0),
|
||||
'closed' => (int) ($row['closed'] ?? 0),
|
||||
'revenue' => $this->cash_decimal((string) ($row['revenue'] ?? '0')),
|
||||
'by_type' => $by_type,
|
||||
'after_sale_pending' => class_exists(AfterSaleService::class) ? AfterSaleService::get_instance()->count([
|
||||
'status' => AfterSaleService::STATUS_PENDING,
|
||||
'date_from' => $args['date_from'] ?? '',
|
||||
'date_to' => $args['date_to'] ?? '',
|
||||
]) : 0,
|
||||
];
|
||||
}
|
||||
|
||||
public function dispatch_managed_status_events(): void {
|
||||
$events = $this->status_after_commit_events;
|
||||
$this->status_after_commit_events = [];
|
||||
$this->dispatch_status_events($events);
|
||||
}
|
||||
|
||||
|
||||
|
||||
public function has_paid_access(int $user_id, string $product_type, int $product_id): bool {
|
||||
@@ -712,7 +757,10 @@ trait OrderRepositoryCore {
|
||||
return false;
|
||||
}
|
||||
|
||||
return null !== $this->find_latest_success_order($user_id, $product_type, $product_id);
|
||||
if (\AKSRT\Core\DomainModel::is_resource_product_type($product_type)) {
|
||||
return null !== $this->find_latest_success_content_order($user_id, $product_id);
|
||||
}
|
||||
return null !== $this->find_latest_success_order($user_id, $product_type, $product_id);
|
||||
}
|
||||
|
||||
|
||||
@@ -756,8 +804,25 @@ trait OrderRepositoryCore {
|
||||
|
||||
$product_type = sanitize_key((string) ($args['product_type'] ?? ''));
|
||||
if ($product_type !== '') {
|
||||
$where[] = 'product_type = %s';
|
||||
$params[] = $product_type;
|
||||
if ($product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE) {
|
||||
$types = \AKSRT\Core\DomainModel::resource_order_product_types();
|
||||
$where[] = 'product_type IN (' . implode(', ', array_fill(0, count($types), '%s')) . ')';
|
||||
array_push($params, ...$types);
|
||||
} else {
|
||||
$legacy_pay_types = [
|
||||
\AKSRT\Core\DomainModel::ORDER_PRODUCT_READING => \AKSRT\Core\DomainModel::PAY_TYPE_READING,
|
||||
\AKSRT\Core\DomainModel::ORDER_PRODUCT_DOWNLOAD => \AKSRT\Core\DomainModel::PAY_TYPE_DOWNLOAD,
|
||||
\AKSRT\Core\DomainModel::ORDER_PRODUCT_IMAGE => \AKSRT\Core\DomainModel::PAY_TYPE_GALLERY,
|
||||
\AKSRT\Core\DomainModel::ORDER_PRODUCT_VIDEO => \AKSRT\Core\DomainModel::PAY_TYPE_VIDEO,
|
||||
];
|
||||
if (isset($legacy_pay_types[$product_type])) {
|
||||
$where[] = "(product_type = %s OR (product_type = %s AND note LIKE %s))";
|
||||
array_push($params, $product_type, \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE, '%"pay_type":"' . $wpdb->esc_like($legacy_pay_types[$product_type]) . '"%');
|
||||
} else {
|
||||
$where[] = 'product_type = %s';
|
||||
$params[] = $product_type;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$order_number_prefix = strtoupper(preg_replace('/[^A-Za-z0-9_-]/', '', (string) ($args['order_number_prefix'] ?? '')) ?? '');
|
||||
@@ -780,7 +845,10 @@ trait OrderRepositoryCore {
|
||||
if ($row['amount'] === null) {
|
||||
$row['amount'] = (string) ($row['currency'] ?? '') === 'points' ? 0 : '0.00';
|
||||
}
|
||||
$row['status'] = sanitize_key((string) ($row['status'] ?? ''));
|
||||
$row['status'] = sanitize_key((string) ($row['status'] ?? ''));
|
||||
if ($row['status'] === 'refunding') {
|
||||
$row['status'] = Order::STATUS_PAID;
|
||||
}
|
||||
|
||||
$row['meta_payload'] = [];
|
||||
if (!empty($row['note']) && is_string($row['note'])) {
|
||||
@@ -794,6 +862,10 @@ trait OrderRepositoryCore {
|
||||
$row['note_text'] = $row['note'];
|
||||
}
|
||||
}
|
||||
$row['product_type'] = \AKSRT\Core\DomainModel::resolve_order_product_type(
|
||||
sanitize_key((string) ($row['product_type'] ?? '')),
|
||||
$row['meta_payload']
|
||||
);
|
||||
|
||||
return $row;
|
||||
}
|
||||
@@ -860,6 +932,20 @@ trait OrderRepositoryCore {
|
||||
return is_array($row) ? $this->normalize_row($row) : null;
|
||||
}
|
||||
|
||||
private function find_latest_success_content_order(int $user_id, int $product_id): ?array {
|
||||
global $wpdb;
|
||||
$types = \AKSRT\Core\DomainModel::resource_order_product_types();
|
||||
$placeholders = implode(', ', array_fill(0, count($types), '%s'));
|
||||
$statuses = \AKSRT\Core\DomainModel::paid_order_statuses();
|
||||
$status_placeholders = implode(', ', array_fill(0, count($statuses), '%s'));
|
||||
$params = array_merge([$user_id, $product_id], $types, $statuses);
|
||||
$row = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT * FROM {$this->table()} WHERE user_id = %d AND product_id = %d AND product_type IN ({$placeholders}) AND status IN ({$status_placeholders}) ORDER BY COALESCE(paid_at, created_at) DESC, id DESC LIMIT 1",
|
||||
$params
|
||||
), ARRAY_A);
|
||||
return is_array($row) ? $this->normalize_row($row) : null;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
|
||||
@@ -130,8 +130,6 @@ class PaymentOrchestrator {
|
||||
'success' => true,
|
||||
'redirect' => false,
|
||||
'transaction_id' => $transaction_id,
|
||||
'ledger_transaction_id' => (int) ($result['ledger_transaction_id'] ?? 0),
|
||||
'flow_number' => (string) ($result['flow_number'] ?? ''),
|
||||
'replayed' => !empty($result['replayed']),
|
||||
'message' => (string) ($result['message'] ?? __('支付成功。', 'aksrt')),
|
||||
];
|
||||
|
||||
@@ -39,7 +39,7 @@ class Revenue {
|
||||
return 0;
|
||||
}
|
||||
$product_type = sanitize_key((string) ($order['product_type'] ?? ''));
|
||||
if ($product_type !== \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE) {
|
||||
if (!\AKSRT\Core\DomainModel::is_resource_product_type($product_type)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -171,7 +171,7 @@ class UserOrders {
|
||||
*/
|
||||
public function decorate_order_view(array $order, bool $detail = true, ?array $reconciliation = null): array {
|
||||
$meta = is_array($order['meta_payload'] ?? null) ? $order['meta_payload'] : [];
|
||||
$product_type = sanitize_key((string) ($order['product_type'] ?? ''));
|
||||
$product_type = DomainModel::resolve_order_product_type(sanitize_key((string) ($order['product_type'] ?? '')), $meta);
|
||||
$product_id = (int) ($order['product_id'] ?? 0);
|
||||
$status = sanitize_key((string) ($order['status'] ?? Order::STATUS_PENDING));
|
||||
$currency = sanitize_key((string) ($order['currency'] ?? 'CNY'));
|
||||
@@ -195,6 +195,8 @@ class UserOrders {
|
||||
$can_pay = $group === OrderCenterQuery::STATUS_PENDING;
|
||||
$can_cancel = $group === OrderCenterQuery::STATUS_PENDING;
|
||||
$order_number = (string) ($order['order_number'] ?? '');
|
||||
$after_sales = $detail && class_exists(AfterSaleService::class) ? AfterSaleService::get_instance()->list_for_order($order_number) : [];
|
||||
$has_open_after_sale = (bool) array_filter($after_sales, static fn(array $item): bool => in_array((string) ($item['status'] ?? ''), ['pending', 'processing', 'approved'], true));
|
||||
$updated_at = $this->resolve_updated_at($order);
|
||||
|
||||
$view = [
|
||||
@@ -222,6 +224,7 @@ class UserOrders {
|
||||
'allowed_actions' => [
|
||||
'can_pay' => $can_pay,
|
||||
'can_cancel' => $can_cancel,
|
||||
'can_after_sale' => $group === OrderCenterQuery::STATUS_PAID && !$has_open_after_sale,
|
||||
],
|
||||
'time_left' => $this->get_order_countdown((string) ($order['expires_at'] ?? ''), $status),
|
||||
'updated_at' => $updated_at,
|
||||
@@ -237,6 +240,7 @@ class UserOrders {
|
||||
$view['paid_at'] = (string) ($order['paid_at'] ?? '');
|
||||
$view['expires_at'] = (string) ($order['expires_at'] ?? '');
|
||||
$view['reconciliation'] = $reconciliation ?: ['state' => 'not_needed', 'required' => false];
|
||||
$view['after_sales'] = $after_sales;
|
||||
}
|
||||
return $view;
|
||||
}
|
||||
@@ -249,7 +253,7 @@ class UserOrders {
|
||||
'total_pages' => 1,
|
||||
'page' => 1,
|
||||
'query' => $query->url_values(),
|
||||
'stats' => ['total' => 0, 'pending' => 0, 'paid' => 0],
|
||||
'stats' => ['total' => 0, 'pending' => 0, 'paid' => 0, 'refunded' => 0, 'closed' => 0, 'after_sale_pending' => 0],
|
||||
'invalidation_tags' => ['orders'],
|
||||
'product_types' => $this->product_type_filter_options(),
|
||||
];
|
||||
@@ -431,21 +435,15 @@ class UserOrders {
|
||||
return ['success' => false, 'message' => __('当前订单状态不可支付。', 'aksrt')];
|
||||
}
|
||||
if ($this->status_group((string) ($order['status'] ?? '')) === OrderCenterQuery::STATUS_PAID) {
|
||||
$ledger_key = \AKSRT\Ledger\OperationKey::deterministic('order-payment', [$order_number]);
|
||||
$history = \AKSRT\Ledger\LedgerQuery::get_instance()->history(['operation_key' => $ledger_key, 'page' => 1, 'per_page' => 1]);
|
||||
if (!empty($history['items'][0])) {
|
||||
return ['success' => true, 'data' => [
|
||||
return ['success' => true, 'data' => [
|
||||
'redirect' => false,
|
||||
'payment_url' => '',
|
||||
'order_number' => $order_number,
|
||||
'order' => $this->get_order_detail_view($user_id, $order_number),
|
||||
'transaction_id' => (int) $history['items'][0]['id'],
|
||||
'flow_number' => \AKSRT\Finance\FinancialFlowQuery::get_instance()->order_payment_number($order_number),
|
||||
'replayed' => true,
|
||||
'balance' => \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id),
|
||||
'message' => __('支付成功。', 'aksrt'),
|
||||
]];
|
||||
}
|
||||
}
|
||||
if ($this->status_group((string) ($order['status'] ?? '')) !== OrderCenterQuery::STATUS_PENDING) {
|
||||
return ['success' => false, 'message' => __('当前订单状态不可支付。', 'aksrt')];
|
||||
@@ -476,8 +474,6 @@ class UserOrders {
|
||||
'payment_url' => (string) ($result['payment_url'] ?? ''),
|
||||
'order_number' => $order_number,
|
||||
'order' => empty($result['redirect']) ? $this->get_order_detail_view($user_id, $order_number) : null,
|
||||
'transaction_id' => (int) ($result['ledger_transaction_id'] ?? 0),
|
||||
'flow_number' => (string) ($result['flow_number'] ?? ''),
|
||||
'replayed' => !empty($result['replayed']),
|
||||
'balance' => $payment_method === 'balance' ? \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id) : null,
|
||||
'message' => (string) ($result['message'] ?? ''),
|
||||
|
||||
@@ -133,6 +133,46 @@ class Withdrawal {
|
||||
if ($net_minor < 1) {
|
||||
return new \WP_Error('aksrt_withdraw_net', __('扣除手续费后到账金额必须至少为 0.01 元', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
$request_hash = hash('sha256', (string) wp_json_encode([
|
||||
'user_id' => $user_id,
|
||||
'gross_minor' => $gross_minor,
|
||||
'net_minor' => $net_minor,
|
||||
'fee_rate' => $fee_rate,
|
||||
'method' => sanitize_key($method),
|
||||
'account' => sanitize_text_field($account),
|
||||
'source' => $source,
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
$existing_request = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT * FROM {$this->table()} WHERE operation_key = %s LIMIT 1",
|
||||
$operation_key
|
||||
), ARRAY_A);
|
||||
if (is_array($existing_request)) {
|
||||
$existing_meta = $this->parse_account_meta((string) ($existing_request['account_info'] ?? ''));
|
||||
if ((int) ($existing_request['user_id'] ?? 0) !== $user_id
|
||||
|| !hash_equals((string) ($existing_meta['request_hash'] ?? ''), $request_hash)) {
|
||||
return new \WP_Error('aksrt_withdraw_idempotency_conflict', __('相同提现操作标识已用于其他请求', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
$window = $this->daily_window((string) ($settings['reset_time'] ?? '00:00'));
|
||||
$usage = $this->daily_usage_minor_in_window($user_id, $settings, $window, false, $source);
|
||||
return [
|
||||
'withdrawal_id' => (int) $existing_request['id'],
|
||||
'withdrawal_number' => (string) ($existing_request['withdrawal_number'] ?? ''),
|
||||
'net_amount' => $this->database_balance_decimal((string) ($existing_request['amount'] ?? '0.00')),
|
||||
'source' => $source,
|
||||
'wallet_balance' => $this->source_balance_decimal($user_id, $source),
|
||||
'transaction_id' => max(0, (int) ($existing_request['ledger_transaction_id'] ?? 0)),
|
||||
'flow_number' => $source === self::SOURCE_COMMISSION
|
||||
? \AKSRT\Finance\FinancialFlowService::get_instance()->number_for_operation($operation_key)
|
||||
: '',
|
||||
'replayed' => true,
|
||||
'usage' => [
|
||||
'count' => $usage['count'],
|
||||
'total' => $this->balance_decimal($usage['total_minor']),
|
||||
'remaining_count' => $usage['remaining_count'],
|
||||
'remaining_amount' => $this->balance_decimal($usage['remaining_minor']),
|
||||
],
|
||||
];
|
||||
}
|
||||
$window = $this->daily_window((string) ($settings['reset_time'] ?? '00:00'));
|
||||
$usage = null;
|
||||
$withdrawal_id = 0;
|
||||
@@ -163,7 +203,7 @@ class Withdrawal {
|
||||
}
|
||||
return true;
|
||||
};
|
||||
$mutation = function (int $transaction_id) use ($user_id, $gross_minor, $net_minor, $fee_rate, $method, $account, $operation_key, $source, &$withdrawal_id, &$withdrawal_number) {
|
||||
$mutation = function (int $transaction_id) use ($user_id, $gross_minor, $net_minor, $fee_rate, $method, $account, $operation_key, $request_hash, $source, &$withdrawal_id, &$withdrawal_number) {
|
||||
global $wpdb;
|
||||
$payload = wp_json_encode([
|
||||
'account' => $account,
|
||||
@@ -171,11 +211,12 @@ class Withdrawal {
|
||||
'fee_rate' => $fee_rate,
|
||||
'source' => $source,
|
||||
'operation_key' => $operation_key,
|
||||
'request_hash' => $request_hash,
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: $account;
|
||||
$ok = false;
|
||||
for ($attempt = 1; $attempt <= 5; $attempt++) {
|
||||
$withdrawal_number = \AKSRT\Finance\BusinessNumber::allocate();
|
||||
$ok = $wpdb->insert($this->table(), [
|
||||
$insert_data = [
|
||||
'withdrawal_number' => $withdrawal_number,
|
||||
'user_id' => $user_id,
|
||||
'amount' => \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, $net_minor)->decimal(),
|
||||
@@ -183,9 +224,15 @@ class Withdrawal {
|
||||
'method' => sanitize_key($method),
|
||||
'account_info' => $payload,
|
||||
'status' => self::STATUS_PENDING,
|
||||
'ledger_transaction_id' => $transaction_id,
|
||||
'operation_key' => $operation_key,
|
||||
'created_at' => current_time('mysql'),
|
||||
], ['%s', '%d', '%s', '%s', '%s', '%s', '%s', '%d', '%s']);
|
||||
];
|
||||
$insert_formats = ['%s', '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s'];
|
||||
if ($transaction_id > 0) {
|
||||
$insert_data['ledger_transaction_id'] = $transaction_id;
|
||||
$insert_formats[] = '%d';
|
||||
}
|
||||
$ok = $wpdb->insert($this->table(), $insert_data, $insert_formats);
|
||||
if ($ok === 1) {
|
||||
break;
|
||||
}
|
||||
@@ -214,7 +261,9 @@ class Withdrawal {
|
||||
}
|
||||
if ($withdrawal_id <= 0) {
|
||||
$replayed_row = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT id, withdrawal_number FROM {$this->table()} WHERE ledger_transaction_id = %d",
|
||||
"SELECT id, withdrawal_number FROM {$this->table()} WHERE operation_key = %s OR (%d > 0 AND ledger_transaction_id = %d) ORDER BY id DESC LIMIT 1",
|
||||
$operation_key,
|
||||
$result->transaction_id,
|
||||
$result->transaction_id
|
||||
), ARRAY_A);
|
||||
$withdrawal_id = (int) ($replayed_row['id'] ?? 0);
|
||||
@@ -329,7 +378,7 @@ class Withdrawal {
|
||||
return $preflight_replay;
|
||||
}
|
||||
if (is_array($preflight_replay)) {
|
||||
$preflight_replay['wallet_balance'] = \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal((int) ($row['user_id'] ?? 0));
|
||||
$preflight_replay['wallet_balance'] = $this->source_balance_decimal((int) ($row['user_id'] ?? 0), $this->normalize_source((string) ($meta['source'] ?? self::SOURCE_BALANCE)));
|
||||
$preflight_replay['withdrawal_number'] = (string) ($row['withdrawal_number'] ?? '');
|
||||
$preflight_replay['flow_number'] = \AKSRT\Finance\FinancialFlowService::get_instance()->number_for_operation($operation_key);
|
||||
return $preflight_replay;
|
||||
@@ -497,10 +546,16 @@ class Withdrawal {
|
||||
'flow_number' => $ledger_result instanceof \AKSRT\Ledger\LedgerResult ? $ledger_result->flow_number : $completed_flow_number,
|
||||
'withdrawal_number' => (string) ($row['withdrawal_number'] ?? ''),
|
||||
'replayed' => $replayed,
|
||||
'wallet_balance' => \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id),
|
||||
'wallet_balance' => $this->source_balance_decimal($user_id, $refund_source),
|
||||
];
|
||||
}
|
||||
|
||||
private function source_balance_decimal(int $user_id, string $source): string {
|
||||
$asset = $this->source_asset($source);
|
||||
$minor = \AKSRT\Ledger\LedgerService::get_instance()->balance($user_id, $asset);
|
||||
return \AKSRT\Ledger\AssetAmount::from_minor($asset, $minor)->decimal();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether user has open (pending/processing) withdrawals.
|
||||
*/
|
||||
|
||||
Reference in new issue
Block a user