Files
wordpress-theme-aksrt/src/Payment/CommissionLedger.php
T
2026-09-04 23:35:19 +08:00

418 lines
26 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/** Forward-only affiliate commission ledger. */
namespace AKSRT\Payment;
defined('ABSPATH') || exit;
final class CommissionLedger {
private static ?self $instance = null;
public static function get_instance(): self { return self::$instance ??= new self(); }
private function __construct() {}
public function init(): void {
add_action('aksrt_affiliate_settle_due', [$this, 'settle_due']);
add_action('rest_api_init', [$this, 'register_routes']);
if (!wp_next_scheduled('aksrt_affiliate_settle_due')) {
wp_schedule_event(time() + HOUR_IN_SECONDS, 'hourly', 'aksrt_affiliate_settle_due');
}
}
private function table(string $name): string { return \AKSRT\Core\DBMigrator::table($name); }
public function record_order_transition(string $order_number, string $status, array $order = []) {
if ($status !== Order::STATUS_PAID) {
return true;
}
$result = $this->record_paid_order(array_merge($order, [
'order_number' => $order_number,
'status' => $status,
]));
return $result >= 0
? true
: new \WP_Error('aksrt_commission_write_failed', __('返佣记录写入失败', 'aksrt'), ['status' => 500]);
}
public function record_points_exchange(int $user_id, string $amount, int $points, string $operation_key, string $order_number): bool {
$result = $this->record_paid_order([
'order_number' => $order_number,
'product_type' => 'credits',
'user_id' => $user_id,
'amount' => $amount,
'exchange_ref' => $operation_key,
'points' => $points,
]);
return $result >= 0;
}
public function record_paid_order(array $order): int {
$currency = sanitize_key((string) ($order['currency'] ?? ''));
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'];
$order_type = $map[$type] ?? $type;
$settings = \AKSRT\Settings\Settings::get_affiliate_settings();
if (!in_array($order_type, (array) $settings['order_types'], true)) return 0;
$order_no = sanitize_text_field((string) ($order['order_number'] ?? ''));
$buyer = (int) ($order['user_id'] ?? 0);
try {
$amount = \AKSRT\Ledger\AssetAmount::balance(is_string($order['amount'] ?? null) ? $order['amount'] : '');
} catch (\InvalidArgumentException $e) {
return -1;
}
if ($order_no === '' || $buyer <= 0 || $amount->minor() <= 0) return 0;
$promoter = ReferralAttribution::get_instance()->resolve($buyer);
if ($promoter <= 0) return 0;
$ratio = \AKSRT\Settings\PaymentRuntime::affiliate_ratio_for_user($promoter);
$commission_minor = (intdiv($amount->minor(), 100) * $ratio)
+ intdiv((($amount->minor() % 100) * $ratio) + 50, 100);
if ($commission_minor <= 0) return 0;
$commission = \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, $commission_minor)->decimal();
global $wpdb;
$table = $this->table('commission_ledger');
$identity = $order_type === 'credits' && !empty($order['exchange_ref'])
? 'credits:' . sanitize_key((string) $order['exchange_ref'])
: $order_no . ':' . $order_type;
$key = hash('sha256', 'affiliate:' . $identity);
$now = current_time('mysql', true);
$available = gmdate('Y-m-d H:i:s', time() + ((int) $settings['settlement_days'] * DAY_IN_SECONDS));
$ok = $wpdb->insert($table, ['promoter_id'=>$promoter,'source_user_id'=>$buyer,'order_number'=>$order_no,'order_type'=>$order_type,'eligible_base'=>$amount->decimal(),'ratio'=>$ratio,'original_amount'=>$commission,'adjustment_total'=>'0.00','status'=>'pending','idempotency_key'=>$key,'available_at'=>$available,'created_at'=>$now], ['%d','%d','%s','%s','%s','%d','%s','%s','%s','%s','%s','%s']);
if ($ok) {
return (int) $wpdb->insert_id;
}
$existing = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$table} WHERE idempotency_key=%s", $key), ARRAY_A);
if (!is_array($existing)
|| (int) $existing['promoter_id'] !== $promoter
|| (int) $existing['source_user_id'] !== $buyer
|| (string) $existing['order_number'] !== $order_no
|| (string) $existing['order_type'] !== $order_type
|| (string) $existing['eligible_base'] !== $amount->decimal()
|| (int) $existing['ratio'] !== $ratio
|| (string) $existing['original_amount'] !== $commission
) {
return -1;
}
return (int) $existing['id'];
}
public function settle_due(int $limit = 100): int {
global $wpdb; $table = $this->table('commission_ledger');
$rows = $wpdb->get_results($wpdb->prepare("SELECT * FROM {$table} WHERE status='pending' AND available_at <= %s ORDER BY id ASC LIMIT %d", current_time('mysql', true), max(1,min(500,$limit))), ARRAY_A);
$count = 0;
foreach ((array) $rows as $row) if ($this->settle_one((int)$row['id'])) $count++;
return $count;
}
private function settle_one(int $id, string $operation_key = '') {
global $wpdb; $table = $this->table('commission_ledger');
$row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$table} WHERE id=%d", $id), ARRAY_A);
if (!is_array($row)) return false;
try {
$original_minor = $this->signed_balance_minor((string) ($row['original_amount'] ?? '0'));
$adjustment_minor = $this->signed_balance_minor((string) ($row['adjustment_total'] ?? '0'));
if ($adjustment_minor > 0 && $original_minor > PHP_INT_MAX - $adjustment_minor) {
return false;
}
if ($adjustment_minor < 0 && $original_minor < PHP_INT_MIN - $adjustment_minor) {
return false;
}
$amount_minor = $original_minor + $adjustment_minor;
} catch (\InvalidArgumentException $e) { return false; }
if ($amount_minor <= 0 || (int)$row['promoter_id'] <= 0) return false;
$guard = function () use ($table, $id, $row) {
global $wpdb;
$current = $wpdb->get_row($wpdb->prepare("SELECT promoter_id, original_amount, adjustment_total, status FROM {$table} WHERE id=%d FOR UPDATE", $id), ARRAY_A);
if (!is_array($current) || (string)$current['status'] !== 'pending'
|| (int)$current['promoter_id'] !== (int)$row['promoter_id']
|| (string)$current['original_amount'] !== (string)$row['original_amount']
|| (string)$current['adjustment_total'] !== (string)$row['adjustment_total']) {
return new \WP_Error('aksrt_commission_state', __('佣金记录状态已变化','aksrt'), ['status'=>409]);
}
return true;
};
$mutation = function () use ($table, $id) {
global $wpdb;
return $wpdb->query($wpdb->prepare("UPDATE {$table} SET status='settled', settled_at=%s WHERE id=%d AND status='pending'", current_time('mysql', true), $id)) === 1
? true : new \WP_Error('aksrt_commission_state', __('佣金记录状态已变化','aksrt'), ['status'=>409]);
};
$result = \AKSRT\Ledger\LedgerService::get_instance()->credit(
(int)$row['promoter_id'], \AKSRT\Ledger\AssetType::BALANCE, $amount_minor,
$operation_key !== '' ? $operation_key : \AKSRT\Ledger\OperationKey::deterministic('commission-settle', [$id]), 'affiliate_commission',
['type'=>'commission','id'=>$id], sprintf(__('推广佣金结算(订单 %s)','aksrt'), $row['order_number']),
0, [], $mutation, $guard
);
return is_wp_error($result) ? false : $result;
}
/** @return array<string,mixed>|\WP_Error */
public function prepare_full_refund(string $order_number) {
global $wpdb;
$row = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM {$this->table('commission_ledger')} WHERE order_number = %s LIMIT 1",
$order_number
), ARRAY_A);
if (!is_array($row) || (string) ($row['status'] ?? '') === 'cancelled') {
return ['active' => false];
}
try {
$original_minor = $this->signed_balance_minor((string) ($row['original_amount'] ?? '0'));
$adjustment_minor = $this->signed_balance_minor((string) ($row['adjustment_total'] ?? '0'));
} catch (\InvalidArgumentException $e) {
return new \WP_Error('aksrt_commission_amount', __('返佣金额无效', 'aksrt'), ['status' => 500]);
}
$remaining_minor = $original_minor + $adjustment_minor;
if ($remaining_minor <= 0) {
return ['active' => false];
}
$status = (string) ($row['status'] ?? '');
if (!in_array($status, ['pending', 'settled'], true)) {
return new \WP_Error('aksrt_commission_state', __('返佣记录状态不可退款', 'aksrt'), ['status' => 409]);
}
return [
'active' => true,
'id' => (int) $row['id'],
'promoter_id' => (int) $row['promoter_id'],
'status' => $status,
'original_amount' => (string) $row['original_amount'],
'adjustment_total' => (string) $row['adjustment_total'],
'amount_minor' => $remaining_minor,
'source_key' => hash('sha256', 'order-full-refund:' . $order_number),
];
}
/** @param array<string,mixed> $plan */
public function guard_full_refund(array $plan) {
if (empty($plan['active'])) return true;
global $wpdb;
$current = $wpdb->get_row($wpdb->prepare(
"SELECT promoter_id, status, original_amount, adjustment_total FROM {$this->table('commission_ledger')} WHERE id = %d FOR UPDATE",
(int) $plan['id']
), ARRAY_A);
if (!is_array($current)
|| (int) ($current['promoter_id'] ?? 0) !== (int) $plan['promoter_id']
|| (string) ($current['status'] ?? '') !== (string) $plan['status']
|| (string) ($current['original_amount'] ?? '') !== (string) $plan['original_amount']
|| (string) ($current['adjustment_total'] ?? '') !== (string) $plan['adjustment_total']
) {
return new \WP_Error('aksrt_commission_state', __('返佣记录状态已变化', 'aksrt'), ['status' => 409]);
}
return true;
}
/** @param array<string,mixed> $plan */
public function apply_full_refund(array $plan) {
if (empty($plan['active'])) return true;
global $wpdb;
$negative = '-' . \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, (int) $plan['amount_minor'])->decimal();
$inserted = $wpdb->insert($this->table('commission_adjustment'), [
'ledger_id' => (int) $plan['id'],
'promoter_id' => (int) $plan['promoter_id'],
'amount' => $negative,
'reason' => 'order_refund',
'source_key' => (string) $plan['source_key'],
'operator_id' => get_current_user_id(),
'created_at' => current_time('mysql', true),
], ['%d', '%d', '%s', '%s', '%s', '%d', '%s']);
if ($inserted !== 1) {
return new \WP_Error('aksrt_commission_adjustment', __('返佣退款记录写入失败', 'aksrt'), ['status' => 409]);
}
$updated = $wpdb->query($wpdb->prepare(
"UPDATE {$this->table('commission_ledger')} SET adjustment_total = adjustment_total + %s, status = 'cancelled' WHERE id = %d AND status = %s",
$negative,
(int) $plan['id'],
(string) $plan['status']
));
return $updated === 1
? true
: new \WP_Error('aksrt_commission_state', __('返佣记录状态已变化', 'aksrt'), ['status' => 409]);
}
private function signed_balance_minor(string $decimal): int {
$negative = str_starts_with($decimal, '-');
$value = $negative ? substr($decimal, 1) : $decimal;
$minor = \AKSRT\Ledger\AssetAmount::balance($value)->minor();
return $negative ? -$minor : $minor;
}
public function overview(int $user_id): array {
global $wpdb; $table=$this->table('commission_ledger');
$row=$wpdb->get_row($wpdb->prepare("SELECT COALESCE(SUM(original_amount+adjustment_total),0) total, COALESCE(SUM(CASE WHEN status='pending' THEN original_amount+adjustment_total ELSE 0 END),0) pending, COALESCE(SUM(CASE WHEN status='settled' THEN original_amount+adjustment_total ELSE 0 END),0) settled FROM {$table} WHERE promoter_id=%d",$user_id),ARRAY_A) ?: [];
$withdrawn = '0.00';
$withdrawal_table = $this->table('withdrawal');
if ($withdrawal_table !== '') {
$withdrawn = $this->balance_decimal((string) $wpdb->get_var($wpdb->prepare(
"SELECT COALESCE(SUM(CASE WHEN status='completed' THEN gross_amount ELSE 0 END),0) FROM {$withdrawal_table} WHERE user_id=%d",
$user_id
)));
}
return ['total'=>$this->balance_decimal((string)($row['total']??'0')),'pending'=>$this->balance_decimal((string)($row['pending']??'0')),'settled'=>$this->balance_decimal((string)($row['settled']??'0')),'wallet'=>\AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id),'withdrawn'=>$withdrawn];
}
public function list_entries(int $user_id, array $args=[]): array {
global $wpdb; $table=$this->table('commission_ledger'); $limit=max(1,min(100,(int)($args['per_page']??20))); $page=max(1,(int)($args['page']??1)); $offset=($page-1)*$limit; $where='promoter_id=%d'; $params=[$user_id]; $status=sanitize_key((string)($args['status']??'')); if(in_array($status,['pending','settled','cancelled'],true)){ $where.=' AND status=%s'; $params[]=$status; } $total=(int)$wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM {$table} WHERE {$where}",$params)); $rows=$wpdb->get_results($wpdb->prepare("SELECT * FROM {$table} WHERE {$where} ORDER BY id DESC LIMIT %d OFFSET %d",array_merge($params,[$limit,$offset])),ARRAY_A); return ['items'=>$rows?:[],'total'=>$total,'page'=>$page,'per_page'=>$limit,'max_pages'=>max(1,(int)ceil($total/$limit))];
}
public function register_routes(): void {
$list_args = [
'page' => ['type' => 'integer', 'minimum' => 1, 'default' => 1],
'per_page' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 100, 'default' => 20],
'status' => ['type' => 'string', 'enum' => ['pending', 'processing', 'completed', 'rejected', 'settled', 'cancelled']],
];
register_rest_route('aksrt/v1', '/earnings/overview', [
'methods' => \WP_REST_Server::READABLE,
'callback' => fn() => rest_ensure_response($this->overview(get_current_user_id())),
'permission_callback' => [$this, 'check_user_rest'],
]);
register_rest_route('aksrt/v1', '/earnings/commissions', [
'methods' => \WP_REST_Server::READABLE,
'callback' => fn(\WP_REST_Request $request) => rest_ensure_response($this->list_entries(get_current_user_id(), $request->get_params())),
'permission_callback' => [$this, 'check_user_rest'],
'args' => $list_args,
]);
register_rest_route('aksrt/v1', '/earnings/withdrawals', [
[
'methods' => \WP_REST_Server::READABLE,
'callback' => fn(\WP_REST_Request $request) => rest_ensure_response($this->withdrawals(get_current_user_id(), $request->get_params())),
'permission_callback' => [$this, 'check_user_rest'],
'args' => $list_args,
],
[
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [$this, 'create_withdrawal'],
'permission_callback' => [$this, 'check_user_write_rest'],
'args' => [
'amount' => ['type' => 'string', 'required' => true, 'pattern' => '^(0|[1-9]\\d*)(?:\\.\\d{1,2})?$'],
'method' => ['type' => 'string', 'required' => true, 'sanitize_callback' => 'sanitize_key'],
'account' => ['type' => 'string', 'required' => true, 'minLength' => 1, 'maxLength' => 500, 'sanitize_callback' => 'sanitize_text_field'],
],
],
]);
register_rest_route('aksrt/v1', '/payment/affiliate/ledger/(?P<id>\d+)/settle', [
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [$this, 'admin_settle'],
'permission_callback' => [$this, 'check_admin_write_rest'],
'args' => ['id' => ['type' => 'integer', 'minimum' => 1, 'required' => true]],
]);
register_rest_route('aksrt/v1', '/payment/affiliate/ledger/(?P<id>\d+)/cancel', [
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [$this, 'admin_cancel'],
'permission_callback' => [$this, 'check_admin_write_rest'],
'args' => ['id' => ['type' => 'integer', 'minimum' => 1, 'required' => true]],
]);
register_rest_route('aksrt/v1', '/payment/affiliate/overview', [
'methods' => \WP_REST_Server::READABLE,
'callback' => fn() => rest_ensure_response($this->admin_overview()),
'permission_callback' => [$this, 'check_admin_rest'],
]);
register_rest_route('aksrt/v1', '/payment/affiliate/ledger', [
'methods' => \WP_REST_Server::READABLE,
'callback' => fn(\WP_REST_Request $request) => rest_ensure_response($this->admin_ledger($request->get_params())),
'permission_callback' => [$this, 'check_admin_rest'],
'args' => $list_args,
]);
register_rest_route('aksrt/v1', '/payment/withdrawals/(?P<id>\d+)/transition', [
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [$this, 'transition_withdrawal'],
'permission_callback' => [$this, 'check_admin_write_rest'],
'args' => [
'id' => ['type' => 'integer', 'minimum' => 1, 'required' => true],
'status' => ['type' => 'string', 'required' => true, 'enum' => ['processing', 'completed', 'rejected']],
'note' => ['type' => 'string', 'default' => '', 'maxLength' => 1000, 'sanitize_callback' => 'sanitize_textarea_field'],
],
]);
}
public function check_user_rest(\WP_REST_Request $request) {
return is_user_logged_in() ? true : new \WP_Error('aksrt_auth_required', __('请先登录', 'aksrt'), ['status' => 401]);
}
public function check_user_write_rest(\WP_REST_Request $request) {
$allowed = $this->check_user_rest($request);
return is_wp_error($allowed) ? $allowed : $this->verify_rest_nonce($request);
}
public function check_admin_rest(\WP_REST_Request $request) {
return current_user_can('manage_options') ? true : new \WP_Error('aksrt_admin_forbidden', __('无权执行此操作', 'aksrt'), ['status' => 403]);
}
public function check_admin_write_rest(\WP_REST_Request $request) {
$allowed = $this->check_admin_rest($request);
return is_wp_error($allowed) ? $allowed : $this->verify_rest_nonce($request);
}
private function verify_rest_nonce(\WP_REST_Request $request) {
$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 admin_settle(\WP_REST_Request $request): \WP_REST_Response|\WP_Error {
$operation_key = \AKSRT\Ledger\OperationKey::from_request($request, 'admin-commission-settle', get_current_user_id());
if (is_wp_error($operation_key)) return $operation_key;
$result = $this->settle_one((int) $request['id'], $operation_key);
if (!$result instanceof \AKSRT\Ledger\LedgerResult) {
return new \WP_REST_Response(['success' => false, 'message' => __('佣金结算失败', 'aksrt')], 409);
}
return new \WP_REST_Response(['success' => true, 'transaction_id' => $result->transaction_id, 'replayed' => $result->replayed], 200);
}
public function admin_cancel(\WP_REST_Request $request): \WP_REST_Response|\WP_Error {
$operation_key = \AKSRT\Ledger\OperationKey::from_request($request, 'admin-commission-cancel', get_current_user_id());
if (is_wp_error($operation_key)) return $operation_key;
global $wpdb;
$id = (int) $request['id'];
$payload_hash = hash('sha256', (string) wp_json_encode(['id' => $id, 'status' => 'cancelled']));
if ($wpdb->query('START TRANSACTION') === false) {
return new \WP_Error('aksrt_commission_db', __('无法开启返佣取消事务', 'aksrt'), ['status' => 500]);
}
$row = $wpdb->get_row($wpdb->prepare("SELECT status, cancel_operation_key, cancel_payload_hash FROM {$this->table('commission_ledger')} WHERE id=%d FOR UPDATE", $id), ARRAY_A);
if (!is_array($row)) {
$wpdb->query('ROLLBACK');
return new \WP_Error('aksrt_commission_missing', __('返佣记录不存在', 'aksrt'), ['status' => 404]);
}
$stored_key = (string) ($row['cancel_operation_key'] ?? '');
$stored_hash = (string) ($row['cancel_payload_hash'] ?? '');
if ($stored_key !== '') {
if (hash_equals($stored_key, $operation_key) && $stored_hash !== '' && hash_equals($stored_hash, $payload_hash) && (string) $row['status'] === 'cancelled') {
if ($wpdb->query('COMMIT') === false) {
$wpdb->query('ROLLBACK');
return new \WP_Error('aksrt_commission_db', __('返佣取消重放失败', 'aksrt'), ['status' => 500]);
}
return new \WP_REST_Response(['success' => true, 'operation_key' => $operation_key, 'transaction_id' => 0, 'replayed' => true], 200);
}
$wpdb->query('ROLLBACK');
return new \WP_Error('aksrt_commission_idempotency_conflict', __('相同操作标识已用于其他返佣取消请求', 'aksrt'), ['status' => 409]);
}
if ((string) ($row['status'] ?? '') !== 'pending') {
$wpdb->query('ROLLBACK');
return new \WP_Error('aksrt_commission_state', __('返佣记录状态已变化', 'aksrt'), ['status' => 409]);
}
$updated = $wpdb->update($this->table('commission_ledger'), [
'status' => 'cancelled',
'cancel_operation_key' => $operation_key,
'cancel_payload_hash' => $payload_hash,
], ['id' => $id, 'status' => 'pending'], ['%s', '%s', '%s'], ['%d', '%s']);
if ($updated !== 1 || $wpdb->query('COMMIT') === false) {
$wpdb->query('ROLLBACK');
$conflict = $wpdb->get_var($wpdb->prepare("SELECT id FROM {$this->table('commission_ledger')} WHERE cancel_operation_key=%s", $operation_key));
return new \WP_Error($conflict ? 'aksrt_commission_idempotency_conflict' : 'aksrt_commission_db', $conflict ? __('相同操作标识已用于其他返佣取消请求', 'aksrt') : __('返佣取消失败', 'aksrt'), ['status' => $conflict ? 409 : 500]);
}
return new \WP_REST_Response(['success' => true, 'operation_key' => $operation_key, 'transaction_id' => 0, 'replayed' => false], 200);
}
public function admin_overview(): array { global $wpdb; $table=$this->table('commission_ledger'); $row=$wpdb->get_row("SELECT COUNT(*) total, SUM(status='pending') pending, SUM(status='settled') settled, COALESCE(SUM(original_amount+adjustment_total),0) amount FROM {$table}",ARRAY_A) ?: []; return ['total'=>(int)($row['total']??0),'pending'=>(int)($row['pending']??0),'settled'=>(int)($row['settled']??0),'amount'=>$this->balance_decimal((string)($row['amount']??'0'))]; }
public function admin_ledger(array $args): array { global $wpdb; $table=$this->table('commission_ledger'); $limit=max(1,min(100,(int)($args['per_page']??20))); $page=max(1,(int)($args['page']??1)); $offset=($page-1)*$limit; $status=sanitize_key((string)($args['status']??'')); $where='1=1'; $params=[]; if(in_array($status,['pending','settled','cancelled'],true)){ $where.=' AND status=%s'; $params[]=$status; } $total=(int)($wpdb->get_var($params?$wpdb->prepare("SELECT COUNT(*) FROM {$table} WHERE {$where}",$params):"SELECT COUNT(*) FROM {$table}")); $rows=$wpdb->get_results($wpdb->prepare("SELECT * FROM {$table} WHERE {$where} ORDER BY id DESC LIMIT %d OFFSET %d",array_merge($params,[$limit,$offset])),ARRAY_A); return ['items'=>$rows?:[],'total'=>$total,'page'=>$page,'per_page'=>$limit,'max_pages'=>max(1,(int)ceil($total/$limit))]; }
public function withdrawals(int $user_id, array $args): array { return \AKSRT\Payment\Withdrawal::get_instance()->get_user_withdrawals($user_id, ['status'=>sanitize_key((string)($args['status']??'')),'limit'=>max(1,min(100,(int)($args['per_page']??20))),'offset'=>max(0,((int)($args['page']??1)-1)*max(1,(int)($args['per_page']??20)))]); }
public function create_withdrawal(\WP_REST_Request $request): \WP_REST_Response|\WP_Error {
$user_id=get_current_user_id(); $method=sanitize_key((string)$request->get_param('method')); $account=sanitize_text_field((string)$request->get_param('account')); $settings=\AKSRT\Settings\Settings::get_withdrawal_settings(); $affiliate=\AKSRT\Settings\Settings::get_affiliate_settings();
$raw_amount=$request->get_param('amount');
try { $amount=\AKSRT\Ledger\AssetAmount::balance(is_string($raw_amount)?$raw_amount:''); }
catch (\InvalidArgumentException $e) { return new \WP_Error('affiliate_withdraw_amount',__('提现金额格式无效。','aksrt'),['status'=>400]); }
$operation_key=\AKSRT\Ledger\OperationKey::from_request($request,'affiliate-withdraw',$user_id); if(is_wp_error($operation_key)) return $operation_key;
if (empty($affiliate['enabled'])) return new \WP_Error('affiliate_disabled',__('推广返佣未启用。','aksrt'),['status'=>403]);
$minimum=\AKSRT\Ledger\AssetAmount::balance(is_string($settings['min_amount'] ?? null) ? $settings['min_amount'] : '');
if ($amount->minor() < $minimum->minor()) return new \WP_Error('affiliate_withdraw_min',__('未达到最低提现金额。','aksrt'),['status'=>400]);
if (!in_array($method,(array)$settings['payout_channels'],true) || $account==='') return new \WP_Error('affiliate_withdraw_account',__('请提供有效的收款渠道和收款码。','aksrt'),['status'=>400]);
$result=\AKSRT\Payment\Withdrawal::get_instance()->create_balance_request($user_id,$amount->minor(),$method,$account,$settings,$operation_key); if(is_wp_error($result)) return $result; return new \WP_REST_Response(['success'=>true,'data'=>$result],201);
}
public function admin_withdrawals(array $args): array { $service=\AKSRT\Payment\Withdrawal::get_instance(); return ['items'=>$service->get_all(['status'=>sanitize_key((string)($args['status']??'')),'limit'=>max(1,min(100,(int)($args['per_page']??20))),'offset'=>max(0,((int)($args['page']??1)-1)*max(1,(int)($args['per_page']??20)))]),'stats'=>$service->get_stats()]; }
public function transition_withdrawal(\WP_REST_Request $request): \WP_REST_Response|\WP_Error {
$operation_key = \AKSRT\Ledger\OperationKey::from_request($request, 'admin-withdraw-transition', get_current_user_id());
if (is_wp_error($operation_key)) return $operation_key;
$status = sanitize_key((string) $request->get_param('status'));
$result = \AKSRT\Payment\Withdrawal::get_instance()->process_status_change((int) $request['id'], $status, (string) $request->get_param('note'), $operation_key);
if (is_wp_error($result)) return $result;
$data = is_array($result) ? $result : [];
return new \WP_REST_Response(array_merge(['success' => true, 'status' => $status], $data), 200);
}
private function balance_decimal(string $value): string {
try {
return \AKSRT\Ledger\AssetAmount::balance($value)->decimal();
} catch (\InvalidArgumentException $e) {
return '0.00';
}
}
}