feat(payment): complete creator income center

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
kate522andClaude Fable 5 committed 2026-08-07 10:59:58 +08:00
1 parent 450b9f44b9
commit 9e6ae32fc1
16 files changed
+550 -134

No files matched your search

+318 -25
View File
@@ -136,39 +136,240 @@ class Revenue {
}
/**
* Settle one pending record through its matching atomic balance service.
* 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(int $revenue_id): bool {
public function settle($record_ids, int $actor_id = 0): bool {
$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 false;
}
foreach ($ids as $revenue_id) {
if (!$this->settle_one($revenue_id, $actor_id)) {
return false;
}
}
return true;
}
/**
* Settle all due pending records in automatic mode.
*
* @return int Number of records settled.
*/
public function settle_due(int $limit = 100, int $actor_id = 0): int {
global $wpdb;
$revenue = $wpdb->get_row($wpdb->prepare(
"SELECT id, author_id, amount, currency, order_number FROM {$this->table()} WHERE id = %d AND status = %s",
$revenue_id,
'pending'
), ARRAY_A);
if (!is_array($revenue)) {
return false;
if (!$this->is_enabled()) {
return 0;
}
$settings = \AKSRT\Settings\Settings::get_payment_revenue_settings();
if (($settings['settlement_mode'] ?? 'automatic') !== 'automatic') {
return 0;
}
if ((string) $revenue['currency'] === 'points' && class_exists('\\AKSRT\\Credits\\Credits')) {
$credited = \AKSRT\Credits\Credits::add((int) $revenue['author_id'], (int) $revenue['amount'], 'creator_income', ['type' => 'revenue', 'id' => $revenue_id]);
} elseif (class_exists('\\AKSRT\\Credits\\Balance')) {
$credited = \AKSRT\Credits\Balance::add((int) $revenue['author_id'], (float) $revenue['amount'], 'creator_income', ['type' => 'revenue', 'id' => $revenue_id]);
} else {
return false;
}
if (is_wp_error($credited)) {
return false;
}
$updated = $wpdb->query($wpdb->prepare(
"UPDATE {$this->table()} SET status = %s, settled_at = %s WHERE id = %d AND status = %s",
'settled', current_time('mysql'), $revenue_id, 'pending'
$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",
'pending',
$cutoff,
$limit
));
return $updated === 1;
$settled = 0;
foreach ((array) $ids as $id) {
if ($this->settle((int) $id, $actor_id)) {
$settled++;
}
}
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');
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'];
$params = [$user_id];
$status = sanitize_key((string) ($args['status'] ?? ''));
@@ -176,16 +377,87 @@ class Revenue {
$where[] = 'status = %s';
$params[] = $status;
}
$currency = sanitize_key((string) ($args['currency'] ?? ''));
if (in_array($currency, ['CNY', 'points'], true)) {
$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 LIMIT %d OFFSET %d",
"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 = sanitize_key((string) ($args['currency'] ?? ''));
if (in_array($currency, ['CNY', 'points'], true)) {
$where .= ' AND currency = %s';
$params[] = $currency;
}
$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
FROM {$this->table()} WHERE {$where}
GROUP BY currency",
$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'] ?? ''));
$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_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)));
@@ -200,6 +472,27 @@ class Revenue {
$where[] = 'author_id = %d';
$params[] = $user_id;
}
if (($post_id = (int) ($args['post_id'] ?? 0)) > 0) {
$where[] = 'post_id = %d';
$params[] = $post_id;
}
$currency = sanitize_key((string) ($args['currency'] ?? ''));
if (in_array($currency, ['CNY', 'points'], true)) {
$where[] = 'currency = %s';
$params[] = $currency;
}
$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) : '';
$total = (int) ($params ? $wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM {$this->table()} {$where_sql}", $params)) : $wpdb->get_var("SELECT COUNT(*) FROM {$this->table()}"));
$items_params = array_merge($params, [$limit, $offset]);