489 lines
20 KiB
PHP
489 lines
20 KiB
PHP
<?php
|
||
/**
|
||
* @package AKSRT
|
||
*/
|
||
|
||
namespace AKSRT\Payment;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
class Revenue {
|
||
|
||
private static $instance = null;
|
||
|
||
public static function get_instance(): self {
|
||
if (null === self::$instance) {
|
||
self::$instance = new self();
|
||
}
|
||
return self::$instance;
|
||
}
|
||
|
||
private function table(): string {
|
||
return \AKSRT\Core\DBMigrator::table('revenue');
|
||
}
|
||
|
||
/**
|
||
* Create one pending creator-income record from a completed paid order.
|
||
*
|
||
* @param array<string,mixed> $order
|
||
*/
|
||
public function create_from_paid_order(array $order): int {
|
||
global $wpdb;
|
||
|
||
if (!$this->is_enabled()) {
|
||
return 0;
|
||
}
|
||
|
||
$order_number = sanitize_text_field((string) ($order['order_number'] ?? ''));
|
||
if ($order_number === '' || (string) ($order['status'] ?? '') !== 'completed') {
|
||
return 0;
|
||
}
|
||
$product_type = sanitize_key((string) ($order['product_type'] ?? ''));
|
||
if ($product_type !== \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE) {
|
||
return 0;
|
||
}
|
||
|
||
$existing = $wpdb->get_var($wpdb->prepare(
|
||
"SELECT id FROM {$this->table()} WHERE order_number = %s ORDER BY id ASC LIMIT 1",
|
||
$order_number
|
||
));
|
||
if ((int) $existing > 0) {
|
||
return (int) $existing;
|
||
}
|
||
|
||
$meta = $this->decode_order_meta($order);
|
||
if (empty($meta['content_pay'])) {
|
||
return 0;
|
||
}
|
||
|
||
$post_id = (int) ($order['product_id'] ?? $meta['post_id'] ?? 0);
|
||
$buyer_id = (int) ($order['user_id'] ?? 0);
|
||
$post = $post_id > 0 ? get_post($post_id) : null;
|
||
if (!($post instanceof \WP_Post) || $post->post_type !== 'post') {
|
||
return 0;
|
||
}
|
||
|
||
$author_id = (int) $post->post_author;
|
||
if ($author_id <= 0 || $author_id === $buyer_id) {
|
||
return 0;
|
||
}
|
||
|
||
$currency = sanitize_key((string) ($order['currency'] ?? 'CNY'));
|
||
$is_points = $currency === 'points' || ($meta['pay_modo'] ?? '') === 'points';
|
||
$currency = $is_points ? 'points' : 'CNY';
|
||
$original_amount = round((float) ($order['amount'] ?? $meta['amount'] ?? $meta['payable'] ?? 0), 2);
|
||
if ($is_points) {
|
||
$original_amount = (float) ceil($original_amount);
|
||
}
|
||
if ($original_amount <= 0) {
|
||
return 0;
|
||
}
|
||
|
||
$author_level = function_exists('aksrt_get_user_vip_level')
|
||
? max(0, (int) aksrt_get_user_vip_level($author_id))
|
||
: 0;
|
||
$ratio = $this->resolve_share_ratio($currency, $author_level);
|
||
if ($ratio <= 0) {
|
||
return 0;
|
||
}
|
||
$amount = $is_points
|
||
? (float) max(1, (int) floor($original_amount * $ratio / 100))
|
||
: round($original_amount * $ratio / 100, 2);
|
||
if ($amount <= 0) {
|
||
return 0;
|
||
}
|
||
|
||
$inserted = $wpdb->insert($this->table(), [
|
||
'author_id' => $author_id,
|
||
'order_number' => $order_number,
|
||
'post_id' => $post_id,
|
||
'buyer_id' => $buyer_id,
|
||
'currency' => $currency,
|
||
'original_amount' => $original_amount,
|
||
'share_ratio' => $ratio,
|
||
'author_level' => $author_level,
|
||
'amount' => $amount,
|
||
'type' => 'order',
|
||
'status' => 'pending',
|
||
'created_at' => current_time('mysql'),
|
||
], ['%d', '%s', '%d', '%d', '%s', '%f', '%f', '%d', '%f', '%s', '%s', '%s']);
|
||
|
||
if ($inserted === false) {
|
||
// Duplicate callbacks are a successful no-op by contract.
|
||
$existing = $wpdb->get_var($wpdb->prepare(
|
||
"SELECT id FROM {$this->table()} WHERE order_number = %s ORDER BY id ASC LIMIT 1",
|
||
$order_number
|
||
));
|
||
return (int) $existing;
|
||
}
|
||
|
||
$id = (int) $wpdb->insert_id;
|
||
if ($id > 0) {
|
||
do_action('aksrt_creator_income_added', $author_id, $amount, [
|
||
'order_number' => $order_number,
|
||
'currency' => $currency,
|
||
'revenue_id' => $id,
|
||
]);
|
||
}
|
||
return $id;
|
||
}
|
||
|
||
/**
|
||
* Settle one or more pending records through matching atomic balance services.
|
||
*
|
||
* @param int|array<int,int> $record_ids
|
||
* @param int $actor_id
|
||
*/
|
||
public function settle($record_ids, int $actor_id = 0): bool {
|
||
$result = $this->settle_batch($record_ids, $actor_id);
|
||
return !empty($result['settled']) && empty($result['failed']);
|
||
}
|
||
|
||
/**
|
||
* Settle a batch while preserving per-record outcomes for operational feedback.
|
||
*
|
||
* @param int|array<int,int> $record_ids
|
||
* @param int $actor_id
|
||
* @return array{settled:array<int,int>,failed:array<int,int>}
|
||
*/
|
||
public function settle_batch($record_ids, int $actor_id = 0): array {
|
||
$ids = is_array($record_ids) ? $record_ids : [$record_ids];
|
||
$ids = array_values(array_unique(array_filter(array_map('intval', $ids), static fn (int $id): bool => $id > 0)));
|
||
if (!$ids || !$this->is_enabled()) {
|
||
return ['settled' => [], 'failed' => $ids];
|
||
}
|
||
|
||
$settled = [];
|
||
$failed = [];
|
||
foreach ($ids as $revenue_id) {
|
||
if ($this->settle_one($revenue_id, $actor_id)) {
|
||
$settled[] = $revenue_id;
|
||
} else {
|
||
$failed[] = $revenue_id;
|
||
}
|
||
}
|
||
return ['settled' => $settled, 'failed' => $failed];
|
||
}
|
||
|
||
/**
|
||
* Settle all pending records whose rolling earning cycle has ended.
|
||
*
|
||
* @return int Number of records settled.
|
||
*/
|
||
public function settle_due(int $limit = 100, int $actor_id = 0): int {
|
||
global $wpdb;
|
||
|
||
if (!$this->is_enabled()) {
|
||
return 0;
|
||
}
|
||
$settings = \AKSRT\Settings\Settings::get_payment_revenue_settings();
|
||
$cycle_days = max(1, (int) ($settings['cycle_days'] ?? 30));
|
||
$timezone = function_exists('wp_timezone') ? wp_timezone() : new \DateTimeZone('UTC');
|
||
$cutoff = (new \DateTimeImmutable('now', $timezone))
|
||
->modify('-' . $cycle_days . ' days')
|
||
->format('Y-m-d H:i:s');
|
||
|
||
$limit = max(1, min(500, $limit));
|
||
$ids = $wpdb->get_col($wpdb->prepare(
|
||
"SELECT id FROM {$this->table()} WHERE status = %s AND created_at < %s ORDER BY id ASC LIMIT %d",
|
||
'pending',
|
||
$cutoff,
|
||
$limit
|
||
));
|
||
|
||
$settled = 0;
|
||
foreach ((array) $ids as $id) {
|
||
if ($this->settle((int) $id, $actor_id)) {
|
||
$settled++;
|
||
}
|
||
}
|
||
return $settled;
|
||
}
|
||
|
||
private function settle_one(int $revenue_id, int $actor_id): bool {
|
||
global $wpdb;
|
||
$wpdb->query('START TRANSACTION');
|
||
try {
|
||
$revenue = $wpdb->get_row($wpdb->prepare(
|
||
"SELECT * FROM {$this->table()} WHERE id = %d AND status = %s FOR UPDATE",
|
||
$revenue_id,
|
||
'pending'
|
||
), ARRAY_A);
|
||
if (!is_array($revenue)) {
|
||
$wpdb->query('COMMIT');
|
||
return false;
|
||
}
|
||
|
||
$ref = ['type' => 'revenue', 'id' => $revenue_id, 'currency' => (string) ($revenue['currency'] ?? 'CNY')];
|
||
$remark = sprintf(__('创作收益结算(订单 %s)', 'aksrt'), (string) ($revenue['order_number'] ?? ''));
|
||
if (!$this->has_balance_entry((int) $revenue['author_id'], $ref)) {
|
||
$credited = (string) ($revenue['currency'] ?? '') === 'points'
|
||
? (class_exists('\\AKSRT\\Credits\\Credits') ? \AKSRT\Credits\Credits::add((int) $revenue['author_id'], (int) ceil((float) $revenue['amount']), 'creator_income', $ref, $remark) : new \WP_Error('credits_unavailable'))
|
||
: (class_exists('\\AKSRT\\Credits\\Balance') ? \AKSRT\Credits\Balance::add((int) $revenue['author_id'], (float) $revenue['amount'], 'creator_income', $ref, $remark) : new \WP_Error('balance_unavailable'));
|
||
if (is_wp_error($credited)) {
|
||
$wpdb->query('ROLLBACK');
|
||
return false;
|
||
}
|
||
}
|
||
$updated = $wpdb->query($wpdb->prepare(
|
||
"UPDATE {$this->table()} SET status = %s, settled_at = %s, audit_json = %s WHERE id = %d AND status = %s",
|
||
'settled',
|
||
current_time('mysql'),
|
||
$this->audit_json('settled', '', $actor_id),
|
||
$revenue_id,
|
||
'pending'
|
||
));
|
||
if ($updated !== 1) {
|
||
throw new \RuntimeException('revenue_settle_failed');
|
||
}
|
||
$wpdb->query('COMMIT');
|
||
return true;
|
||
} catch (\Throwable $e) {
|
||
$wpdb->query('ROLLBACK');
|
||
return false;
|
||
}
|
||
}
|
||
|
||
private function has_balance_entry(int $user_id, array $ref): bool {
|
||
global $wpdb;
|
||
|
||
if ($user_id <= 0 || (int) ($ref['id'] ?? 0) <= 0) {
|
||
return false;
|
||
}
|
||
$table = (string) ($ref['currency'] ?? '') === 'points'
|
||
? \AKSRT\Core\DBMigrator::table('credit_log')
|
||
: \AKSRT\Core\DBMigrator::table('balance_log');
|
||
if ($table === '') {
|
||
return false;
|
||
}
|
||
return (bool) $wpdb->get_var($wpdb->prepare(
|
||
"SELECT id FROM {$table} WHERE user_id = %d AND ref_type = %s AND ref_id = %d AND reason = %s LIMIT 1",
|
||
$user_id,
|
||
'revenue',
|
||
(int) $ref['id'],
|
||
'creator_income'
|
||
));
|
||
}
|
||
|
||
private function is_enabled(): bool {
|
||
return class_exists('\\AKSRT\\Settings\\Settings')
|
||
&& !empty(\AKSRT\Settings\Settings::get_payment_revenue_settings()['enabled']);
|
||
}
|
||
|
||
private function audit_json(string $action, string $message = '', int $actor_id = 0): string {
|
||
$json = wp_json_encode([
|
||
'action' => sanitize_key($action),
|
||
'message' => sanitize_textarea_field($message),
|
||
'actor_id' => max(0, $actor_id),
|
||
'at' => current_time('mysql'),
|
||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||
return is_string($json) ? $json : '{}';
|
||
}
|
||
|
||
public function get_user_revenues(int $user_id, array $args = []): array {
|
||
global $wpdb;
|
||
if ($user_id <= 0) {
|
||
return [];
|
||
}
|
||
$where = ["author_id = %d", "status IN ('pending', 'settled')"];
|
||
$params = [$user_id];
|
||
$status = sanitize_key((string) ($args['status'] ?? ''));
|
||
if (in_array($status, ['pending', 'settled'], true)) {
|
||
$where[] = 'status = %s';
|
||
$params[] = $status;
|
||
}
|
||
$currency = strtolower(sanitize_key((string) ($args['currency'] ?? '')));
|
||
if (in_array($currency, ['cny', 'points'], true)) {
|
||
$currency = $currency === 'points' ? 'points' : 'CNY';
|
||
$where[] = 'currency = %s';
|
||
$params[] = $currency;
|
||
}
|
||
$limit = max(1, min(100, (int) ($args['limit'] ?? 20)));
|
||
$offset = max(0, (int) ($args['offset'] ?? 0));
|
||
$params[] = $limit;
|
||
$params[] = $offset;
|
||
return (array) $wpdb->get_results($wpdb->prepare(
|
||
"SELECT * FROM {$this->table()} WHERE " . implode(' AND ', $where) . " ORDER BY created_at DESC, id DESC LIMIT %d OFFSET %d",
|
||
$params
|
||
), ARRAY_A);
|
||
}
|
||
|
||
/**
|
||
* Return current-author income statistics and paginated records.
|
||
*
|
||
* @return array{stats:array,items:array,total:int,max_pages:int,page:int,per_page:int}
|
||
*/
|
||
public function get_user_income_view(int $user_id, array $args = []): array {
|
||
global $wpdb;
|
||
$empty_stats = [
|
||
'today' => ['CNY' => 0.0, 'points' => 0.0],
|
||
'total' => ['CNY' => 0.0, 'points' => 0.0],
|
||
'pending' => ['CNY' => 0.0, 'points' => 0.0],
|
||
'settled' => ['CNY' => 0.0, 'points' => 0.0],
|
||
];
|
||
if ($user_id <= 0) {
|
||
return ['stats' => $empty_stats, 'items' => [], 'total' => 0, 'max_pages' => 1, 'page' => 1, 'per_page' => 20];
|
||
}
|
||
|
||
$where = 'author_id = %d';
|
||
$params = [$user_id];
|
||
$currency = strtolower(sanitize_key((string) ($args['currency'] ?? '')));
|
||
if (in_array($currency, ['cny', 'points'], true)) {
|
||
$currency = $currency === 'points' ? 'points' : 'CNY';
|
||
$where .= ' AND currency = %s';
|
||
$params[] = $currency;
|
||
}
|
||
$today = current_time('Y-m-d');
|
||
$stats_params = array_merge($params, [$today]);
|
||
$stats_row = $wpdb->get_results($wpdb->prepare(
|
||
"SELECT currency,
|
||
COALESCE(SUM(CASE WHEN status IN ('pending', 'settled') THEN amount ELSE 0 END), 0) total_amount,
|
||
COALESCE(SUM(CASE WHEN status = 'pending' THEN amount ELSE 0 END), 0) pending_amount,
|
||
COALESCE(SUM(CASE WHEN status = 'settled' THEN amount ELSE 0 END), 0) settled_amount,
|
||
COALESCE(SUM(CASE WHEN status IN ('pending', 'settled') AND DATE(created_at) = %s THEN amount ELSE 0 END), 0) today_amount
|
||
FROM {$this->table()} WHERE {$where}
|
||
GROUP BY currency",
|
||
$stats_params
|
||
), ARRAY_A);
|
||
foreach ((array) $stats_row as $row) {
|
||
$key = (string) ($row['currency'] ?? '');
|
||
if (!isset($empty_stats['total'][$key])) {
|
||
continue;
|
||
}
|
||
$empty_stats['today'][$key] = (float) ($row['today_amount'] ?? 0);
|
||
$empty_stats['total'][$key] = (float) ($row['total_amount'] ?? 0);
|
||
$empty_stats['pending'][$key] = (float) ($row['pending_amount'] ?? 0);
|
||
$empty_stats['settled'][$key] = (float) ($row['settled_amount'] ?? 0);
|
||
}
|
||
|
||
$status = sanitize_key((string) ($args['status'] ?? ''));
|
||
if (!in_array($status, ['pending', 'settled'], true)) {
|
||
$status = '';
|
||
}
|
||
$list_args = ['currency' => $currency, 'status' => $status, 'limit' => 20, 'offset' => 0];
|
||
$page = max(1, min(100, (int) ($args['page'] ?? 1)));
|
||
$list_args['offset'] = ($page - 1) * 20;
|
||
$items = $this->get_user_revenues($user_id, $list_args);
|
||
$count_where = $where . " AND status IN ('pending', 'settled')" . ($status !== '' ? ' AND status = %s' : '');
|
||
$count_params = $params;
|
||
if ($status !== '') {
|
||
$count_params[] = $status;
|
||
}
|
||
$total = (int) $wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM {$this->table()} WHERE {$count_where}", $count_params));
|
||
return [
|
||
'stats' => $empty_stats,
|
||
'items' => $items,
|
||
'total' => $total,
|
||
'max_pages' => max(1, (int) ceil($total / 20)),
|
||
'page' => $page,
|
||
'per_page' => 20,
|
||
];
|
||
}
|
||
|
||
public function get_list(array $args = []): array {
|
||
global $wpdb;
|
||
$limit = max(1, min(100, (int) ($args['limit'] ?? 20)));
|
||
$offset = max(0, (int) ($args['offset'] ?? 0));
|
||
$where = ["status IN ('pending', 'settled')"];
|
||
$params = [];
|
||
$status = sanitize_key((string) ($args['status'] ?? ''));
|
||
if (in_array($status, ['pending', 'settled'], true)) {
|
||
$where[] = 'status = %s';
|
||
$params[] = $status;
|
||
}
|
||
if (($user_id = (int) ($args['user_id'] ?? 0)) > 0) {
|
||
$where[] = 'author_id = %d';
|
||
$params[] = $user_id;
|
||
}
|
||
if (($post_id = (int) ($args['post_id'] ?? 0)) > 0) {
|
||
$where[] = 'post_id = %d';
|
||
$params[] = $post_id;
|
||
}
|
||
$currency = strtolower(sanitize_key((string) ($args['currency'] ?? '')));
|
||
if (in_array($currency, ['cny', 'points'], true)) {
|
||
$currency = $currency === 'points' ? 'points' : 'CNY';
|
||
$where[] = 'currency = %s';
|
||
$params[] = $currency;
|
||
}
|
||
$order_status = sanitize_key((string) ($args['order_status'] ?? ''));
|
||
$valid_order_statuses = class_exists('\\AKSRT\\Core\\DomainModel')
|
||
? \AKSRT\Core\DomainModel::order_status_labels()
|
||
: [];
|
||
$orders_table = \AKSRT\Core\DBMigrator::table('order');
|
||
if ($orders_table !== '' && isset($valid_order_statuses[$order_status])) {
|
||
$where[] = "EXISTS (SELECT 1 FROM {$orders_table} AS income_order WHERE income_order.order_number = {$this->table()}.order_number AND income_order.status = %s)";
|
||
$params[] = $order_status;
|
||
}
|
||
$order_number = sanitize_text_field((string) ($args['order_number'] ?? ''));
|
||
if ($order_number !== '') {
|
||
$where[] = 'order_number LIKE %s';
|
||
$params[] = '%' . $wpdb->esc_like($order_number) . '%';
|
||
}
|
||
foreach (['date_from' => '>=', 'date_to' => '<='] as $key => $operator) {
|
||
$date = sanitize_text_field((string) ($args[$key] ?? ''));
|
||
if ($date !== '') {
|
||
$where[] = 'created_at ' . $operator . ' %s';
|
||
$params[] = strlen($date) === 10 ? ($operator === '>=' ? $date . ' 00:00:00' : $date . ' 23:59:59') : $date;
|
||
}
|
||
}
|
||
$where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
|
||
$count_sql = "SELECT COUNT(*) FROM {$this->table()} {$where_sql}";
|
||
$total = (int) ($params ? $wpdb->get_var($wpdb->prepare($count_sql, $params)) : $wpdb->get_var($count_sql));
|
||
$items_params = array_merge($params, [$limit, $offset]);
|
||
$items = $wpdb->get_results($wpdb->prepare("SELECT * FROM {$this->table()} {$where_sql} ORDER BY created_at DESC LIMIT %d OFFSET %d", $items_params), ARRAY_A);
|
||
return ['items' => $items ?: [], 'total' => $total, 'page' => (int) floor($offset / $limit) + 1, 'max_pages' => (int) ceil(max(1, $total) / $limit)];
|
||
}
|
||
|
||
/**
|
||
* Return separate cash and points totals; amounts must never be added across units.
|
||
*
|
||
* @return array{cash:array{total:float,pending:float,settled:float,count:int},points:array{total:float,pending:float,settled:float,count:int}}
|
||
*/
|
||
public function get_stats(int $user_id = 0): array {
|
||
global $wpdb;
|
||
$empty = [
|
||
'cash' => ['total' => 0.0, 'pending' => 0.0, 'settled' => 0.0, 'count' => 0],
|
||
'points' => ['total' => 0.0, 'pending' => 0.0, 'settled' => 0.0, 'count' => 0],
|
||
];
|
||
$where = ["status IN ('pending', 'settled')"];
|
||
$params = [];
|
||
if ($user_id > 0) {
|
||
$where[] = 'author_id = %d';
|
||
$params[] = $user_id;
|
||
}
|
||
$stats_sql = "SELECT currency,
|
||
COALESCE(SUM(amount), 0) total,
|
||
COALESCE(SUM(CASE WHEN status = 'pending' THEN amount ELSE 0 END), 0) pending,
|
||
COALESCE(SUM(CASE WHEN status = 'settled' THEN amount ELSE 0 END), 0) settled,
|
||
COUNT(*) count
|
||
FROM {$this->table()} WHERE " . implode(' AND ', $where) . " GROUP BY currency";
|
||
$rows = $wpdb->get_results($params ? $wpdb->prepare($stats_sql, $params) : $stats_sql, ARRAY_A);
|
||
foreach ((array) $rows as $row) {
|
||
$key = (string) ($row['currency'] ?? '') === 'points' ? 'points' : 'cash';
|
||
$empty[$key] = [
|
||
'total' => (float) ($row['total'] ?? 0),
|
||
'pending' => (float) ($row['pending'] ?? 0),
|
||
'settled' => (float) ($row['settled'] ?? 0),
|
||
'count' => (int) ($row['count'] ?? 0),
|
||
];
|
||
}
|
||
return $empty;
|
||
}
|
||
|
||
private function resolve_share_ratio(string $currency, int $author_level): float {
|
||
$settings = \AKSRT\Settings\Settings::get_payment_revenue_settings();
|
||
$bucket = $currency === 'points' ? 'points' : 'cash';
|
||
$level_key = $author_level >= 2 ? 'vip2' : ($author_level === 1 ? 'vip1' : 'ordinary');
|
||
$ratio = $settings['ratios'][$bucket][$level_key] ?? 0;
|
||
return max(0, min(100, (float) $ratio));
|
||
}
|
||
|
||
private function decode_order_meta(array $order): array {
|
||
if (is_array($order['meta_payload'] ?? null)) {
|
||
return $order['meta_payload'];
|
||
}
|
||
$decoded = json_decode((string) ($order['note'] ?? ''), true);
|
||
return is_array($decoded) ? $decoded : [];
|
||
}
|
||
}
|