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:
@@ -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");
|
||||
|
||||
@@ -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 推广收益:佣金详细页采用紧凑的一列信息清单。 */
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
+23
-5
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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'),
|
||||
];
|
||||
?>
|
||||
<?php
|
||||
$pool_amount = $format_amount($commission_stats['pool'] ?? 0);
|
||||
$total_amount = $format_amount($commission_stats['total'] ?? 0);
|
||||
$withdrawn_amount = $format_amount($commission_stats['withdrawn'] ?? 0);
|
||||
$debt_amount = $format_amount($commission_stats['debt'] ?? 0);
|
||||
$reward_panes = ['detail'];
|
||||
if ($affiliate_on) {
|
||||
$reward_panes[] = 'orders';
|
||||
}
|
||||
$reward_panes[] = 'users';
|
||||
$pane_is_active = static function (string $tab) use ($active_reward_tab): bool {
|
||||
return $active_reward_tab === $tab;
|
||||
};
|
||||
?>
|
||||
<div class="uc-revenue-panel" data-aksrt-revenue-page>
|
||||
<?php if ($affiliate_on) : ?>
|
||||
<section class="uc-commission-summary" aria-label="<?php esc_attr_e('佣金概览', 'aksrt'); ?>">
|
||||
@@ -127,9 +144,16 @@ $status_labels = [
|
||||
<article class="uc-commission-card uc-commission-card--balance">
|
||||
<div class="uc-commission-card__body">
|
||||
<span><?php esc_html_e('我的佣金', 'aksrt'); ?></span>
|
||||
<strong><small>¥</small><?php echo esc_html($format_amount($pending_commission)); ?></strong>
|
||||
<span><?php printf(esc_html__('累计佣金 ¥%s', 'aksrt'), esc_html($format_amount($commission_stats['total'] ?? 0))); ?></span>
|
||||
<strong><small>¥</small><?php echo esc_html($pool_amount); ?></strong>
|
||||
<p class="uc-commission-card__meta">
|
||||
<span><?php printf(esc_html__('累计佣金 ¥%s', 'aksrt'), esc_html($total_amount)); ?></span>
|
||||
<span><?php printf(esc_html__('已提现 ¥%s', 'aksrt'), esc_html($withdrawn_amount)); ?></span>
|
||||
<?php if (($commission_stats['debt'] ?? '0.00') !== '0.00') : ?><span class="uc-commission-card__debt"><?php printf(esc_html__('待偿欠款 ¥%s', 'aksrt'), esc_html($debt_amount)); ?></span><?php endif; ?>
|
||||
</p>
|
||||
</div>
|
||||
<?php if ($withdraw_on) : ?>
|
||||
<button type="button" class="uc-commission-card__action" data-aksrt-withdraw-open data-aksrt-withdraw-source="commission" data-withdraw-min="<?php echo esc_attr($withdraw_min); ?>" data-withdraw-remaining-amount="<?php echo esc_attr((string) ($commission_withdraw_usage['remaining_amount'] ?? '0.00')); ?>" data-withdraw-remaining-count="<?php echo esc_attr((string) ($commission_withdraw_usage['remaining_count'] ?? 0)); ?>"><?php esc_html_e('立即提现', 'aksrt'); ?><i class="fa fas fa-angle-right" aria-hidden="true"></i></button>
|
||||
<?php endif; ?>
|
||||
</article>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
@@ -139,61 +163,59 @@ $status_labels = [
|
||||
<span><?php echo esc_html($affiliate_on ? __('推广链接、佣金订单与推广用户', 'aksrt') : __('推广链接与推广用户', 'aksrt')); ?></span>
|
||||
</div>
|
||||
<nav class="uc-reward-tabs" aria-label="<?php esc_attr_e('收益明细', 'aksrt'); ?>">
|
||||
<a <?php echo $active_reward_tab === 'detail' ? 'aria-current="page"' : ''; ?> class="<?php echo $active_reward_tab === 'detail' ? 'is-active' : ''; ?>" href="<?php echo esc_url($reward_url('detail')); ?>"><?php esc_html_e('推广详情', 'aksrt'); ?></a>
|
||||
<?php if ($affiliate_on) : ?><a <?php echo $active_reward_tab === 'orders' ? 'aria-current="page"' : ''; ?> class="<?php echo $active_reward_tab === 'orders' ? 'is-active' : ''; ?>" href="<?php echo esc_url($reward_url('orders')); ?>"><?php esc_html_e('佣金明细', 'aksrt'); ?></a><?php endif; ?>
|
||||
<a <?php echo $active_reward_tab === 'users' ? 'aria-current="page"' : ''; ?> class="<?php echo $active_reward_tab === 'users' ? 'is-active' : ''; ?>" href="<?php echo esc_url($reward_url('users')); ?>"><?php esc_html_e('推广用户', 'aksrt'); ?></a>
|
||||
<a data-reward-tab="detail" <?php echo $pane_is_active('detail') ? 'aria-current="page"' : ''; ?> class="<?php echo $pane_is_active('detail') ? 'is-active' : ''; ?>" href="<?php echo esc_url($reward_url('detail')); ?>"><?php esc_html_e('推广详情', 'aksrt'); ?></a>
|
||||
<?php if ($affiliate_on) : ?><a data-reward-tab="orders" <?php echo $pane_is_active('orders') ? 'aria-current="page"' : ''; ?> class="<?php echo $pane_is_active('orders') ? 'is-active' : ''; ?>" href="<?php echo esc_url($reward_url('orders')); ?>"><?php esc_html_e('佣金明细', 'aksrt'); ?></a><?php endif; ?>
|
||||
<a data-reward-tab="users" <?php echo $pane_is_active('users') ? 'aria-current="page"' : ''; ?> class="<?php echo $pane_is_active('users') ? 'is-active' : ''; ?>" href="<?php echo esc_url($reward_url('users')); ?>"><?php esc_html_e('推广用户', 'aksrt'); ?></a>
|
||||
</nav>
|
||||
|
||||
<?php if ($active_reward_tab === 'detail') : ?>
|
||||
<div class="uc-reward-pane uc-reward-pane--detail" data-aksrt-revenue-page>
|
||||
<div class="uc-revenue-summary-list">
|
||||
<div class="uc-revenue-summary-row uc-revenue-summary-row--link">
|
||||
<span><?php esc_html_e('推广链接', 'aksrt'); ?></span>
|
||||
<input type="text" readonly data-affiliate-link value="<?php echo esc_attr($affiliate_link_url); ?>" aria-label="<?php esc_attr_e('推广链接', 'aksrt'); ?>">
|
||||
<button type="button" class="btn-primary btn-sm" data-affiliate-copy><?php esc_html_e('复制链接', 'aksrt'); ?></button>
|
||||
<?php if (!empty($affiliate['poster']['enabled'])) : ?><button type="button" class="btn-outline btn-sm" data-affiliate-poster hidden><?php esc_html_e('推广海报', 'aksrt'); ?></button><?php endif; ?>
|
||||
<small data-affiliate-feedback role="status" aria-live="polite"></small>
|
||||
</div>
|
||||
<?php if ($affiliate_on) : ?>
|
||||
<div class="uc-revenue-summary-row"><span><?php esc_html_e('佣金比例', 'aksrt'); ?></span><strong data-affiliate-ratio><?php echo esc_html((string) $commission_ratio); ?>%</strong></div>
|
||||
<div class="uc-revenue-summary-row uc-revenue-summary-row--total"><span><?php esc_html_e('累计佣金', 'aksrt'); ?></span><div><strong><?php printf(esc_html__('累计 %s 元', 'aksrt'), esc_html($format_amount($commission_stats['total'] ?? 0))); ?></strong><strong><?php printf(esc_html__('待结算 %s 元', 'aksrt'), esc_html($format_amount($commission_stats['pending'] ?? 0))); ?></strong></div></div>
|
||||
<div class="uc-revenue-summary-row uc-revenue-summary-row--explain"><span><?php echo esc_html($details_title); ?></span><div class="uc-reward-richtext"><?php echo wp_kses_post(wpautop($details_text)); ?></div></div>
|
||||
<?php endif; ?>
|
||||
<div class="uc-reward-pane uc-reward-pane--detail" data-reward-pane="detail"<?php echo $pane_is_active('detail') ? '' : ' hidden'; ?>>
|
||||
<div class="uc-revenue-summary-list">
|
||||
<div class="uc-revenue-summary-row uc-revenue-summary-row--link">
|
||||
<span><?php esc_html_e('推广链接', 'aksrt'); ?></span>
|
||||
<input type="text" readonly data-affiliate-link value="<?php echo esc_attr($affiliate_link_url); ?>" aria-label="<?php esc_attr_e('推广链接', 'aksrt'); ?>">
|
||||
<button type="button" class="btn-primary btn-sm" data-affiliate-copy><?php esc_html_e('复制链接', 'aksrt'); ?></button>
|
||||
<?php if (!empty($affiliate['poster']['enabled'])) : ?><button type="button" class="btn-outline btn-sm" data-affiliate-poster hidden><?php esc_html_e('推广海报', 'aksrt'); ?></button><?php endif; ?>
|
||||
<small data-affiliate-feedback role="status" aria-live="polite"></small>
|
||||
</div>
|
||||
<?php if ($affiliate_on) : ?>
|
||||
<div class="uc-revenue-summary-row"><span><?php esc_html_e('佣金比例', 'aksrt'); ?></span><strong data-affiliate-ratio><?php echo esc_html((string) $commission_ratio); ?>%</strong></div>
|
||||
<div class="uc-revenue-summary-row uc-revenue-summary-row--total"><span><?php esc_html_e('累计佣金', 'aksrt'); ?></span><div><strong><?php printf(esc_html__('累计 %s 元', 'aksrt'), esc_html($total_amount)); ?></strong><strong><?php printf(esc_html__('可提现 %s 元', 'aksrt'), esc_html($pool_amount)); ?></strong><strong><?php printf(esc_html__('已提现 %s 元', 'aksrt'), esc_html($withdrawn_amount)); ?></strong><?php if (($commission_stats['debt'] ?? '0.00') !== '0.00') : ?><strong><?php printf(esc_html__('待偿欠款 %s 元', 'aksrt'), esc_html($debt_amount)); ?></strong><?php endif; ?></div></div>
|
||||
<div class="uc-revenue-summary-row uc-revenue-summary-row--explain"><span><?php echo esc_html($details_title); ?></span><div class="uc-reward-richtext"><?php echo wp_kses_post(wpautop($details_text)); ?></div></div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php elseif ($active_reward_tab === 'orders') : ?>
|
||||
<div class="uc-reward-pane">
|
||||
<div class="uc-reward-list-heading"><div><h4><?php esc_html_e('佣金订单', 'aksrt'); ?></h4><p><?php esc_html_e('每页显示 10 条佣金记录。', 'aksrt'); ?></p></div><strong><?php printf(esc_html__('%d 条', 'aksrt'), (int) ($commission_view['total'] ?? 0)); ?></strong></div>
|
||||
<?php if ($commission_items) : ?>
|
||||
<div class="uc-commission-list">
|
||||
<?php foreach ($commission_items as $item) :
|
||||
$status = sanitize_key((string) ($item['status'] ?? 'pending'));
|
||||
$source_user = !empty($item['source_user_id']) ? get_userdata((int) $item['source_user_id']) : null;
|
||||
$source_name = $source_user instanceof WP_User ? $source_user->display_name : __('推广用户', 'aksrt');
|
||||
?>
|
||||
<?php
|
||||
$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));
|
||||
?>
|
||||
<article class="uc-commission-row"><span class="uc-commission-row__icon"><i class="fa fas fa-file-alt" aria-hidden="true"></i></span><div class="uc-commission-row__main"><strong><?php echo esc_html($source_name); ?></strong><span><?php printf(esc_html__('订单 %s', 'aksrt'), esc_html((string) ($item['order_number'] ?? '-'))); ?></span><time><?php echo esc_html((string) ($item['created_at'] ?? '')); ?></time></div><div class="uc-commission-row__value"><strong><?php echo esc_html($format_amount($commission_amount)); ?></strong><span class="is-<?php echo esc_attr($status); ?>"><?php echo esc_html($status_labels[$status] ?? $status); ?></span></div></article>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php if ($commission_total_pages > 1) : ?><nav class="uc-reward-pagination" aria-label="<?php esc_attr_e('佣金明细分页', 'aksrt'); ?>"><?php for ($i = 1; $i <= $commission_total_pages; $i++) : ?><a class="<?php echo $i === $commission_page ? 'is-active' : ''; ?>" href="<?php echo esc_url($reward_url('orders', ['commission_page' => $i])); ?>"><?php echo esc_html((string) $i); ?></a><?php endfor; ?></nav><?php endif; ?>
|
||||
<?php else : ?><p class="uc-reward-empty"><?php esc_html_e('暂无佣金订单', 'aksrt'); ?></p><?php endif; ?>
|
||||
</div>
|
||||
<?php else : ?>
|
||||
<div class="uc-reward-pane">
|
||||
<div class="uc-reward-list-heading"><div><h4><?php esc_html_e('推广用户', 'aksrt'); ?></h4><p><?php esc_html_e('通过你的 ref 推广链接完成绑定的用户。', 'aksrt'); ?></p></div><strong><?php printf(esc_html__('%d 人', 'aksrt'), (int) ($user_view['total'] ?? 0)); ?></strong></div>
|
||||
<?php if (!empty($user_view['items'])) : ?>
|
||||
<div class="uc-revenue-user-list">
|
||||
<?php foreach ($user_view['items'] as $item) : ?>
|
||||
<article class="uc-revenue-user-row"><img src="<?php echo esc_url((string) ($item['avatar'] ?? '')); ?>" alt=""><div><strong><?php echo esc_html((string) ($item['name'] ?? __('推广用户', 'aksrt'))); ?></strong><small><?php echo esc_html((string) ($item['bound_at'] ?? '')); ?></small></div><span><?php esc_html_e('已绑定', 'aksrt'); ?></span></article>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php if ($user_total_pages > 1) : ?><nav class="uc-reward-pagination" aria-label="<?php esc_attr_e('推广用户分页', 'aksrt'); ?>"><?php for ($i = 1; $i <= $user_total_pages; $i++) : ?><a class="<?php echo $i === $user_page ? 'is-active' : ''; ?>" href="<?php echo esc_url($reward_url('users', ['user_page' => $i])); ?>"><?php echo esc_html((string) $i); ?></a><?php endfor; ?></nav><?php endif; ?>
|
||||
<?php else : ?><p class="uc-reward-empty"><?php esc_html_e('暂无推广用户', 'aksrt'); ?></p><?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if ($affiliate_on) : ?>
|
||||
<div class="uc-reward-pane" data-reward-pane="orders"<?php echo $pane_is_active('orders') ? '' : ' hidden'; ?>>
|
||||
<div class="uc-reward-list-heading"><div><h4><?php esc_html_e('佣金订单', 'aksrt'); ?></h4><p><?php esc_html_e('每页显示 10 条佣金记录。', 'aksrt'); ?></p></div><strong><?php printf(esc_html__('%d 条', 'aksrt'), (int) ($commission_view['total'] ?? 0)); ?></strong></div>
|
||||
<?php if ($commission_items) : ?>
|
||||
<div class="uc-commission-list">
|
||||
<?php foreach ($commission_items as $item) :
|
||||
$status = sanitize_key((string) ($item['status'] ?? 'pending'));
|
||||
$source_user = !empty($item['source_user_id']) ? get_userdata((int) $item['source_user_id']) : null;
|
||||
$source_name = $source_user instanceof WP_User ? $source_user->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));
|
||||
?>
|
||||
<article class="uc-commission-row"><span class="uc-commission-row__icon"><i class="fa fas fa-file-alt" aria-hidden="true"></i></span><div class="uc-commission-row__main"><strong><?php echo esc_html($source_name); ?></strong><span><?php printf(esc_html__('订单 %s', 'aksrt'), esc_html((string) ($item['order_number'] ?? '-'))); ?></span><time><?php echo esc_html((string) ($item['created_at'] ?? '')); ?></time></div><div class="uc-commission-row__value"><strong><?php echo esc_html($format_amount($commission_amount)); ?></strong><span class="is-<?php echo esc_attr($status); ?>"><?php echo esc_html($status_labels[$status] ?? $status); ?></span></div></article>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php if ($commission_total_pages > 1) : ?><nav class="uc-reward-pagination" aria-label="<?php esc_attr_e('佣金明细分页', 'aksrt'); ?>"><?php for ($i = 1; $i <= $commission_total_pages; $i++) : ?><a class="<?php echo $i === $commission_page ? 'is-active' : ''; ?>" href="<?php echo esc_url($reward_url('orders', ['commission_page' => $i])); ?>"><?php echo esc_html((string) $i); ?></a><?php endfor; ?></nav><?php endif; ?>
|
||||
<?php else : ?><p class="uc-reward-empty"><?php esc_html_e('暂无佣金订单', 'aksrt'); ?></p><?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="uc-reward-pane" data-reward-pane="users"<?php echo $pane_is_active('users') ? '' : ' hidden'; ?>>
|
||||
<div class="uc-reward-list-heading"><div><h4><?php esc_html_e('推广用户', 'aksrt'); ?></h4><p><?php esc_html_e('通过你的 ref 推广链接完成绑定的用户。', 'aksrt'); ?></p></div><strong><?php printf(esc_html__('%d 人', 'aksrt'), (int) ($user_view['total'] ?? 0)); ?></strong></div>
|
||||
<?php if (!empty($user_view['items'])) : ?>
|
||||
<div class="uc-revenue-user-list">
|
||||
<?php foreach ($user_view['items'] as $item) : ?>
|
||||
<article class="uc-revenue-user-row"><img src="<?php echo esc_url((string) ($item['avatar'] ?? '')); ?>" alt=""><div><strong><?php echo esc_html((string) ($item['name'] ?? __('推广用户', 'aksrt'))); ?></strong><small><?php echo esc_html((string) ($item['bound_at'] ?? '')); ?></small></div><span><?php esc_html_e('已绑定', 'aksrt'); ?></span></article>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php if ($user_total_pages > 1) : ?><nav class="uc-reward-pagination" aria-label="<?php esc_attr_e('推广用户分页', 'aksrt'); ?>"><?php for ($i = 1; $i <= $user_total_pages; $i++) : ?><a class="<?php echo $i === $user_page ? 'is-active' : ''; ?>" href="<?php echo esc_url($reward_url('users', ['user_page' => $i])); ?>"><?php echo esc_html((string) $i); ?></a><?php endfor; ?></nav><?php endif; ?>
|
||||
<?php else : ?><p class="uc-reward-empty"><?php esc_html_e('暂无推广用户', 'aksrt'); ?></p><?php endif; ?>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<?php if ($withdraw_on) {
|
||||
|
||||
@@ -347,7 +347,6 @@ class SchemaPayment {
|
||||
['key' => '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' => [
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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,
|
||||
|
||||
+251
-145
@@ -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<string,mixed>|\WP_Error */
|
||||
}
|
||||
/**
|
||||
* 退款准备:返回本订单待收回的佣金计划。即时入账模型下佣金状态只有
|
||||
* credited | cancelled,可收回额 = original + 既有调整(正值)。
|
||||
*
|
||||
* @return array<string,mixed>|\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<string,mixed> $plan */
|
||||
/**
|
||||
* 退款守卫(在买家退款账务事务内运行):锁定佣金明细行并复核未变化。
|
||||
* 仅校验明细行,不触碰资金池,故不会因促推者池不足而使买家退款失败。
|
||||
*
|
||||
* @param array<string,mixed> $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<string,mixed> $plan */
|
||||
/**
|
||||
* 退款应用(在买家退款账务事务内运行):仅做佣金明细账的登记——冲正
|
||||
* adjustment_total 并置为 cancelled,写入不可变调整记录。这里不移动资金池
|
||||
* (避免池不足时中止买家退款);真正的池收回 + 欠款挂账由 clawback_after_refund
|
||||
* 在退款事务提交后独立执行。
|
||||
*
|
||||
* @param array<string,mixed> $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<string,mixed> $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<id>\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<id>\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<id>\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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+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) . '%';
|
||||
|
||||
@@ -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<string,mixed>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()')
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)",
|
||||
|
||||
Reference in New Issue
Block a user