feat(revenue): immediate commission credit with independent withdrawable pool

Aligns affiliate commission with the reference theme: commission is credited
to a dedicated COMMISSION asset pool the moment an order is paid, removing the
7-day settlement freeze, the settlement cron, and the settlement_days setting.

- Ledger: add AssetType::COMMISSION (isolated from the recharge balance wallet)
- CommissionLedger: credit-on-record + opportunistic debt repayment; refunds
  claw back the full commission from the pool, carrying any shortfall as debt
  (aksrt_commission_debt) that future commission pays down first
- Withdrawal: make asset-aware via a source discriminator (commission vs
  balance); each pool keeps independent daily limits, pending gate, and
  reject-refund. Balance withdrawal (recharge + creator income) is unchanged
- Fix pay_rebate_self_s: honor self-commission at purchase time
- Revenue card: show pool balance + 累计/已提现 (drop 待结算), debt when present
- Schema 2.0.0 -> 2.1.0: add commission_debt, drop freeze columns
This commit is contained in:
2026-09-28 21:27:44 +08:00
parent cded20ae02
commit 61667aa03c
22 changed files with 569 additions and 268 deletions
+63 -22
View File
@@ -23,10 +23,26 @@ class Withdrawal {
return self::$instance;
}
public const SOURCE_BALANCE = 'balance';
public const SOURCE_COMMISSION = 'commission';
private function table(): string {
return \AKSRT\Core\DBMigrator::table('withdrawal');
}
/**
* 提现来源 → 资产池映射。commission 走独立佣金池,其余归余额钱包。
*/
private function source_asset(string $source): \AKSRT\Ledger\AssetType {
return sanitize_key($source) === self::SOURCE_COMMISSION
? \AKSRT\Ledger\AssetType::COMMISSION
: \AKSRT\Ledger\AssetType::BALANCE;
}
private function normalize_source(string $source): string {
return sanitize_key($source) === self::SOURCE_COMMISSION ? self::SOURCE_COMMISSION : self::SOURCE_BALANCE;
}
/**
* Daily withdrawal window in the configured WordPress timezone.
* @return array{start:string,end:string}
@@ -43,14 +59,14 @@ class Withdrawal {
}
/** @return array{count:int,total:string,remaining_count:int,remaining_amount:string,window:array{start:string,end:string}} */
public function daily_usage(int $user_id, array $settings): array {
public function daily_usage(int $user_id, array $settings, string $source = self::SOURCE_BALANCE): array {
$window = $this->daily_window((string) ($settings['reset_time'] ?? '00:00'));
return $this->daily_usage_in_window($user_id, $settings, $window);
return $this->daily_usage_in_window($user_id, $settings, $window, $source);
}
/** @return array{count:int,total:string,remaining_count:int,remaining_amount:string,window:array{start:string,end:string}} */
private function daily_usage_in_window(int $user_id, array $settings, array $window): array {
$usage = $this->daily_usage_minor_in_window($user_id, $settings, $window);
private function daily_usage_in_window(int $user_id, array $settings, array $window, string $source = self::SOURCE_BALANCE): array {
$usage = $this->daily_usage_minor_in_window($user_id, $settings, $window, false, $source);
return [
'count' => $usage['count'],
'total' => $this->balance_decimal($usage['total_minor']),
@@ -61,13 +77,16 @@ class Withdrawal {
}
/** @return array{count:int,total_minor:int,remaining_count:int,remaining_minor:int,window:array{start:string,end:string}} */
private function daily_usage_minor_in_window(int $user_id, array $settings, array $window, bool $lock = false): array {
private function daily_usage_minor_in_window(int $user_id, array $settings, array $window, bool $lock = false, string $source = self::SOURCE_BALANCE): array {
global $wpdb;
// 每个资金池(source)独立计每日次数/额度:按 account_info 里的 source 标记过滤。
$source_like = '%' . $wpdb->esc_like('"source":"' . $this->normalize_source($source) . '"') . '%';
$sql = $wpdb->prepare(
"SELECT id, gross_amount, amount FROM {$this->table()} WHERE user_id = %d AND created_at >= %s AND created_at < %s ORDER BY id ASC" . ($lock ? ' FOR UPDATE' : ''),
"SELECT id, gross_amount, amount FROM {$this->table()} WHERE user_id = %d AND created_at >= %s AND created_at < %s AND account_info LIKE %s ORDER BY id ASC" . ($lock ? ' FOR UPDATE' : ''),
$user_id,
$window['start'],
$window['end']
$window['end'],
$source_like
);
$rows = (array) $wpdb->get_results($sql, ARRAY_A);
$total_minor = 0;
@@ -101,11 +120,13 @@ class Withdrawal {
* Atomically validates quotas, freezes wallet funds, writes the ledger and creates an order.
* @return array{withdrawal_id:int,withdrawal_number:string,net_amount:string,wallet_balance:string,transaction_id:int,flow_number:string,replayed:bool,usage:array}|\WP_Error
*/
public function create_balance_request(int $user_id, int $gross_minor, string $method, string $account, array $settings, string $operation_key) {
public function create_balance_request(int $user_id, int $gross_minor, string $method, string $account, array $settings, string $operation_key, string $source = self::SOURCE_BALANCE) {
global $wpdb;
if ($user_id <= 0 || $gross_minor <= 0) {
return new \WP_Error('aksrt_withdraw_amount', __('提现金额必须大于零', 'aksrt'), ['status' => 400]);
}
$source = $this->normalize_source($source);
$asset = $this->source_asset($source);
$fee_rate = max(0, min(50, (int) ($settings['service_charge_rate'] ?? 5)));
$fee_minor = (intdiv($gross_minor, 100) * $fee_rate) + intdiv(($gross_minor % 100) * $fee_rate, 100);
$net_minor = $gross_minor - $fee_minor;
@@ -116,20 +137,24 @@ class Withdrawal {
$usage = null;
$withdrawal_id = 0;
$withdrawal_number = '';
$guard = function () use ($user_id, $gross_minor, $settings, $window, &$usage) {
$source_like = '%' . $wpdb->esc_like('"source":"' . $source . '"') . '%';
$guard = function () use ($user_id, $gross_minor, $settings, $window, $source, $source_like, &$usage) {
global $wpdb;
// 每个资金池独立的「已有待处理申请」闸门:只锁定同 source 的 pending/processing 行。
$wpdb->get_results($wpdb->prepare(
"SELECT id FROM {$this->table()} WHERE user_id = %d AND status IN ('pending','processing') ORDER BY id ASC FOR UPDATE",
$user_id
"SELECT id FROM {$this->table()} WHERE user_id = %d AND status IN ('pending','processing') AND account_info LIKE %s ORDER BY id ASC FOR UPDATE",
$user_id,
$source_like
));
$open = (int) $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(*) FROM {$this->table()} WHERE user_id = %d AND status IN ('pending','processing')",
$user_id
"SELECT COUNT(*) FROM {$this->table()} WHERE user_id = %d AND status IN ('pending','processing') AND account_info LIKE %s",
$user_id,
$source_like
));
if ($open > 0) {
return new \WP_Error('withdraw_pending', __('已有待处理提现申请', 'aksrt'), ['status' => 409]);
}
$usage = $this->daily_usage_minor_in_window($user_id, $settings, $window, true);
$usage = $this->daily_usage_minor_in_window($user_id, $settings, $window, true, $source);
if ($usage['remaining_count'] <= 0) {
return new \WP_Error('withdraw_count_limit', __('当日提现次数已用尽', 'aksrt'), ['status' => 409]);
}
@@ -138,13 +163,13 @@ class Withdrawal {
}
return true;
};
$mutation = function (int $transaction_id) use ($user_id, $gross_minor, $net_minor, $fee_rate, $method, $account, $operation_key, &$withdrawal_id, &$withdrawal_number) {
$mutation = function (int $transaction_id) use ($user_id, $gross_minor, $net_minor, $fee_rate, $method, $account, $operation_key, $source, &$withdrawal_id, &$withdrawal_number) {
global $wpdb;
$payload = wp_json_encode([
'account' => $account,
'gross_amount' => \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, $gross_minor)->decimal(),
'fee_rate' => $fee_rate,
'source' => 'balance',
'source' => $source,
'operation_key' => $operation_key,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: $account;
$ok = false;
@@ -173,14 +198,14 @@ class Withdrawal {
};
$result = \AKSRT\Ledger\LedgerService::get_instance()->debit(
$user_id,
\AKSRT\Ledger\AssetType::BALANCE,
$asset,
$gross_minor,
$operation_key,
'withdraw_hold',
['type' => 'withdraw', 'key' => $operation_key],
__('余额提现申请冻结', 'aksrt'),
$source === self::SOURCE_COMMISSION ? __('推广佣金提现申请冻结', 'aksrt') : __('余额提现申请冻结', 'aksrt'),
$user_id,
['fee_minor' => $fee_minor, 'net_minor' => $net_minor],
['fee_minor' => $fee_minor, 'net_minor' => $net_minor, 'source' => $source],
$mutation,
$guard
);
@@ -214,7 +239,8 @@ class Withdrawal {
'withdrawal_number' => $withdrawal_number,
'net_amount' => \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, $net_minor)->decimal(),
'wallet_balance' => \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, $result->balance($user_id, \AKSRT\Ledger\AssetType::BALANCE))->decimal(),
'source' => $source,
'wallet_balance' => \AKSRT\Ledger\AssetAmount::from_minor($asset, $result->balance($user_id, $asset))->decimal(),
'transaction_id' => $result->transaction_id,
'flow_number' => $result->flow_number,
'replayed' => $result->replayed,
@@ -388,7 +414,9 @@ class Withdrawal {
: new \WP_Error('aksrt_withdraw_update', __('提现状态更新失败', 'aksrt'), ['status' => 500]);
};
if ($new_status === self::STATUS_REJECTED && $this->is_balance_source($meta) && $user_id > 0) {
// 拒绝提现时把冻结毛额退回原始资金池(balance→余额钱包,commission→佣金池)。
$refund_source = $this->normalize_source((string) ($meta['source'] ?? self::SOURCE_BALANCE));
if ($new_status === self::STATUS_REJECTED && $user_id > 0) {
try {
$gross_minor = \AKSRT\Ledger\AssetAmount::balance((string) ($row['gross_amount'] ?? ''))->minor();
} catch (\InvalidArgumentException $e) {
@@ -399,7 +427,7 @@ class Withdrawal {
}
$ledger_result = \AKSRT\Ledger\LedgerService::get_instance()->credit(
$user_id,
\AKSRT\Ledger\AssetType::BALANCE,
$this->source_asset($refund_source),
$gross_minor,
$operation_key !== '' ? $operation_key : \AKSRT\Ledger\OperationKey::deterministic('withdraw-refund', [$withdrawal_id]),
'withdraw_refund',
@@ -549,6 +577,13 @@ class Withdrawal {
}
}
// 按资金池来源过滤:收益页只看佣金提现,余额页只看余额提现。
$source = sanitize_key((string) ($args['source'] ?? ''));
if ($source === self::SOURCE_BALANCE || $source === self::SOURCE_COMMISSION) {
$where[] = 'account_info LIKE %s';
$params[] = '%' . $wpdb->esc_like('"source":"' . $source . '"') . '%';
}
$limit = max(1, min(100, (int) ($args['limit'] ?? 20)));
$offset = max(0, (int) ($args['offset'] ?? 0));
@@ -608,6 +643,12 @@ class Withdrawal {
$where[] = 'user_id = %d';
$params[] = $user_id;
}
// 资金池来源过滤:commission / balance 各自独立统计。
$source = sanitize_key((string) ($args['source'] ?? ''));
if (in_array($source, [self::SOURCE_BALANCE, self::SOURCE_COMMISSION], true)) {
$where[] = 'account_info LIKE %s';
$params[] = '%' . $wpdb->esc_like('"source":"' . $source . '"') . '%';
}
$keyword = trim(sanitize_text_field((string) ($args['keyword'] ?? '')));
if ($keyword !== '') {
$like = '%' . $wpdb->esc_like($keyword) . '%';