diff --git a/assets/css/routes/user-center.css b/assets/css/routes/user-center.css index 1a687e10..909fe89e 100644 --- a/assets/css/routes/user-center.css +++ b/assets/css/routes/user-center.css @@ -3,7 +3,6 @@ @import url("user-center/vip.css"); @import url("user-center/level.css"); @import url("user-center/settings.css"); -@import url("revenue.css"); @import url("user-center/revenue.css"); @import url("user-center/revenue-earnings.css"); @import url("user-center/responsive.css"); diff --git a/assets/css/routes/user-center/revenue-earnings.css b/assets/css/routes/user-center/revenue-earnings.css index 4d5bc9de..c7ff5643 100644 --- a/assets/css/routes/user-center/revenue-earnings.css +++ b/assets/css/routes/user-center/revenue-earnings.css @@ -27,12 +27,16 @@ } .uc-content .uc-commission-summary { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; } .uc-content .uc-commission-card { + position: relative; min-height: 116px; padding: 16px 22px; + overflow: hidden; border: 0; border-radius: 18px; box-shadow: 0 12px 28px rgba(15, 23, 42, .08); @@ -56,7 +60,10 @@ .uc-content .uc-commission-card__body { display: grid; + position: relative; + z-index: 1; align-content: space-between; + gap: 8px; min-height: 84px; } @@ -69,29 +76,73 @@ .uc-content .uc-commission-card__body > span:first-child { margin: 0; - font-size: 18px; - font-weight: 700; + font-size: 15px; + opacity: .85; } .uc-content .uc-commission-card__body strong { - font-size: 32px; + font-size: 34px; + font-weight: 700; line-height: 1; } .uc-content .uc-commission-card__body strong small { margin-left: 4px; font-size: 15px; + font-weight: 400; +} + +.uc-content .uc-commission-card--ratio .uc-commission-card__body strong small { + margin-left: 6px; + font-size: 20px; } -.uc-content .uc-commission-card__body > span:last-child, .uc-content .uc-commission-card__body > p { margin: 0; - font-size: 14px; - opacity: .92; + font-size: 13px; + opacity: .9; } -.uc-content .uc-commission-card--ratio .uc-commission-card__body strong { - color: inherit; +/* 我的佣金:累计 / 已提现(含欠款)副信息。 */ +.uc-content .uc-commission-card__meta { + display: flex; + flex-wrap: wrap; + gap: 4px 16px; +} + +/* 待偿欠款:退款收回不足挂账,副信息里以描边标签弱提示。 */ +.uc-content .uc-commission-card__debt { + padding: 0 8px; + border-radius: 999px; + background: rgba(255, 255, 255, .22); +} + +/* 立即提现按钮:贴右居中,对齐参考主题 .abs-right。 */ +.uc-content .uc-commission-card__action { + position: absolute; + top: 50%; + right: 16px; + z-index: 2; + display: inline-flex; + align-items: center; + gap: 4px; + padding: 7px 14px; + transform: translateY(-50%); + border: 0; + border-radius: 999px; + color: #fff; + background: rgba(255, 255, 255, .22); + font-size: 13px; + cursor: pointer; +} + +.uc-content .uc-commission-card__action:hover { + background: rgba(255, 255, 255, .32); +} + +/* 提现按钮所在卡片:正文右侧预留空间,避免与按钮重叠。 */ +.uc-content .uc-commission-card--balance:has(.uc-commission-card__action) .uc-commission-card__body { + padding-right: 96px; } /* 三 Tab 推广收益:佣金详细页采用紧凑的一列信息清单。 */ diff --git a/assets/css/routes/user-center/revenue.css b/assets/css/routes/user-center/revenue.css index 80e7f41e..bc8139ba 100644 --- a/assets/css/routes/user-center/revenue.css +++ b/assets/css/routes/user-center/revenue.css @@ -85,6 +85,12 @@ gap: 16px; } +/* 三个 Tab 面板首屏全部直出,靠 hidden 属性做本地切换; + * display:grid 会覆盖 [hidden] 的 display:none,必须显式复位。 */ +.uc-reward-pane[hidden] { + display: none; +} + .uc-reward-pane--detail { grid-template-columns: repeat(2, minmax(0, 1fr)); } diff --git a/assets/js/credits.js b/assets/js/credits.js index 9572507f..218fce17 100644 --- a/assets/js/credits.js +++ b/assets/js/credits.js @@ -934,8 +934,24 @@ modal.dataset.aksrtWithdrawInitialized = '1'; var form = modal.querySelector('[data-aksrt-withdraw-form]'); var busy = false; + // 提现来源随打开按钮传入:commission 走佣金池,其余走余额钱包。 + // 每个来源的每日次数/额度不同,按钮上带了各自的 min/remaining, + // 打开时覆盖到金额输入框,提交时把 source 一并回传。 document.querySelectorAll('[data-aksrt-withdraw-open]').forEach(function (btn) { - btn.addEventListener('click', function () { openModal(modal); }); + btn.addEventListener('click', function () { + var src = btn.getAttribute('data-aksrt-withdraw-source') || 'balance'; + modal.dataset.aksrtWithdrawSource = src === 'commission' ? 'commission' : 'balance'; + var amountEl = form ? form.querySelector('[data-aksrt-withdraw-amount]') : null; + if (amountEl) { + var min = btn.getAttribute('data-withdraw-min'); + var remAmt = btn.getAttribute('data-withdraw-remaining-amount'); + var remCnt = btn.getAttribute('data-withdraw-remaining-count'); + if (min !== null) amountEl.setAttribute('data-withdraw-min', min); + if (remAmt !== null) amountEl.setAttribute('data-withdraw-remaining-amount', remAmt); + if (remCnt !== null) amountEl.setAttribute('data-withdraw-remaining-count', remCnt); + } + openModal(modal); + }); }); modal.querySelectorAll('[data-aksrt-withdraw-close]').forEach(function (btn) { btn.addEventListener('click', function () { closeModal(modal); }); @@ -978,10 +994,11 @@ } busy = true; notyf('正在提交提现申请…', 'load', '', 'aksrt-balance-withdraw-toast'); + var source = modal.dataset.aksrtWithdrawSource === 'commission' ? 'commission' : 'balance'; api('/earnings/withdrawals', { method: 'POST', headers: { 'Idempotency-Key': operationKey(form) }, - body: JSON.stringify({ amount: amount, method: method, account: account }) + body: JSON.stringify({ amount: amount, method: method, account: account, source: source }) }).then(function (r) { busy = false; if (!r.ok || (r.json && r.json.code && !r.json.success)) { @@ -994,11 +1011,12 @@ if (data && data.withdrawal_number) successMessage += '(' + data.withdrawal_number + ')'; notyf(successMessage, 'success', 5, 'aksrt-balance-withdraw-toast'); closeModal(modal); - var panel = modal.closest ? modal.closest('.dashboard-panel') : null; - panel = panel || document.getElementById('panel-credits'); + // 佣金提现刷新推广收益面板,余额提现刷新余额面板;各自拉取对应片段。 + var panelName = source === 'commission' ? 'revenue' : 'credits'; + var panel = document.getElementById('panel-' + panelName) || (modal.closest ? modal.closest('.dashboard-panel') : null); var rest = (window.aksrtData && (window.aksrtData.restUrl || window.aksrtData.apiUrl)) || (window.aksrtUserCenter && window.aksrtUserCenter.restUrl) || ''; if (panel && rest) { - fetch(rest.replace(/\/$/, '') + '/user-center/panel/credits', { credentials: 'same-origin', headers: { 'X-WP-Nonce': (window.aksrtData && (window.aksrtData.restNonce || window.aksrtData.nonce)) || '' } }) + fetch(rest.replace(/\/$/, '') + '/user-center/panel/' + panelName, { credentials: 'same-origin', headers: { 'X-WP-Nonce': (window.aksrtData && (window.aksrtData.restNonce || window.aksrtData.nonce)) || '' } }) .then(function (response) { return response.json(); }) .then(function (payload) { if (!payload || !payload.success || !payload.data || typeof payload.data.html !== 'string') return; diff --git a/assets/js/routes/user-center.js b/assets/js/routes/user-center.js index 1f602431..dcebd010 100644 --- a/assets/js/routes/user-center.js +++ b/assets/js/routes/user-center.js @@ -158,6 +158,26 @@ return true; } + /* 推广收益三个 Tab 的内容在首屏已由 PHP 全部直出,切换只需本地切换 + * 显隐,无需任何网络往返,消除切换阻塞。分页仍走 loadRevenueTab。 */ + function toggleRevenueTab(tabName) { + var target = document.getElementById('panel-revenue'); + if (!target) return false; + var pane = target.querySelector('[data-reward-pane="' + tabName + '"]'); + if (!pane) return false; + target.querySelectorAll('[data-reward-pane]').forEach(function(node) { + var active = node === pane; + node.hidden = !active; + }); + target.querySelectorAll('.uc-reward-tabs a[data-reward-tab]').forEach(function(link) { + var active = link.getAttribute('data-reward-tab') === tabName; + link.classList.toggle('is-active', active); + if (active) link.setAttribute('aria-current', 'page'); + else link.removeAttribute('aria-current'); + }); + return true; + } + function loadRevenueTab(queryParams) { var target = document.getElementById('panel-revenue'); var restUrl = (window.aksrtData && (window.aksrtData.restUrl || window.aksrtData.apiUrl)) || cfg.restUrl || ''; @@ -260,6 +280,13 @@ if (link.closest('.uc-reward-tabs, .uc-reward-pagination')) { e.preventDefault(); e.stopPropagation(); + var rewardTab = link.getAttribute('data-reward-tab'); + /* Tab 切换:内容已全部直出,纯本地切换显隐,零网络往返。 */ + if (rewardTab && toggleRevenueTab(rewardTab)) { + if (window.history.replaceState) window.history.replaceState({}, '', '/user/revenue/'); + return; + } + /* 分页:需要新一页数据,仍走 REST 片段加载。 */ loadRevenueTab(url.searchParams).then(function() { if (window.history.replaceState) window.history.replaceState({}, '', '/user/revenue/'); if (typeof window.AKSRTAffiliateRevenueInit === 'function') window.AKSRTAffiliateRevenueInit(); diff --git a/inc/helpers/user-center-renderers/profile-revenue.php b/inc/helpers/user-center-renderers/profile-revenue.php index a990627b..23bc2ad5 100644 --- a/inc/helpers/user-center-renderers/profile-revenue.php +++ b/inc/helpers/user-center-renderers/profile-revenue.php @@ -43,8 +43,12 @@ $withdraw_min = $normalize_balance($withdrawal['min_amount'] ?? '50')->decimal() $commission_stats = class_exists('\AKSRT\Payment\CommissionLedger') ? \AKSRT\Payment\CommissionLedger::get_instance()->overview($user_id) - : ['total' => 0, 'pending' => 0, 'settled' => 0, 'wallet' => 0]; -$pending_commission = $normalize_balance($commission_stats['pending'] ?? '0')->decimal(); + : ['total' => 0, 'pool' => 0, 'withdrawn' => 0, 'debt' => 0]; +/* 佣金池提现的每日次数/额度:与余额提现相互独立,按 commission source 计。 */ +$commission_withdraw_usage = ['remaining_count' => 0, 'remaining_amount' => '0.00']; +if ($withdraw_on && class_exists('\AKSRT\Payment\Withdrawal')) { + $commission_withdraw_usage = \AKSRT\Payment\Withdrawal::get_instance()->daily_usage($user_id, $withdrawal, \AKSRT\Payment\Withdrawal::SOURCE_COMMISSION); +} $commission_ratio = class_exists('\AKSRT\Settings\Settings') ? (int) \AKSRT\Settings\Settings::affiliate_ratio_for_user($user_id) : 0; @@ -109,11 +113,24 @@ if ($commission_page > $commission_total_pages) { } $commission_items = (array) ($commission_view['items'] ?? []); $status_labels = [ - 'pending' => __('待结算', 'aksrt'), - 'settled' => __('已结算', 'aksrt'), - 'cancelled' => __('已取消', 'aksrt'), + 'credited' => __('已入账', 'aksrt'), + 'cancelled' => __('已退款', 'aksrt'), ]; ?> +
@@ -127,9 +144,16 @@ $status_labels = [
- ¥ - + ¥ +

+ + + +

+ + +
@@ -139,61 +163,59 @@ $status_labels = [
- -
-
- - -
%
-
-
- +
> +
+ + +
%
+
+
+
- -
-

- -
- display_name : __('推广用户', 'aksrt'); - ?> - -
- -
- 1) : ?> -

-
- -
-

- -
- -
- -
- 1) : ?> -

-
+
+ + +
> +

+ +
+ display_name : __('推广用户', 'aksrt'); + $commission_minor = $signed_balance_minor($item['original_amount'] ?? '0') + $signed_balance_minor($item['adjustment_total'] ?? '0'); + $commission_amount = \AKSRT\Ledger\AssetAmount::decimal_from_minor(max(0, $commission_minor)); + ?> +
+ +
+ 1) : ?> +

+
+ +
> +

+ +
+ +
+ +
+ 1) : ?> +

+
'pay_rebate_ratio_vip_2', 'label' => '二级会员返佣比例(%)', 'type' => 'number', 'default' => 20, 'min' => 0, 'max' => 100, 'dependsOn' => ['key' => 'pay_rebate_s', 'value' => true]], ]], ]], - ['key' => 'affiliate_settlement_days', 'label' => '结算观察期(天)', 'type' => 'number', 'default' => 7, 'min' => 0, 'max' => 90, 'dependsOn' => ['key' => 'pay_rebate_s', 'value' => true], 'help' => '订单完成后经过观察期,佣金才会结算到可提现余额'], ['key' => 'affiliate_disabled_code_stops_commission', 'label' => '推广码禁用后停止返佣', 'type' => 'switch', 'default' => false, 'dependsOn' => ['key' => 'pay_rebate_s', 'value' => true], 'help' => '开启后,推广者的推广码被禁用(或缺失)时,其名下已绑定用户的新订单不再产生佣金;关闭则绑定关系继续生效'], ]], ['key' => 'text', 'title' => '返佣文案', 'fields' => [ diff --git a/src/Core/DBMigrator.php b/src/Core/DBMigrator.php index a419cd1d..d85c6af1 100644 --- a/src/Core/DBMigrator.php +++ b/src/Core/DBMigrator.php @@ -14,7 +14,7 @@ class DBMigrator { use DBMigratorVip; /** Current schema version; changing it re-runs dbDelta. */ - const SCHEMA_VERSION = '2.0.0'; + const SCHEMA_VERSION = '2.1.0'; const OPT_KEY = 'aksrt_db_schema_version'; diff --git a/src/Core/DBMigratorPart2.php b/src/Core/DBMigratorPart2.php index b9952fb9..5022f034 100644 --- a/src/Core/DBMigratorPart2.php +++ b/src/Core/DBMigratorPart2.php @@ -435,6 +435,8 @@ trait DBMigratorPart2 { dbDelta($sql_affiliate_relation); // ── immutable commission ledger ── + // 佣金即时入账(无冻结/结算期):下单支付即 credit 到 COMMISSION 资金池, + // 本表仅作佣金明细账 + 退款冲正的溯源记录。status: credited | cancelled。 $sql_commission_ledger = "CREATE TABLE {$prefix}aksrt_commission_ledger ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, promoter_id BIGINT UNSIGNED NOT NULL, @@ -445,12 +447,10 @@ trait DBMigratorPart2 { ratio DECIMAL(8,4) NOT NULL DEFAULT 0, original_amount DECIMAL(18,2) NOT NULL DEFAULT 0, adjustment_total DECIMAL(18,2) NOT NULL DEFAULT 0, - status VARCHAR(20) NOT NULL DEFAULT 'pending', + status VARCHAR(20) NOT NULL DEFAULT 'credited', idempotency_key VARCHAR(128) NOT NULL, cancel_operation_key VARCHAR(191) DEFAULT NULL, cancel_payload_hash CHAR(64) DEFAULT NULL, - available_at DATETIME NOT NULL, - settled_at DATETIME DEFAULT NULL, created_at DATETIME NOT NULL, note TEXT DEFAULT NULL, PRIMARY KEY (id), @@ -458,11 +458,20 @@ trait DBMigratorPart2 { UNIQUE KEY uniq_commission_cancel_operation (cancel_operation_key), KEY idx_promoter_status (promoter_id, status), KEY idx_order_type (order_number, order_type), - KEY idx_available (status, available_at), KEY idx_created (created_at) ) ENGINE=InnoDB {$charset_collate};"; dbDelta($sql_commission_ledger); + // 佣金欠款挂账:退款全额收回后,若佣金池不足产生的差额记为欠款, + // 后续新增佣金先抵扣欠款再入池。每用户一行。 + $sql_commission_debt = "CREATE TABLE {$prefix}aksrt_commission_debt ( + user_id BIGINT UNSIGNED NOT NULL, + debt_minor BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL, + PRIMARY KEY (user_id) + ) ENGINE=InnoDB {$charset_collate};"; + dbDelta($sql_commission_debt); + // ── immutable commission adjustments (refund/debt/manual) ── $sql_commission_adjustment = "CREATE TABLE {$prefix}aksrt_commission_adjustment ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, diff --git a/src/Core/DBMigratorPart3.php b/src/Core/DBMigratorPart3.php index 04f9dc15..9b89e4b1 100644 --- a/src/Core/DBMigratorPart3.php +++ b/src/Core/DBMigratorPart3.php @@ -37,6 +37,7 @@ trait DBMigratorPart3 { 'affiliate_relation' => 'aksrt_affiliate_relation', 'commission_ledger' => 'aksrt_commission_ledger', 'commission_adjustment' => 'aksrt_commission_adjustment', + 'commission_debt' => 'aksrt_commission_debt', 'payout_account' => 'aksrt_payout_account', 'coupon_usage' => 'aksrt_coupon_usage', 'user_restriction' => 'aksrt_user_restriction', diff --git a/src/Ledger/AssetAmount.php b/src/Ledger/AssetAmount.php index f13e4a2d..76ba2a8a 100644 --- a/src/Ledger/AssetAmount.php +++ b/src/Ledger/AssetAmount.php @@ -37,6 +37,13 @@ final class AssetAmount { return self::balance($decimal)->minor(); } + /** + * 佣金池与余额同为两位小数、同一 minor 口径,仅资产标签不同。 + */ + public static function commission(string $decimal): self { + return new self(AssetType::COMMISSION, self::balance($decimal)->minor()); + } + public static function decimal_from_minor(int $minor): string { return self::from_minor(AssetType::BALANCE, $minor)->decimal(); } diff --git a/src/Ledger/AssetType.php b/src/Ledger/AssetType.php index 9e9394c1..37fb1793 100644 --- a/src/Ledger/AssetType.php +++ b/src/Ledger/AssetType.php @@ -10,8 +10,14 @@ defined('ABSPATH') || exit; enum AssetType: string { case POINTS = 'points'; case BALANCE = 'balance'; + case COMMISSION = 'commission'; public static function from_value(self|string $asset): self { return $asset instanceof self ? $asset : self::from($asset); } + + /** 是否为两位小数的货币型资产(非积分)。 */ + public function is_currency(): bool { + return $this !== self::POINTS; + } } diff --git a/src/Ledger/OperationKey.php b/src/Ledger/OperationKey.php index 363c001b..618d988c 100644 --- a/src/Ledger/OperationKey.php +++ b/src/Ledger/OperationKey.php @@ -21,6 +21,14 @@ final class OperationKey { return $key; } + /** + * 生成一次性唯一 operation_key。用于自身已带并发守卫、且每次执行金额可能不同、 + * 因而不能被幂等重放的账务操作(如机会性偿债、退款分次收回)。 + */ + public static function unique(string $scope): string { + return self::deterministic($scope, [wp_generate_uuid4()]); + } + /** @return string|\WP_Error */ public static function from_request(\WP_REST_Request $request, string $scope, int $actor_user_id) { $request_id = trim((string) $request->get_header('Idempotency-Key')); diff --git a/src/Payment/BalanceGateway.php b/src/Payment/BalanceGateway.php index 5cf87d4e..e5f37e92 100644 --- a/src/Payment/BalanceGateway.php +++ b/src/Payment/BalanceGateway.php @@ -234,6 +234,10 @@ class BalanceGateway implements GatewayInterface { } return $commission->apply_full_refund($commission_plan); }; + // 只退买家余额。佣金收回不再作为分录混入本事务:即时入账模型下佣金在 + // 独立 COMMISSION 池,收回可能超出池余额(转为欠款),若作为分录会因池 + // 不足而中止买家退款。故 apply_full_refund 仅登记佣金明细账的冲正,真正 + // 的池收回 + 欠款挂账由 clawback_after_refund 在本事务提交后独立执行。 $entries = [[ 'user_id' => $user_id, 'asset' => \AKSRT\Ledger\AssetType::BALANCE->value, @@ -241,16 +245,6 @@ class BalanceGateway implements GatewayInterface { 'entry_type' => 'refund', 'remark' => sprintf(__('订单 %s 退款', 'aksrt'), $order_number), ]]; - if (!empty($commission_plan['active']) && (string) ($commission_plan['status'] ?? '') === 'settled') { - $entries[] = [ - 'user_id' => (int) $commission_plan['promoter_id'], - 'asset' => \AKSRT\Ledger\AssetType::BALANCE->value, - 'delta_minor' => -(int) $commission_plan['amount_minor'], - 'entry_type' => 'commission_reversal', - 'counterparty_user_id' => $user_id, - 'remark' => sprintf(__('订单 %s 返佣退款冲正', 'aksrt'), $order_number), - ]; - } $result = \AKSRT\Ledger\LedgerService::get_instance()->post(new \AKSRT\Ledger\LedgerCommand( $operation_key, 'order_refund', @@ -263,6 +257,10 @@ class BalanceGateway implements GatewayInterface { if (is_wp_error($result)) { return ['success' => false, 'message' => $result->get_error_message() ?: __('退款失败', 'aksrt')]; } + // 退款事务已提交:从佣金池收回本单佣金,不足部分挂账为欠款(非关键路径)。 + if (!$result->replayed) { + $commission->clawback_after_refund($commission_plan); + } return [ 'success' => true, 'refund_id' => 'BAL_REF_' . $order_number, diff --git a/src/Payment/CommissionLedger.php b/src/Payment/CommissionLedger.php index 05e6356e..fc0da93e 100644 --- a/src/Payment/CommissionLedger.php +++ b/src/Payment/CommissionLedger.php @@ -9,10 +9,11 @@ final class CommissionLedger { public static function get_instance(): self { return self::$instance ??= new self(); } private function __construct() {} public function init(): void { - add_action('aksrt_affiliate_settle_due', [$this, 'settle_due']); add_action('rest_api_init', [$this, 'register_routes']); - if (!wp_next_scheduled('aksrt_affiliate_settle_due')) { - wp_schedule_event(time() + HOUR_IN_SECONDS, 'hourly', 'aksrt_affiliate_settle_due'); + // 佣金已改为即时入账,历史遗留的结算 cron 需主动清理(无兼容层)。 + $timestamp = wp_next_scheduled('aksrt_affiliate_settle_due'); + if ($timestamp) { + wp_unschedule_event($timestamp, 'aksrt_affiliate_settle_due'); } } private function table(string $name): string { return \AKSRT\Core\DBMigrator::table($name); } @@ -56,6 +57,14 @@ final class CommissionLedger { } if ($order_no === '' || $buyer <= 0 || $amount->minor() <= 0) return 0; $promoter = ReferralAttribution::get_instance()->resolve($buyer); + // 自我返佣(参考主题「自己推广自己」,购买时归因):无外部推广人且开关开启时, + // 买家用自己的(有效)推广码即成为自身推广人。resolve() 结构上永不返回自身, + // 故这里显式补齐,且要求买家推广码有效以复用「禁用即停返佣」的口径。 + if ($promoter <= 0 + && !empty($settings['self_rebate']) + && ReferralAttribution::get_instance()->is_promoter_code_active($buyer)) { + $promoter = $buyer; + } if ($promoter <= 0) return 0; // 开关开启时,推广者的推广码已被禁用(或缺失)则不再产生新佣金。 if (!empty($settings['disabled_code_stops_commission']) @@ -66,10 +75,9 @@ final class CommissionLedger { $promoter_level = function_exists('aksrt_get_user_vip_level') ? (int) aksrt_get_user_vip_level($promoter) : 0; if (!in_array($order_type, \AKSRT\Settings\PaymentRuntime::affiliate_order_types_for_level($promoter_level), true)) return 0; $ratio = \AKSRT\Settings\PaymentRuntime::affiliate_ratio_for_user($promoter); - $commission_minor = (intdiv($amount->minor(), 100) * $ratio) - + intdiv((($amount->minor() % 100) * $ratio) + 50, 100); + $commission_minor = \AKSRT\Ledger\AssetAmount::percentage($amount->minor(), $ratio); if ($commission_minor <= 0) return 0; - $commission = \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, $commission_minor)->decimal(); + $commission = \AKSRT\Ledger\AssetAmount::commission_from_minor($commission_minor)->decimal(); global $wpdb; $table = $this->table('commission_ledger'); $identity = $order_type === 'credits' && !empty($order['exchange_ref']) @@ -77,10 +85,13 @@ final class CommissionLedger { : $order_no . ':' . $order_type; $key = hash('sha256', 'affiliate:' . $identity); $now = current_time('mysql', true); - $available = gmdate('Y-m-d H:i:s', time() + ((int) $settings['settlement_days'] * DAY_IN_SECONDS)); - $ok = $wpdb->insert($table, ['promoter_id'=>$promoter,'source_user_id'=>$buyer,'order_number'=>$order_no,'order_type'=>$order_type,'eligible_base'=>$amount->decimal(),'ratio'=>$ratio,'original_amount'=>$commission,'adjustment_total'=>'0.00','status'=>'pending','idempotency_key'=>$key,'available_at'=>$available,'created_at'=>$now], ['%d','%d','%s','%s','%s','%d','%s','%s','%s','%s','%s','%s']); + // 佣金明细账先落库(status=credited),作为入账溯源;随后把佣金即时 + // credit 到 COMMISSION 资金池(先抵扣欠款)。两步分别幂等。 + $ok = $wpdb->insert($table, ['promoter_id'=>$promoter,'source_user_id'=>$buyer,'order_number'=>$order_no,'order_type'=>$order_type,'eligible_base'=>$amount->decimal(),'ratio'=>$ratio,'original_amount'=>$commission,'adjustment_total'=>'0.00','status'=>'credited','idempotency_key'=>$key,'created_at'=>$now], ['%d','%d','%s','%s','%s','%d','%s','%s','%s','%s','%s']); if ($ok) { - return (int) $wpdb->insert_id; + $ledger_id = (int) $wpdb->insert_id; + $this->credit_commission($promoter, $commission_minor, $ledger_id, $order_no); + return $ledger_id; } $existing = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$table} WHERE idempotency_key=%s", $key), ARRAY_A); if (!is_array($existing) @@ -94,56 +105,85 @@ final class CommissionLedger { ) { return -1; } + // 明细行已存在(订单支付回调重放):入账用确定性 operation_key 幂等,安全重放。 + $this->credit_commission($promoter, $commission_minor, (int) $existing['id'], $order_no); return (int) $existing['id']; } - public function settle_due(int $limit = 100): int { - global $wpdb; $table = $this->table('commission_ledger'); - $rows = $wpdb->get_results($wpdb->prepare("SELECT * FROM {$table} WHERE status='pending' AND available_at <= %s ORDER BY id ASC LIMIT %d", current_time('mysql', true), max(1,min(500,$limit))), ARRAY_A); - $count = 0; - foreach ((array) $rows as $row) if ($this->settle_one((int)$row['id'])) $count++; - return $count; - } - private function settle_one(int $id, string $operation_key = '') { - global $wpdb; $table = $this->table('commission_ledger'); - $row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$table} WHERE id=%d", $id), ARRAY_A); - if (!is_array($row)) return false; - try { - $original_minor = $this->signed_balance_minor((string) ($row['original_amount'] ?? '0')); - $adjustment_minor = $this->signed_balance_minor((string) ($row['adjustment_total'] ?? '0')); - if ($adjustment_minor > 0 && $original_minor > PHP_INT_MAX - $adjustment_minor) { - return false; - } - if ($adjustment_minor < 0 && $original_minor < PHP_INT_MIN - $adjustment_minor) { - return false; - } - $amount_minor = $original_minor + $adjustment_minor; - } catch (\InvalidArgumentException $e) { return false; } - if ($amount_minor <= 0 || (int)$row['promoter_id'] <= 0) return false; - $guard = function () use ($table, $id, $row) { + + /** + * 佣金即时入账:全额 credit 到 COMMISSION 资金池,再机会性偿还历史欠款。 + * + * 两段式而非「净额入池」,是为了金额守恒可证: + * 1) 入池分录金额固定 = 佣金全额,operation_key 由明细行 id 派生,天然幂等/防重放。 + * 2) 偿债在独立账务事务里做:池扣减 min(欠款, 池余额) 且欠款同额递减,两者在同一 + * 事务内以锁定值为准,等额增减 → 永不凭空增减钱;并发导致失败则本次跳过, + * 欠款留到下一笔佣金再偿,绝不出错。 + */ + private function credit_commission(int $promoter, int $commission_minor, int $ledger_id, string $order_no): void { + if ($promoter <= 0 || $commission_minor <= 0) { + return; + } + $credit = \AKSRT\Ledger\LedgerService::get_instance()->credit( + $promoter, \AKSRT\Ledger\AssetType::COMMISSION, $commission_minor, + \AKSRT\Ledger\OperationKey::deterministic('commission-credit', [$ledger_id]), + 'affiliate_commission', + ['type' => 'commission', 'id' => $ledger_id], + sprintf(__('推广佣金入账(订单 %s)', 'aksrt'), $order_no) + ); + if (!is_wp_error($credit)) { + $this->repay_debt_from_pool($promoter); + } + } + + /** + * 机会性偿债:从 COMMISSION 池扣减 min(欠款, 池余额),欠款同额递减,单个账务事务内原子完成。 + * 偿债额 ≤ 池余额 → 扣减不会透支;扣减额 == 欠款递减额 → 金额守恒。并发/余额不足则本次跳过。 + */ + private function repay_debt_from_pool(int $promoter): void { + if ($promoter <= 0) { + return; + } + global $wpdb; + $debt_table = $this->table('commission_debt'); + $debt = (int) $wpdb->get_var($wpdb->prepare("SELECT debt_minor FROM {$debt_table} WHERE user_id=%d", $promoter)); + if ($debt <= 0) { + return; + } + $pool = \AKSRT\Ledger\LedgerService::get_instance()->balance($promoter, \AKSRT\Ledger\AssetType::COMMISSION); + $repay = max(0, min($debt, $pool)); + if ($repay <= 0) { + return; + } + // guard 内锁定欠款行复核:并发偿债已把欠款降到不足 repay,则可重试/跳过。 + $guard = function () use ($debt_table, $promoter, $repay) { global $wpdb; - $current = $wpdb->get_row($wpdb->prepare("SELECT promoter_id, original_amount, adjustment_total, status FROM {$table} WHERE id=%d FOR UPDATE", $id), ARRAY_A); - if (!is_array($current) || (string)$current['status'] !== 'pending' - || (int)$current['promoter_id'] !== (int)$row['promoter_id'] - || (string)$current['original_amount'] !== (string)$row['original_amount'] - || (string)$current['adjustment_total'] !== (string)$row['adjustment_total']) { - return new \WP_Error('aksrt_commission_state', __('佣金记录状态已变化','aksrt'), ['status'=>409]); - } - return true; + $locked = (int) $wpdb->get_var($wpdb->prepare("SELECT debt_minor FROM {$debt_table} WHERE user_id=%d FOR UPDATE", $promoter)); + return $locked >= $repay ? true : new \WP_Error('aksrt_commission_debt', __('佣金欠款已变化', 'aksrt'), ['status' => 409]); }; - $mutation = function () use ($table, $id) { + $mutation = function () use ($debt_table, $promoter, $repay) { global $wpdb; - return $wpdb->query($wpdb->prepare("UPDATE {$table} SET status='settled', settled_at=%s WHERE id=%d AND status='pending'", current_time('mysql', true), $id)) === 1 - ? true : new \WP_Error('aksrt_commission_state', __('佣金记录状态已变化','aksrt'), ['status'=>409]); + return $wpdb->query($wpdb->prepare( + "UPDATE {$debt_table} SET debt_minor = debt_minor - %d, updated_at = %s WHERE user_id=%d AND debt_minor >= %d", + $repay, current_time('mysql', true), $promoter, $repay + )) === 1 ? true : new \WP_Error('aksrt_commission_debt', __('佣金欠款抵扣失败', 'aksrt'), ['status' => 409]); }; - $result = \AKSRT\Ledger\LedgerService::get_instance()->credit( - (int)$row['promoter_id'], \AKSRT\Ledger\AssetType::BALANCE, $amount_minor, - $operation_key !== '' ? $operation_key : \AKSRT\Ledger\OperationKey::deterministic('commission-settle', [$id]), 'affiliate_commission', - ['type'=>'commission','id'=>$id], sprintf(__('推广佣金结算(订单 %s)','aksrt'), $row['order_number']), + // 偿债是机会性动作、由锁定的 debt_minor>=repay 保证不重复扣减,无需跨调用幂等; + // 故用唯一 operation_key,避免 (promoter,debt,pool) 元组偶然重现被误判为重放而漏偿。 + \AKSRT\Ledger\LedgerService::get_instance()->debit( + $promoter, \AKSRT\Ledger\AssetType::COMMISSION, $repay, + \AKSRT\Ledger\OperationKey::unique('commission-debt-repay'), + 'affiliate_commission_debt', + ['type' => 'commission_debt', 'id' => $promoter], + __('推广佣金偿还历史欠款', 'aksrt'), 0, [], $mutation, $guard ); - return is_wp_error($result) ? false : $result; - } - /** @return array|\WP_Error */ + } + /** + * 退款准备:返回本订单待收回的佣金计划。即时入账模型下佣金状态只有 + * credited | cancelled,可收回额 = original + 既有调整(正值)。 + * + * @return array|\WP_Error + */ public function prepare_full_refund(string $order_number) { global $wpdb; $row = $wpdb->get_row($wpdb->prepare( @@ -164,7 +204,7 @@ final class CommissionLedger { return ['active' => false]; } $status = (string) ($row['status'] ?? ''); - if (!in_array($status, ['pending', 'settled'], true)) { + if ($status !== 'credited') { return new \WP_Error('aksrt_commission_state', __('返佣记录状态不可退款', 'aksrt'), ['status' => 409]); } return [ @@ -179,7 +219,12 @@ final class CommissionLedger { ]; } - /** @param array $plan */ + /** + * 退款守卫(在买家退款账务事务内运行):锁定佣金明细行并复核未变化。 + * 仅校验明细行,不触碰资金池,故不会因促推者池不足而使买家退款失败。 + * + * @param array $plan + */ public function guard_full_refund(array $plan) { if (empty($plan['active'])) return true; global $wpdb; @@ -198,11 +243,18 @@ final class CommissionLedger { return true; } - /** @param array $plan */ + /** + * 退款应用(在买家退款账务事务内运行):仅做佣金明细账的登记——冲正 + * adjustment_total 并置为 cancelled,写入不可变调整记录。这里不移动资金池 + * (避免池不足时中止买家退款);真正的池收回 + 欠款挂账由 clawback_after_refund + * 在退款事务提交后独立执行。 + * + * @param array $plan + */ public function apply_full_refund(array $plan) { if (empty($plan['active'])) return true; global $wpdb; - $negative = '-' . \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, (int) $plan['amount_minor'])->decimal(); + $negative = '-' . \AKSRT\Ledger\AssetAmount::commission_from_minor((int) $plan['amount_minor'])->decimal(); $inserted = $wpdb->insert($this->table('commission_adjustment'), [ 'ledger_id' => (int) $plan['id'], 'promoter_id' => (int) $plan['promoter_id'], @@ -226,27 +278,137 @@ final class CommissionLedger { : new \WP_Error('aksrt_commission_state', __('返佣记录状态已变化', 'aksrt'), ['status' => 409]); } + /** + * 退款后的资金池收回(在买家退款事务提交后调用,不属于买家退款关键路径): + * 从 COMMISSION 池收回 min(应收回, 池余额),不足部分记为欠款,后续新佣金优先偿还。 + * + * 金额守恒 + 幂等: + * - 池扣减带重试,locked 池余额 < take 时重试并缩小 take,绝不透支中止; + * - 池扣减用 source_key 派生的确定性 operation_key,重复调用天然去重; + * - 欠款挂账用独立 source_key 一次性插入 commission_adjustment(唯一约束), + * 仅首次插入成功者累加欠款,杜绝重复挂账。 + * + * @param array $plan + */ + public function clawback_after_refund(array $plan): void { + if (empty($plan['active'])) { + return; + } + $promoter = (int) ($plan['promoter_id'] ?? 0); + $clawback = (int) ($plan['amount_minor'] ?? 0); + $source_key = (string) ($plan['source_key'] ?? ''); + if ($promoter <= 0 || $clawback <= 0 || $source_key === '') { + return; + } + $ledger = \AKSRT\Ledger\LedgerService::get_instance(); + // 收回的幂等边界是明细行的 cancelled 状态(apply_full_refund 已置位且带唯一 source_key): + // 单笔退款只会调用一次收回。这里每次 debit 用唯一 operation_key,避免「不同金额复用 + // 同一确定性 key 被当作重放而漏收」。池扣减额恒 ≤ 锁定池余额,绝不透支。 + $remaining = $clawback; + for ($attempt = 1; $attempt <= 5 && $remaining > 0; $attempt++) { + $pool = $ledger->balance($promoter, \AKSRT\Ledger\AssetType::COMMISSION); + $take = max(0, min($remaining, $pool)); + if ($take <= 0) { + break; + } + $guard = function () use ($promoter, $take, $ledger) { + $locked = $ledger->balance($promoter, \AKSRT\Ledger\AssetType::COMMISSION); + return $locked >= $take ? true : new \WP_Error('aksrt_commission_pool', __('佣金池余额已变化', 'aksrt'), ['status' => 409]); + }; + $result = $ledger->debit( + $promoter, \AKSRT\Ledger\AssetType::COMMISSION, $take, + \AKSRT\Ledger\OperationKey::unique('commission-refund-clawback'), 'affiliate_commission_refund', + ['type' => 'commission_refund', 'key' => $source_key], + __('推广佣金退款收回', 'aksrt'), + get_current_user_id(), [], null, $guard + ); + if (!is_wp_error($result)) { + $remaining -= $take; + continue; + } + // 池余额被并发提现/收回改变:重试并以新的锁定余额缩小 take。 + } + if ($remaining > 0) { + $this->record_debt($promoter, $remaining, $source_key, (int) ($plan['id'] ?? 0)); + } + } + + /** + * 挂账欠款:以独立 source_key 一次性写入调整记录(唯一约束保证幂等), + * 仅首次插入成功时累加用户欠款计数。 + */ + private function record_debt(int $promoter, int $shortfall_minor, string $source_key, int $ledger_id): void { + if ($promoter <= 0 || $shortfall_minor <= 0) { + return; + } + global $wpdb; + $debt_source = hash('sha256', 'commission-refund-debt:' . $source_key); + $inserted = $wpdb->insert($this->table('commission_adjustment'), [ + 'ledger_id' => $ledger_id, + 'promoter_id' => $promoter, + 'amount' => '-' . \AKSRT\Ledger\AssetAmount::commission_from_minor($shortfall_minor)->decimal(), + 'reason' => 'refund_debt', + 'source_key' => $debt_source, + 'operator_id' => get_current_user_id(), + 'created_at' => current_time('mysql', true), + ], ['%d', '%d', '%s', '%s', '%s', '%d', '%s']); + if ($inserted !== 1) { + // 已挂账(重复调用):唯一约束拦截,不重复累加欠款。 + return; + } + $debt_table = $this->table('commission_debt'); + $now = current_time('mysql', true); + $wpdb->query($wpdb->prepare( + "INSERT INTO {$debt_table} (user_id, debt_minor, updated_at) VALUES (%d, %d, %s) + ON DUPLICATE KEY UPDATE debt_minor = debt_minor + %d, updated_at = %s", + $promoter, $shortfall_minor, $now, $shortfall_minor, $now + )); + } + private function signed_balance_minor(string $decimal): int { $negative = str_starts_with($decimal, '-'); $value = $negative ? substr($decimal, 1) : $decimal; $minor = \AKSRT\Ledger\AssetAmount::balance($value)->minor(); return $negative ? -$minor : $minor; } + /** + * 佣金总览(即时入账模型): + * - total 累计佣金(明细账原始佣金累加,不含退款冲正) + * - pool 当前可提现佣金(COMMISSION 资金池余额) + * - withdrawn 已提现(佣金来源、已完成的提现毛额累加) + * - debt 当前挂账欠款(退款收回不足产生,后续佣金优先偿还) + * 无冻结结算期,故不再有 pending/settled/wallet 语义。 + */ public function overview(int $user_id): array { global $wpdb; $table=$this->table('commission_ledger'); - $row=$wpdb->get_row($wpdb->prepare("SELECT COALESCE(SUM(original_amount+adjustment_total),0) total, COALESCE(SUM(CASE WHEN status='pending' THEN original_amount+adjustment_total ELSE 0 END),0) pending, COALESCE(SUM(CASE WHEN status='settled' THEN original_amount+adjustment_total ELSE 0 END),0) settled FROM {$table} WHERE promoter_id=%d",$user_id),ARRAY_A) ?: []; - $withdrawn = '0.00'; + $total = (string) $wpdb->get_var($wpdb->prepare( + "SELECT COALESCE(SUM(original_amount),0) FROM {$table} WHERE promoter_id=%d", + $user_id + )); + $withdrawn = '0.00'; $withdrawal_table = $this->table('withdrawal'); if ($withdrawal_table !== '') { - $withdrawn = $this->balance_decimal((string) $wpdb->get_var($wpdb->prepare( - "SELECT COALESCE(SUM(CASE WHEN status='completed' THEN gross_amount ELSE 0 END),0) FROM {$withdrawal_table} WHERE user_id=%d", - $user_id - ))); + $withdrawn = $this->balance_decimal((string) $wpdb->get_var($wpdb->prepare( + "SELECT COALESCE(SUM(CASE WHEN status='completed' THEN gross_amount ELSE 0 END),0) FROM {$withdrawal_table} + WHERE user_id=%d AND account_info LIKE %s", + $user_id, + '%"source":"commission"%' + ))); } - return ['total'=>$this->balance_decimal((string)($row['total']??'0')),'pending'=>$this->balance_decimal((string)($row['pending']??'0')),'settled'=>$this->balance_decimal((string)($row['settled']??'0')),'wallet'=>\AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id),'withdrawn'=>$withdrawn]; + $debt_minor = (int) $wpdb->get_var($wpdb->prepare( + "SELECT debt_minor FROM {$this->table('commission_debt')} WHERE user_id=%d", + $user_id + )); + $pool_minor = \AKSRT\Ledger\LedgerService::get_instance()->balance($user_id, \AKSRT\Ledger\AssetType::COMMISSION); + return [ + 'total' => $this->balance_decimal($total), + 'pool' => \AKSRT\Ledger\AssetAmount::commission_from_minor($pool_minor)->decimal(), + 'withdrawn' => $withdrawn, + 'debt' => \AKSRT\Ledger\AssetAmount::commission_from_minor(max(0, $debt_minor))->decimal(), + ]; } public function list_entries(int $user_id, array $args=[]): array { - global $wpdb; $table=$this->table('commission_ledger'); $limit=max(1,min(100,(int)($args['per_page']??20))); $page=max(1,(int)($args['page']??1)); $offset=($page-1)*$limit; $where='promoter_id=%d'; $params=[$user_id]; $status=sanitize_key((string)($args['status']??'')); if(in_array($status,['pending','settled','cancelled'],true)){ $where.=' AND status=%s'; $params[]=$status; } $total=(int)$wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM {$table} WHERE {$where}",$params)); $rows=$wpdb->get_results($wpdb->prepare("SELECT * FROM {$table} WHERE {$where} ORDER BY id DESC LIMIT %d OFFSET %d",array_merge($params,[$limit,$offset])),ARRAY_A); return ['items'=>$rows?:[],'total'=>$total,'page'=>$page,'per_page'=>$limit,'max_pages'=>max(1,(int)ceil($total/$limit))]; + global $wpdb; $table=$this->table('commission_ledger'); $limit=max(1,min(100,(int)($args['per_page']??20))); $page=max(1,(int)($args['page']??1)); $offset=($page-1)*$limit; $where='promoter_id=%d'; $params=[$user_id]; $status=sanitize_key((string)($args['status']??'')); if(in_array($status,['credited','cancelled'],true)){ $where.=' AND status=%s'; $params[]=$status; } $total=(int)$wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM {$table} WHERE {$where}",$params)); $rows=$wpdb->get_results($wpdb->prepare("SELECT * FROM {$table} WHERE {$where} ORDER BY id DESC LIMIT %d OFFSET %d",array_merge($params,[$limit,$offset])),ARRAY_A); return ['items'=>$rows?:[],'total'=>$total,'page'=>$page,'per_page'=>$limit,'max_pages'=>max(1,(int)ceil($total/$limit))]; } public function register_routes(): void { $list_args = [ @@ -268,6 +430,7 @@ final class CommissionLedger { $withdrawal_args = $list_args; $withdrawal_args['per_page']['default'] = 10; $withdrawal_args['per_page']['maximum'] = 10; + $withdrawal_args['source'] = ['type' => 'string', 'enum' => ['balance', 'commission'], 'default' => 'commission']; register_rest_route('aksrt/v1', '/earnings/withdrawals', [ [ 'methods' => \WP_REST_Server::READABLE, @@ -284,32 +447,10 @@ final class CommissionLedger { 'amount' => ['type' => 'string', 'required' => true, 'pattern' => '^(0|[1-9]\d*)(?:\.\d{1,2})?$'], 'method' => ['type' => 'string', 'required' => true, 'sanitize_callback' => 'sanitize_key'], 'account' => ['type' => 'string', 'required' => true, 'minLength' => 1, 'maxLength' => 500, 'sanitize_callback' => 'sanitize_text_field'], + 'source' => ['type' => 'string', 'enum' => ['balance', 'commission'], 'default' => 'commission'], ], ], ]); - register_rest_route('aksrt/v1', '/payment/affiliate/ledger/(?P\d+)/settle', [ - 'methods' => \WP_REST_Server::CREATABLE, - 'callback' => [$this, 'admin_settle'], - 'permission_callback' => [$this, 'check_admin_write_rest'], - 'args' => ['id' => ['type' => 'integer', 'minimum' => 1, 'required' => true]], - ]); - register_rest_route('aksrt/v1', '/payment/affiliate/ledger/(?P\d+)/cancel', [ - 'methods' => \WP_REST_Server::CREATABLE, - 'callback' => [$this, 'admin_cancel'], - 'permission_callback' => [$this, 'check_admin_write_rest'], - 'args' => ['id' => ['type' => 'integer', 'minimum' => 1, 'required' => true]], - ]); - register_rest_route('aksrt/v1', '/payment/affiliate/overview', [ - 'methods' => \WP_REST_Server::READABLE, - 'callback' => fn() => rest_ensure_response($this->admin_overview()), - 'permission_callback' => [$this, 'check_admin_rest'], - ]); - register_rest_route('aksrt/v1', '/payment/affiliate/ledger', [ - 'methods' => \WP_REST_Server::READABLE, - 'callback' => fn(\WP_REST_Request $request) => rest_ensure_response($this->admin_ledger($request->get_params())), - 'permission_callback' => [$this, 'check_admin_rest'], - 'args' => $list_args, - ]); register_rest_route('aksrt/v1', '/payment/withdrawals/(?P\d+)/transition', [ 'methods' => \WP_REST_Server::CREATABLE, 'callback' => [$this, 'transition_withdrawal'], @@ -341,66 +482,17 @@ final class CommissionLedger { ? true : new \WP_Error('aksrt_rest_nonce', __('安全验证失败,请刷新页面后重试', 'aksrt'), ['status' => 403]); } - public function admin_settle(\WP_REST_Request $request): \WP_REST_Response|\WP_Error { - $operation_key = \AKSRT\Ledger\OperationKey::from_request($request, 'admin-commission-settle', get_current_user_id()); - if (is_wp_error($operation_key)) return $operation_key; - $result = $this->settle_one((int) $request['id'], $operation_key); - if (!$result instanceof \AKSRT\Ledger\LedgerResult) { - return new \WP_REST_Response(['success' => false, 'message' => __('佣金结算失败', 'aksrt')], 409); - } - return new \WP_REST_Response(['success' => true, 'transaction_id' => $result->transaction_id, 'replayed' => $result->replayed], 200); - } - public function admin_cancel(\WP_REST_Request $request): \WP_REST_Response|\WP_Error { - $operation_key = \AKSRT\Ledger\OperationKey::from_request($request, 'admin-commission-cancel', get_current_user_id()); - if (is_wp_error($operation_key)) return $operation_key; - global $wpdb; - $id = (int) $request['id']; - $payload_hash = hash('sha256', (string) wp_json_encode(['id' => $id, 'status' => 'cancelled'])); - if ($wpdb->query('START TRANSACTION') === false) { - return new \WP_Error('aksrt_commission_db', __('无法开启返佣取消事务', 'aksrt'), ['status' => 500]); - } - $row = $wpdb->get_row($wpdb->prepare("SELECT status, cancel_operation_key, cancel_payload_hash FROM {$this->table('commission_ledger')} WHERE id=%d FOR UPDATE", $id), ARRAY_A); - if (!is_array($row)) { - $wpdb->query('ROLLBACK'); - return new \WP_Error('aksrt_commission_missing', __('返佣记录不存在', 'aksrt'), ['status' => 404]); - } - $stored_key = (string) ($row['cancel_operation_key'] ?? ''); - $stored_hash = (string) ($row['cancel_payload_hash'] ?? ''); - if ($stored_key !== '') { - if (hash_equals($stored_key, $operation_key) && $stored_hash !== '' && hash_equals($stored_hash, $payload_hash) && (string) $row['status'] === 'cancelled') { - if ($wpdb->query('COMMIT') === false) { - $wpdb->query('ROLLBACK'); - return new \WP_Error('aksrt_commission_db', __('返佣取消重放失败', 'aksrt'), ['status' => 500]); - } - return new \WP_REST_Response(['success' => true, 'operation_key' => $operation_key, 'transaction_id' => 0, 'replayed' => true], 200); - } - $wpdb->query('ROLLBACK'); - return new \WP_Error('aksrt_commission_idempotency_conflict', __('相同操作标识已用于其他返佣取消请求', 'aksrt'), ['status' => 409]); - } - if ((string) ($row['status'] ?? '') !== 'pending') { - $wpdb->query('ROLLBACK'); - return new \WP_Error('aksrt_commission_state', __('返佣记录状态已变化', 'aksrt'), ['status' => 409]); - } - $updated = $wpdb->update($this->table('commission_ledger'), [ - 'status' => 'cancelled', - 'cancel_operation_key' => $operation_key, - 'cancel_payload_hash' => $payload_hash, - ], ['id' => $id, 'status' => 'pending'], ['%s', '%s', '%s'], ['%d', '%s']); - if ($updated !== 1 || $wpdb->query('COMMIT') === false) { - $wpdb->query('ROLLBACK'); - $conflict = $wpdb->get_var($wpdb->prepare("SELECT id FROM {$this->table('commission_ledger')} WHERE cancel_operation_key=%s", $operation_key)); - return new \WP_Error($conflict ? 'aksrt_commission_idempotency_conflict' : 'aksrt_commission_db', $conflict ? __('相同操作标识已用于其他返佣取消请求', 'aksrt') : __('返佣取消失败', 'aksrt'), ['status' => $conflict ? 409 : 500]); - } - return new \WP_REST_Response(['success' => true, 'operation_key' => $operation_key, 'transaction_id' => 0, 'replayed' => false], 200); - } - public function admin_overview(): array { global $wpdb; $table=$this->table('commission_ledger'); $row=$wpdb->get_row("SELECT COUNT(*) total, SUM(status='pending') pending, SUM(status='settled') settled, COALESCE(SUM(original_amount+adjustment_total),0) amount FROM {$table}",ARRAY_A) ?: []; return ['total'=>(int)($row['total']??0),'pending'=>(int)($row['pending']??0),'settled'=>(int)($row['settled']??0),'amount'=>$this->balance_decimal((string)($row['amount']??'0'))]; } - public function admin_ledger(array $args): array { global $wpdb; $table=$this->table('commission_ledger'); $limit=max(1,min(100,(int)($args['per_page']??20))); $page=max(1,(int)($args['page']??1)); $offset=($page-1)*$limit; $status=sanitize_key((string)($args['status']??'')); $where='1=1'; $params=[]; if(in_array($status,['pending','settled','cancelled'],true)){ $where.=' AND status=%s'; $params[]=$status; } $total=(int)($wpdb->get_var($params?$wpdb->prepare("SELECT COUNT(*) FROM {$table} WHERE {$where}",$params):"SELECT COUNT(*) FROM {$table}")); $rows=$wpdb->get_results($wpdb->prepare("SELECT * FROM {$table} WHERE {$where} ORDER BY id DESC LIMIT %d OFFSET %d",array_merge($params,[$limit,$offset])),ARRAY_A); return ['items'=>$rows?:[],'total'=>$total,'page'=>$page,'per_page'=>$limit,'max_pages'=>max(1,(int)ceil($total/$limit))]; } - public function withdrawals(int $user_id, array $args): array { + public function withdrawals(int $user_id, array $args): array { $service = \AKSRT\Payment\Withdrawal::get_instance(); $page = max(1, (int) ($args['page'] ?? 1)); $per_page = 10; $status = sanitize_key((string) ($args['status'] ?? '')); - $filters = ['user_id' => $user_id]; + // 提现记录按来源隔离:历史弹窗仅在余额页调用(无 source),默认 balance; + // 佣金页如需查询佣金提现记录须显式传 source=commission。 + $source = sanitize_key((string) ($args['source'] ?? '')) === \AKSRT\Payment\Withdrawal::SOURCE_COMMISSION + ? \AKSRT\Payment\Withdrawal::SOURCE_COMMISSION + : \AKSRT\Payment\Withdrawal::SOURCE_BALANCE; + $filters = ['user_id' => $user_id, 'source' => $source]; if ($status !== '') { $filters['status'] = $status; } @@ -410,6 +502,7 @@ final class CommissionLedger { return [ 'items' => $service->get_user_withdrawals($user_id, [ 'status' => $status, + 'source' => $source, 'limit' => $per_page, 'offset' => ($page - 1) * $per_page, ]), @@ -420,16 +513,29 @@ final class CommissionLedger { ]; } public function create_withdrawal(\WP_REST_Request $request): \WP_REST_Response|\WP_Error { - $user_id=get_current_user_id(); $method=sanitize_key((string)$request->get_param('method')); $account=sanitize_text_field((string)$request->get_param('account')); $settings=\AKSRT\Settings\Settings::get_withdrawal_settings(); $affiliate=\AKSRT\Settings\Settings::get_affiliate_settings(); + $user_id=get_current_user_id(); $method=sanitize_key((string)$request->get_param('method')); $account=sanitize_text_field((string)$request->get_param('account')); $settings=\AKSRT\Settings\Settings::get_withdrawal_settings(); + // 提现来源:commission → 佣金池;其余 → 余额钱包(充值 + 创作分成)。 + $source = sanitize_key((string) $request->get_param('source')) === \AKSRT\Payment\Withdrawal::SOURCE_COMMISSION + ? \AKSRT\Payment\Withdrawal::SOURCE_COMMISSION + : \AKSRT\Payment\Withdrawal::SOURCE_BALANCE; $raw_amount=$request->get_param('amount'); try { $amount=\AKSRT\Ledger\AssetAmount::balance(is_string($raw_amount)?$raw_amount:''); } catch (\InvalidArgumentException $e) { return new \WP_Error('affiliate_withdraw_amount',__('提现金额格式无效。','aksrt'),['status'=>400]); } $operation_key=\AKSRT\Ledger\OperationKey::from_request($request,'affiliate-withdraw',$user_id); if(is_wp_error($operation_key)) return $operation_key; - if (empty($affiliate['enabled'])) return new \WP_Error('affiliate_disabled',__('推广返佣未启用。','aksrt'),['status'=>403]); + // 来源各自独立的开关闸门:佣金提现需返佣启用;余额提现需余额提现开关。 + if ($source === \AKSRT\Payment\Withdrawal::SOURCE_COMMISSION) { + if (empty(\AKSRT\Settings\Settings::get_affiliate_settings()['enabled'])) return new \WP_Error('affiliate_disabled',__('推广返佣未启用。','aksrt'),['status'=>403]); + } else { + $all=\AKSRT\Settings\Settings::get_all(); + if (empty($all['pay_balance_withdraw_s'])) return new \WP_Error('balance_withdraw_disabled',__('余额提现未启用。','aksrt'),['status'=>403]); + } + if (class_exists('\AKSRT\Settings\Capabilities') && !\AKSRT\Settings\Capabilities::user_can('balance_withdraw', $user_id)) { + return new \WP_Error('withdraw_forbidden',__('当前账号不可提现。','aksrt'),['status'=>403]); + } $minimum=\AKSRT\Ledger\AssetAmount::balance(is_string($settings['min_amount'] ?? null) ? $settings['min_amount'] : ''); if ($amount->minor() < $minimum->minor()) return new \WP_Error('affiliate_withdraw_min',__('未达到最低提现金额。','aksrt'),['status'=>400]); if (!in_array($method,(array)$settings['payout_channels'],true) || $account==='') return new \WP_Error('affiliate_withdraw_account',__('请提供有效的收款渠道和收款码。','aksrt'),['status'=>400]); - $result=\AKSRT\Payment\Withdrawal::get_instance()->create_balance_request($user_id,$amount->minor(),$method,$account,$settings,$operation_key); if(is_wp_error($result)) return $result; return new \WP_REST_Response(['success'=>true,'data'=>$result],201); + $result=\AKSRT\Payment\Withdrawal::get_instance()->create_balance_request($user_id,$amount->minor(),$method,$account,$settings,$operation_key,$source); if(is_wp_error($result)) return $result; return new \WP_REST_Response(['success'=>true,'data'=>$result],201); } public function admin_withdrawals(array $args): array { $service=\AKSRT\Payment\Withdrawal::get_instance(); return ['items'=>$service->get_all(['status'=>sanitize_key((string)($args['status']??'')),'limit'=>max(1,min(100,(int)($args['per_page']??20))),'offset'=>max(0,((int)($args['page']??1)-1)*max(1,(int)($args['per_page']??20)))]),'stats'=>$service->get_stats()]; } public function transition_withdrawal(\WP_REST_Request $request): \WP_REST_Response|\WP_Error { diff --git a/src/Payment/ReferralAttribution.php b/src/Payment/ReferralAttribution.php index ebcbecfa..946b8db6 100644 --- a/src/Payment/ReferralAttribution.php +++ b/src/Payment/ReferralAttribution.php @@ -364,7 +364,6 @@ final class ReferralAttribution { 'order_types' => (array) ($affiliate['order_types'] ?? ['membership', 'content']), 'rules' => [ 'window_days' => (int) ($this->shared_settings()['window_days'] ?? 30), - 'settlement_days' => (int) ($affiliate['settlement_days'] ?? 7), ], ], 200); } diff --git a/src/Payment/Withdrawal.php b/src/Payment/Withdrawal.php index cbe7963a..66304a49 100644 --- a/src/Payment/Withdrawal.php +++ b/src/Payment/Withdrawal.php @@ -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) . '%'; diff --git a/src/Settings/CreditsPaymentGroups.php b/src/Settings/CreditsPaymentGroups.php index ddecbf01..b9045eb6 100644 --- a/src/Settings/CreditsPaymentGroups.php +++ b/src/Settings/CreditsPaymentGroups.php @@ -162,7 +162,6 @@ trait CreditsPaymentGroups { public static function affiliate_ratio_for_user(int $user_id = 0): int { return PaymentRuntime::affiliate_ratio_for_user($user_id > 0 ? $user_id : get_current_user_id()); } public static function affiliate_ratio_for_level(int $level): int { return PaymentRuntime::affiliate_ratio_for_level($level); } public static function get_affiliate_settings(): array { return PaymentRuntime::get_affiliate_settings(); } - public static function get_settlement_settings(): array { return PaymentRuntime::get_settlement_settings(); } /** * @return array diff --git a/src/Settings/PaymentRuntime.php b/src/Settings/PaymentRuntime.php index acfe54fb..a7ca17ef 100644 --- a/src/Settings/PaymentRuntime.php +++ b/src/Settings/PaymentRuntime.php @@ -341,7 +341,6 @@ class PaymentRuntime { 'order_types_by_level' => $types_by_level, 'disabled_code_stops_commission' => !empty($settings['affiliate_disabled_code_stops_commission']), 'window_days' => (int) CreditsRuntime::get_referral_settings()['window_days'], - 'settlement_days' => max(0, min(90, (int) ($settings['affiliate_settlement_days'] ?? 7))), 'promotion_text' => sanitize_textarea_field((string) ($settings['pay_rebate_text_details'] ?? '')), 'details_title' => sanitize_text_field((string) ($settings['pay_rebate_text_details_title'] ?? '返佣详解')), 'poster' => [ @@ -375,10 +374,6 @@ class PaymentRuntime { return (int) ($settings['ratios'][$key] ?? 0); } public static function get_affiliate_settings(): array { return self::get_payment_affiliate_settings(); } - public static function get_settlement_settings(): array { - $s = self::get_payment_affiliate_settings(); - return ['observation_days' => (int) $s['settlement_days'], 'retry_limit' => 100]; - } /** * Gateway channel selection. WeChat / Alipay each pick between the diff --git a/tests/widget-system/asset-ledger-contract.test.cjs b/tests/widget-system/asset-ledger-contract.test.cjs index a35d0d15..80ae7c42 100644 --- a/tests/widget-system/asset-ledger-contract.test.cjs +++ b/tests/widget-system/asset-ledger-contract.test.cjs @@ -135,7 +135,7 @@ test('core asset paths never use floating-point money helpers', () => { test('points orders bypass cash commissions and purchase dead paths stay removed', () => { const commission = fs.readFileSync(path.join(root, 'src', 'Payment', 'CommissionLedger.php'), 'utf8') const paidStart = commission.indexOf('public function record_paid_order') - const paidEnd = commission.indexOf('public function settle_due', paidStart) + const paidEnd = commission.indexOf('private function credit_commission', paidStart) const paidMethod = commission.slice(paidStart, paidEnd) const pointsGuard = paidMethod.indexOf('AssetType::POINTS->value') const affiliateGuard = paidMethod.indexOf('Settings::affiliate_enabled()') diff --git a/tests/widget-system/referral-policy-contract.test.cjs b/tests/widget-system/referral-policy-contract.test.cjs index fa4a055d..725ca490 100644 --- a/tests/widget-system/referral-policy-contract.test.cjs +++ b/tests/widget-system/referral-policy-contract.test.cjs @@ -61,19 +61,29 @@ test('commission base is the discounted payable amount, not the list price', () assert.match(ledger, /AssetAmount::balance\(is_string\(\$order\['amount'\] \?\? null\) \? \$order\['amount'\] : ''\)/) }) -test('settlement stays off the read path and runs on the cron schedule', () => { +test('commission is credited immediately with no settlement freeze', () => { const ledger = read('src', 'Payment', 'CommissionLedger.php') const attribution = read('src', 'Payment', 'ReferralAttribution.php') // 懒结算兜底已移除:读路径不得顺带写库(收益页打开慢的主因)。 assert.doesNotMatch(ledger, /lazy_settle_due|\$lazy_settled/) - // overview() 与 rest_me() 都不再触发结算。 assert.doesNotMatch(ledger, /\$this->lazy_settle_due\(\)/) assert.doesNotMatch(attribution, /lazy_settle_due/) assert.doesNotMatch(attribution, /->stats\(/) - // 结算只由 hourly 的 wp-cron 事件驱动。 - assert.match(ledger, /add_action\('aksrt_affiliate_settle_due', \[\$this, 'settle_due'\]\)/) - assert.match(ledger, /wp_schedule_event\(time\(\) \+ HOUR_IN_SECONDS, 'hourly', 'aksrt_affiliate_settle_due'\)/) + + // 即时入账模型:不再有结算冻结期 / 结算 cron / settle_due 方法。 + assert.doesNotMatch(ledger, /aksrt_affiliate_settle_due'?, \[\$this, 'settle_due'\]/) + assert.doesNotMatch(ledger, /wp_schedule_event\([^)]*aksrt_affiliate_settle_due/) + assert.doesNotMatch(ledger, /function settle_due|function settle_one/) + assert.doesNotMatch(ledger, /available_at|settled_at/) + // 遗留结算 cron 需在 init() 主动清理。 + assert.match(ledger, /wp_unschedule_event\([^)]*aksrt_affiliate_settle_due/) + + // 下单支付即把佣金 credit 到 COMMISSION 资金池。 + assert.match(ledger, /AssetType::COMMISSION/) + assert.match(ledger, /private function credit_commission/) + // 佣金明细行状态为 credited(非 pending 冻结)。 + assert.match(ledger, /'status'\s*=>\s*'credited'/) }) test('referral codes are self-serve: no admin ops page, granted at registration', () => { diff --git a/tools/admin-contract-report.json b/tools/admin-contract-report.json index e7ebf389..df218467 100644 --- a/tools/admin-contract-report.json +++ b/tools/admin-contract-report.json @@ -1,5 +1,5 @@ { - "generated_at": "2026-09-27T07:36:13+00:00", + "generated_at": "2026-09-28T13:07:05+00:00", "errors": [], "warnings": [ "customPage tools-update section 'tools/update' not in PHP schema (ok if pure operational page)",