feat(payment): separate creator income settlement

This commit is contained in:
kate522 committed 2026-08-07 12:39:32 +08:00
1 parent cd93ea1656
commit e20418e909
17 files changed
+451 -445

No files matched your search

+58 -120
View File
@@ -66,7 +66,7 @@ class Revenue {
$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($author_level);
$ratio = $this->resolve_share_ratio($currency, $author_level);
if ($ratio <= 0) {
return 0;
}
@@ -157,7 +157,7 @@ class Revenue {
}
/**
* Settle all due pending records in automatic mode.
* Settle all pending records whose rolling earning cycle has ended.
*
* @return int Number of records settled.
*/
@@ -168,15 +168,15 @@ class Revenue {
return 0;
}
$settings = \AKSRT\Settings\Settings::get_payment_revenue_settings();
if (($settings['settlement_mode'] ?? 'automatic') !== 'automatic') {
return 0;
}
$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));
$delay_hours = max(0, (int) ($settings['settlement_delay_hours'] ?? 24));
$cutoff = gmdate('Y-m-d H:i:s', time() - ($delay_hours * HOUR_IN_SECONDS));
$ids = $wpdb->get_col($wpdb->prepare(
"SELECT id FROM {$this->table()} WHERE status = %s AND created_at <= %s ORDER BY id ASC LIMIT %d",
"SELECT id FROM {$this->table()} WHERE status = %s AND created_at < %s ORDER BY id ASC LIMIT %d",
'pending',
$cutoff,
$limit
@@ -191,100 +191,6 @@ class Revenue {
return $settled;
}
/**
* Cancel a pending record or reverse a settled record for a refunded order.
*
* @param string|int $order_id Order number or local order id.
*/
public function cancel_or_reverse($order_id, string $reason = ''): bool {
global $wpdb;
$order_number = sanitize_text_field((string) $order_id);
if ($order_number === '') {
return false;
}
$reason = sanitize_textarea_field($reason);
$wpdb->query('START TRANSACTION');
try {
$rows = $wpdb->get_results($wpdb->prepare(
"SELECT * FROM {$this->table()} WHERE order_number = %s FOR UPDATE",
$order_number
), ARRAY_A);
if (!is_array($rows) || !$rows) {
$wpdb->query('COMMIT');
return true;
}
foreach ($rows as $row) {
$status = (string) ($row['status'] ?? '');
$id = (int) ($row['id'] ?? 0);
if (in_array($status, ['cancelled', 'reversed'], true)) {
continue;
}
if ($status === 'pending') {
$updated = $wpdb->query($wpdb->prepare(
"UPDATE {$this->table()} SET status = %s, error_message = %s, audit_json = %s WHERE id = %d AND status = %s",
'cancelled',
$reason,
$this->audit_json('cancelled', $reason),
$id,
'pending'
));
if ($updated !== 1) {
throw new \RuntimeException('revenue_cancel_failed');
}
continue;
}
if (!in_array($status, ['settled', 'pending_recovery'], true)) {
continue;
}
if ($status === 'pending_recovery') {
continue;
}
$amount = round((float) ($row['amount'] ?? 0), 2);
$user_id = (int) ($row['author_id'] ?? 0);
$ref = ['type' => 'creator_income_reversal', 'id' => $id];
$debited = (string) ($row['currency'] ?? '') === 'points'
? (class_exists('\\AKSRT\\Credits\\Credits') ? \AKSRT\Credits\Credits::deduct($user_id, (int) ceil($amount), 'creator_income_reversal', $ref, $reason) : new \WP_Error('credits_unavailable'))
: (class_exists('\\AKSRT\\Credits\\Balance') ? \AKSRT\Credits\Balance::deduct($user_id, $amount, 'creator_income_reversal', $ref, $reason) : new \WP_Error('balance_unavailable'));
if (is_wp_error($debited)) {
$updated = $wpdb->query($wpdb->prepare(
"UPDATE {$this->table()} SET status = %s, reversal_amount = %f, error_message = %s, audit_json = %s WHERE id = %d AND status = %s",
'pending_recovery',
$amount,
sanitize_textarea_field($debited->get_error_message()),
$this->audit_json('pending_recovery', $debited->get_error_message()),
$id,
'settled'
));
if ($updated !== 1) {
throw new \RuntimeException('revenue_recovery_failed');
}
continue;
}
$updated = $wpdb->query($wpdb->prepare(
"UPDATE {$this->table()} SET status = %s, reversal_amount = %f, reversed_at = %s, error_message = %s, audit_json = %s WHERE id = %d AND status = %s",
'reversed',
$amount,
current_time('mysql'),
$reason,
$this->audit_json('reversed', $reason),
$id,
'settled'
));
if ($updated !== 1) {
throw new \RuntimeException('revenue_reverse_failed');
}
}
$wpdb->query('COMMIT');
return true;
} catch (\Throwable $e) {
$wpdb->query('ROLLBACK');
return false;
}
}
private function settle_one(int $revenue_id, int $actor_id): bool {
global $wpdb;
$wpdb->query('START TRANSACTION');
@@ -370,10 +276,10 @@ class Revenue {
if ($user_id <= 0) {
return [];
}
$where = ['author_id = %d'];
$where = ["author_id = %d", "status IN ('pending', 'settled')"];
$params = [$user_id];
$status = sanitize_key((string) ($args['status'] ?? ''));
if ($status !== '') {
if (in_array($status, ['pending', 'settled'], true)) {
$where[] = 'status = %s';
$params[] = $status;
}
@@ -416,15 +322,17 @@ class Revenue {
$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) = CURDATE() THEN amount ELSE 0 END), 0) today_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",
$params
$stats_params
), ARRAY_A);
foreach ((array) $stats_row as $row) {
$key = (string) ($row['currency'] ?? '');
@@ -438,11 +346,14 @@ class Revenue {
}
$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 . ($status !== '' ? ' AND status = %s' : '');
$count_where = $where . " AND status IN ('pending', 'settled')" . ($status !== '' ? ' AND status = %s' : '');
$count_params = $params;
if ($status !== '') {
$count_params[] = $status;
@@ -462,9 +373,10 @@ class Revenue {
global $wpdb;
$limit = max(1, min(100, (int) ($args['limit'] ?? 20)));
$offset = max(0, (int) ($args['offset'] ?? 0));
$where = [];
$where = ["status IN ('pending', 'settled')"];
$params = [];
if (($status = sanitize_key((string) ($args['status'] ?? ''))) !== '') {
$status = sanitize_key((string) ($args['status'] ?? ''));
if (in_array($status, ['pending', 'settled'], true)) {
$where[] = 'status = %s';
$params[] = $status;
}
@@ -500,22 +412,48 @@ class Revenue {
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;
$where = $user_id > 0 ? 'WHERE author_id = %d' : '';
$params = $user_id > 0 ? [$user_id] : [];
$row = $params ? $wpdb->get_row($wpdb->prepare("SELECT 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}", $params), ARRAY_A) : $wpdb->get_row("SELECT 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()}", ARRAY_A);
return ['total' => (float) ($row['total'] ?? 0), 'pending' => (float) ($row['pending'] ?? 0), 'settled' => (float) ($row['settled'] ?? 0), 'count' => (int) ($row['count'] ?? 0)];
$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(int $author_level): float {
private function resolve_share_ratio(string $currency, int $author_level): float {
$settings = \AKSRT\Settings\Settings::get_payment_revenue_settings();
$ratio = $author_level >= 2 ? 80.0 : ($author_level === 1 ? 70.0 : 60.0);
$key = $author_level >= 2 ? 'ratio_vip2' : ($author_level === 1 ? 'ratio_vip1' : 'share_ratio');
if (isset($settings[$key]) && is_numeric($settings[$key])) {
$ratio = (float) $settings[$key];
}
return max(0, min(100, $ratio));
$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 {