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:
+63
-22
@@ -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) . '%';
|
||||
|
||||
Reference in New Issue
Block a user