doge 3 дней назад
Родитель
Сommit
a5e14d502d

+ 1 - 1
app/Http/Controllers/admin/Agent.php

@@ -184,7 +184,7 @@ class Agent extends AgentApiController
             foreach (['agent_id', 'order_no', 'status'] as $field) if (!empty($data[$field])) $query->where($field, $data[$field]);
             if (!empty($data['channel'])) $query->where('account_type', $data['channel']);
             $result = $this->paginate($query, $data);
-            $result['list']->each->makeVisible(['pay_data', 'callback_data']);
+            $result['list']->each->makeVisible(AgentWithdrawal::ADMIN_ONLY);
             return $result;
         });
     }

+ 4 - 5
app/Http/Controllers/api/Wallet.php

@@ -233,7 +233,7 @@ class Wallet extends BaseController
             $data['type'] = PaymentOrderService::TYPE_SELF_PAY;
             $data['order_no'] = PaymentOrderService::createOrderNo('rgcz_', $member_id);
             $data['member_id'] = $member_id;
-            $data['fee'] = 0;
+            $data += PaymentOrderService::feeFields($params['amount'], 0);
             $data['amount'] = number_format($params['amount'], 2, '.', '');
             $data['payment_type'] = $params['payment_type'];
             $data['channel'] = 'rgcz';
@@ -422,13 +422,12 @@ class Wallet extends BaseController
             if ($channel === 'rgtx') {
                 
                 try {
-                    $rate = RechargeChannel::where('type', 'rgtx')->value('rate');
-                    $rate = $rate ?? 1;
                     $data = [];
                     $data['type'] = PaymentOrderService::TYPE_SELF_PAYOUT;
                     $data['order_no'] = PaymentOrderService::createOrderNo('rgtx' . $data['type'] . '_', $member_id);
                     $data['member_id'] = $member_id;
-                    $data['fee'] = $amount * $rate;
+                    // 人工提现暂不记三方手续费(全额出款,不扣客户)。
+                    $data += PaymentOrderService::feeFields($amount, 0);
                     $data['amount'] = number_format($amount, 2, '.', '');
                     $data['channel'] = $params['channel'];
                     $data['bank_name'] = $destination['bank_name'];
@@ -438,7 +437,7 @@ class Wallet extends BaseController
                     $data['qr_image'] = $destination['qr_image'] ?? null;
                     $data['status'] = PaymentOrderService::STATUS_STAY;
                     // 校验打码量 → 冻结资金 → 创建待处理的提现记录 → 重置打码周期
-                    [$info] = WithdrawFundsService::hold($member_id, $amount, '人工提现', '钱宝提现费率:'.($rate  * 100) . '%',
+                    [$info] = WithdrawFundsService::hold($member_id, $amount, '人工提现', '',
                         WithdrawFundsService::PAYMENT_ORDERS, function () use ($data, $member_id) {
                             $pending = PaymentOrder::where('type', PaymentOrderService::TYPE_SELF_PAYOUT)
                                 ->where('member_id', $member_id)->whereIn('status', [0, 1, 4, 5])

+ 5 - 3
app/Models/Agent/AgentWithdrawal.php

@@ -6,14 +6,16 @@ class AgentWithdrawal extends BaseModel
     protected $table = 'agent_withdrawals';
     protected $fillable = ['order_no', 'request_key', 'agent_id', 'account_id', 'account_type', 'account_snapshot', 'amount', 'fee', 'actual_amount',
         'usdt_amount', 'actual_usdt_amount', 'exchange_rate', 'status', 'remark', 'audit_remark', 'audit_admin_id', 'audited_at', 'paid_at',
-        'pay_no', 'pay_data', 'callback_data'];
-    // 三方代付的原始返回/回调只给总后台看(列表里 makeVisible)。
-    protected $hidden = ['request_key', 'pay_data', 'callback_data'];
+        'pay_no', 'pay_data', 'callback_data', 'fee_rate', 'channel_fee_rate', 'channel_fee_fixed', 'channel_fee'];
+    // 三方代付的原始返回/回调和平台三方成本只给总后台看(列表里 makeVisible)。
+    public const ADMIN_ONLY = ['pay_data', 'callback_data', 'channel_fee_rate', 'channel_fee_fixed', 'channel_fee'];
+    protected $hidden = ['request_key', 'pay_data', 'callback_data', 'channel_fee_rate', 'channel_fee_fixed', 'channel_fee'];
     protected $appends = ['status_text', 'channel'];
 
     public const STATUS_TEXT = ['pending' => '待审核', 'approved' => '已通过', 'processing' => '代付中', 'paid' => '已打款', 'rejected' => '已驳回', 'failed' => '代付失败'];
     protected $casts = ['agent_id' => 'integer', 'amount' => 'decimal:4', 'fee' => 'decimal:4', 'actual_amount' => 'decimal:4',
         'usdt_amount' => 'decimal:4', 'actual_usdt_amount' => 'decimal:4', 'exchange_rate' => 'decimal:4',
+        'fee_rate' => 'decimal:6', 'channel_fee_rate' => 'decimal:6', 'channel_fee_fixed' => 'decimal:4', 'channel_fee' => 'decimal:4',
         'account_snapshot' => 'array', 'audited_at' => 'datetime', 'paid_at' => 'datetime'];
 
     public function getStatusTextAttribute(): string

+ 1 - 1
app/Models/PaymentOrder.php

@@ -16,7 +16,7 @@ class PaymentOrder extends BaseModel
 {
 
     protected $table = 'payment_orders';
-    protected $fillable = ['type', 'order_no', 'member_id', 'amount', 'channel', 'admin_note','bank_name', 'bank_branch', 'account', 'card_no', 'qr_image', 'status', 'callback_url', 'callback_data', 'remark', 'pay_no', 'pay_url', 'pay_data', 'fee', 'payment_type', 'is_send', 'is_locked', 'recharge_bonus_snapshot', 'frozen_amount'];
+    protected $fillable = ['type', 'order_no', 'member_id', 'amount', 'channel', 'admin_note','bank_name', 'bank_branch', 'account', 'card_no', 'qr_image', 'status', 'callback_url', 'callback_data', 'remark', 'pay_no', 'pay_url', 'pay_data', 'fee', 'fee_rate', 'fee_fixed', 'payment_type', 'is_send', 'is_locked', 'recharge_bonus_snapshot', 'frozen_amount'];
     protected $hidden = ['recharge_bonus_snapshot'];
     protected $casts = ['recharge_bonus_snapshot' => 'array'];
 

+ 2 - 0
app/Services/Agent/AgentPaymentService.php

@@ -141,8 +141,10 @@ class AgentPaymentService
         $total = (clone $query)->count();
         $list = $query->orderByDesc('id')->forPage($page, $limit)->get();
         // 在线代收的 pay_data 是三方原始返回,不给代理看;人工充值的 pay_data 是收款信息,需要展示。
+        // fee 是平台付给三方的成本,不扣代理,也不给代理看。
         if ($agent) {
             foreach ($list as $order) {
+                $order->makeHidden(['fee', 'fee_rate', 'fee_fixed']);
                 if ((int) $order->type === PaymentOrderService::TYPE_PAY) $order->makeHidden('pay_data');
             }
         }

+ 8 - 0
app/Services/Agent/AgentWithdrawalService.php

@@ -178,6 +178,10 @@ class AgentWithdrawalService
             if (bccomp($actual, '0', 4) <= 0) {
                 throw new \RuntimeException('扣除手续费后到账金额必须大于0');
             }
+            // 平台付给三方代付的成本(按到账金额下单);USDT / 人工提现不记(链上成本不计)。
+            $channelFee = self::isProviderChannel($channel)
+                ? PaymentOrderService::feeFields($actual, (string) ($config->rate ?? 0), PaymentOrderService::PAYOUT_FIXED_FEE)
+                : PaymentOrderService::feeFields($actual, 0);
 
             $before = bcadd((string) $lockedAgent->balance, '0', 4);
             $lockedAgent->balance = bcsub($before, $rmbAmount, 4);
@@ -205,6 +209,10 @@ class AgentWithdrawalService
                 ], fn ($v) => $v !== null),
                 'amount' => $rmbAmount,
                 'fee' => $fee,
+                'fee_rate' => bcdiv((string) $lockedAgent->withdraw_fee_rate, '100', 6),
+                'channel_fee_rate' => $channelFee['fee_rate'],
+                'channel_fee_fixed' => $channelFee['fee_fixed'],
+                'channel_fee' => bcadd($channelFee['fee'], '0', 4),
                 'actual_amount' => $actual,
                 'usdt_amount' => $usdtAmount,
                 'actual_usdt_amount' => $channel === 'usdt' ? bcdiv($actual, $rate, 4) : '0',

+ 73 - 13
app/Services/FundsSummaryService.php

@@ -4,6 +4,7 @@ namespace App\Services;
 
 use Carbon\Carbon;
 use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
 
 class FundsSummaryService
 {
@@ -16,6 +17,7 @@ class FundsSummaryService
         $withdraw = self::withdrawAggregates($start, $end);
         $paymentOrder = self::paymentOrderAggregates($start, $end);
         $combinedPeople = self::combinedPeople($start, $end);
+        $agentFees = self::agentFeeAggregates($start, $end);
         $commission = app(ThirdGameCommissionService::class)->summary($start?->toDateTimeString(), $end?->toDateTimeString());
 
         $income = [
@@ -30,8 +32,8 @@ class FundsSummaryService
             self::item(
                 'member_withdraw_deduction',
                 '会员出款扣除',
-                (float)$withdraw->member_withdraw_deduction_amount
-                    + (float)$paymentOrder->manual_withdrawal_fee_amount,
+                // 只有 USDT 提现向会员收手续费;三方/人工提现的 fee 是平台成本,不扣会员。
+                $withdraw->member_withdraw_deduction_amount,
                 $combinedPeople['member_withdraw_deduction']
             ),
         ];
@@ -52,9 +54,10 @@ class FundsSummaryService
             self::item('vip_discount', '给予优惠(VIP 折扣)', 0, 0),
             self::item('rebate', '给予返点', $balance->rebate_amount, $balance->rebate_people),
             self::item('bonus', '优惠金', $balance->bonus_amount, $balance->bonus_people),
+            // 下面几项手续费都是三方按通道费率向平台收的,平台承担,不扣会员/代理。
             self::item(
                 'third_party_fee',
-                '第三方手续费',
+                '第三方支付(入款)手续费',
                 $paymentOrder->third_party_fee_amount,
                 $paymentOrder->third_party_fee_people
             ),
@@ -70,6 +73,8 @@ class FundsSummaryService
                 $paymentOrder->auto_withdrawal_fee_amount,
                 $paymentOrder->auto_withdrawal_fee_people
             ),
+            self::item('agent_deposit_channel_fee', '代理三方充值手续费', $agentFees->deposit_fee, $agentFees->deposit_fee_people),
+            self::item('agent_payout_channel_fee', '代理三方提现手续费', $agentFees->channel_fee, $agentFees->channel_fee_people),
             ['key' => 'third_game_commission', 'label' => '第三方游戏平台抽成',
                 'amount' => $commission['amount'] === null ? null : self::commissionMoney($commission['amount']),
                 'people' => $commission['people']],
@@ -86,9 +91,73 @@ class FundsSummaryService
                 $commission['complete'] ? '0.00' : self::commissionMoney($commission['known_amount']), 2),
             'third_game_net_profit' => $commission['net_profit'] === null ? null : self::commissionMoney($commission['net_profit']),
             'third_game_commission_meta' => $commission,
+            'fee_summary' => self::feeSummary($withdraw, $paymentOrder, $agentFees, $start, $end),
         ];
     }
 
+    /**
+     * 手续费盈亏:收入是向客户收的手续费(会员 USDT 提现、代理提现),成本是三方按通道费率向平台收的手续费。
+     * 代理提现的历史订单没有记录三方成本(channel_fee 为 NULL),计入 unrecorded_count。
+     */
+    private static function feeSummary(object $withdraw, object $paymentOrder, object $agentFees, ?Carbon $start, ?Carbon $end): array
+    {
+        $income = [
+            self::item('member_usdt_withdraw_fee', '会员USDT提现手续费', $withdraw->member_withdraw_deduction_amount,
+                self::successfulWithdrawLogs($start, $end)->where('w.service_charge', '>', 0)->distinct()->count('bl.member_id')),
+            self::item('agent_withdraw_fee', '代理提现手续费', $agentFees->withdraw_fee, $agentFees->withdraw_fee_people),
+        ];
+        $cost = [
+            self::item('member_deposit_channel_fee', '会员三方充值手续费', $paymentOrder->third_party_fee_amount, $paymentOrder->third_party_fee_people),
+            self::item('member_payout_channel_fee', '会员三方提现手续费', $paymentOrder->auto_withdrawal_fee_amount, $paymentOrder->auto_withdrawal_fee_people),
+            self::item('agent_deposit_channel_fee', '代理三方充值手续费', $agentFees->deposit_fee, $agentFees->deposit_fee_people),
+            self::item('agent_payout_channel_fee', '代理三方提现手续费', $agentFees->channel_fee, $agentFees->channel_fee_people),
+        ];
+        $incomeTotal = self::sumItems($income);
+        $costTotal = self::sumItems($cost);
+
+        return [
+            'income' => $income,
+            'cost' => $cost,
+            'income_total' => $incomeTotal,
+            'cost_total' => $costTotal,
+            'net' => bcsub($incomeTotal, $costTotal, 2),
+            'unrecorded_agent_payout_count' => (int)$agentFees->unrecorded,
+        ];
+    }
+
+    /**
+     * 代理侧手续费:代理在线充值的三方手续费(平台成本)、代理提现向代理收的手续费(收入)和三方代付成本(平台成本)。
+     * 代理提现的历史订单没有记录三方成本(channel_fee 为 NULL),计入 unrecorded。
+     */
+    private static function agentFeeAggregates(?Carbon $start, ?Carbon $end): object
+    {
+        $result = ['deposit_fee' => 0, 'deposit_fee_people' => 0, 'withdraw_fee' => 0, 'withdraw_fee_people' => 0,
+            'channel_fee' => 0, 'channel_fee_people' => 0, 'unrecorded' => 0];
+        if (Schema::hasTable('agent_payment_orders')) {
+            $deposit = self::applyDateRange(DB::table('agent_payment_orders')
+                ->where('type', PaymentOrderService::TYPE_PAY)
+                ->where('status', PaymentOrderService::STATUS_SUCCESS)
+                ->where('fee', '>', 0), 'created_at', $start, $end)
+                ->selectRaw('COALESCE(SUM(fee), 0) AS deposit_fee, COUNT(DISTINCT agent_id) AS deposit_fee_people')
+                ->first();
+            $result = array_merge($result, (array)$deposit);
+        }
+        if (Schema::hasTable('agent_withdrawals') && Schema::hasColumn('agent_withdrawals', 'channel_fee')) {
+            $withdraw = self::applyDateRange(DB::table('agent_withdrawals')->where('status', 'paid'), 'paid_at', $start, $end)
+                ->selectRaw(<<<'SQL'
+                    COALESCE(SUM(fee), 0) AS withdraw_fee,
+                    COUNT(DISTINCT CASE WHEN fee > 0 THEN agent_id END) AS withdraw_fee_people,
+                    COALESCE(SUM(channel_fee), 0) AS channel_fee,
+                    COUNT(DISTINCT CASE WHEN channel_fee > 0 THEN agent_id END) AS channel_fee_people,
+                    COUNT(CASE WHEN channel_fee IS NULL THEN 1 END) AS unrecorded
+                SQL)
+                ->first();
+            $result = array_merge($result, (array)$withdraw);
+        }
+
+        return (object)$result;
+    }
+
     private static function balanceAggregates(?Carbon $start, ?Carbon $end): object
     {
         $query = DB::table('balance_logs')
@@ -187,9 +256,7 @@ class FundsSummaryService
                 COUNT(DISTINCT CASE WHEN type = {$payout} AND fee > 0 THEN member_id END)
                     AS auto_withdrawal_fee_people,
                 COALESCE(SUM(CASE WHEN type = {$manualPayout} THEN amount ELSE 0 END), 0)
-                    AS manual_withdrawal_amount,
-                COALESCE(SUM(CASE WHEN type = {$manualPayout} AND fee > 0 THEN fee ELSE 0 END), 0)
-                    AS manual_withdrawal_fee_amount
+                    AS manual_withdrawal_amount
             SQL)
             ->first();
     }
@@ -205,12 +272,6 @@ class FundsSummaryService
             ->where('w.service_charge', '>', 0)
             ->selectRaw("'member_withdraw_deduction' AS category")
             ->addSelect('bl.member_id as member_id');
-        $manualPayoutFees = self::applyDateRange(DB::table('payment_orders as po')
-            ->where('po.type', PaymentOrderService::TYPE_SELF_PAYOUT)
-            ->where('po.status', PaymentOrderService::STATUS_SUCCESS)
-            ->where('po.fee', '>', 0), 'po.created_at', $start, $end)
-            ->selectRaw("'member_withdraw_deduction' AS category")
-            ->addSelect('po.member_id as member_id');
         $manualDebits = self::applyDateRange(DB::table('balance_logs as bl')
             ->where('bl.change_type', '人工扣款')
             ->where('bl.amount', '<', 0), 'bl.created_at', $start, $end)
@@ -223,7 +284,6 @@ class FundsSummaryService
             ->addSelect('po.member_id as member_id');
 
         $union = $withdrawFees
-            ->unionAll($manualPayoutFees)
             ->unionAll($manualDebits)
             ->unionAll($manualPayouts);
         $rows = DB::query()

+ 20 - 5
app/Services/PaymentOrderService.php

@@ -45,8 +45,23 @@ class PaymentOrderService extends BaseService
     const PAY_TIMEOUT_MINUTES = 30;
     const PAY_OPEN_STATUSES = [self::STATUS_PROCESS, self::STATUS_TIMEOUT]; // 在线充值可接收回调/查询入账的状态
 
+    const PAYOUT_FIXED_FEE = 2; // 三方代付每笔固定手续费
+
     public static string $MODEL = PaymentOrder::class;
 
+    /**
+     * 三方手续费快照:fee 是三方向平台收的成本(= 金额 × 费率 + 固定费),不扣客户。费率为小数(0.02 = 2%)。
+     * @return array{fee_rate: string, fee_fixed: string, fee: string}
+     */
+    public static function feeFields($amount, $rate, $fixed = 0): array
+    {
+        $decimal = static fn ($value, int $scale): string => is_string($value) && is_numeric($value)
+            ? bcadd($value, '0', $scale) : number_format((float)$value, $scale, '.', '');
+        $rate = $decimal($rate, 6);
+        $fixed = $decimal($fixed, 4);
+        return ['fee_rate' => $rate, 'fee_fixed' => $fixed, 'fee' => bcadd(bcmul($decimal($amount, 4), $rate, 8), $fixed, 8)];
+    }
+
     /**
      * @description: 模型
      * @return {string}
@@ -317,7 +332,7 @@ class PaymentOrderService extends BaseService
             $data['member_id'] = $memberId;
             $data['amount'] = ZimuPayService::amount($amount);
             $data['channel'] = $channel;
-            $data['fee'] = $amount * $rate;
+            $data += self::feeFields($amount, $rate);
             $data['bank_name'] = $bankName;
             $order_no = self::createOrderNo($orderPrefix . 'zm' . $data['type'] . '_', $memberId);
             $data['order_no'] = $order_no;
@@ -366,7 +381,7 @@ class PaymentOrderService extends BaseService
             $data['member_id'] = $memberId;
             $data['amount'] = NoPayService::amount($amount);
             $data['channel'] = $channel;
-            $data['fee'] = $amount * $rate;
+            $data += self::feeFields($amount, $rate);
             $data['bank_name'] = $bankName;
             $order_no = self::createOrderNo($orderPrefix . 'no' . $data['type'] . '_', $memberId);
             $data['order_no'] = $order_no;
@@ -414,7 +429,7 @@ class PaymentOrderService extends BaseService
             $data['member_id'] = $memberId;
             $data['amount'] = JdPayService::amount($amount);
             $data['channel'] = $channel;
-            $data['fee'] = $amount * $rate;
+            $data += self::feeFields($amount, $rate);
             $data['bank_name'] = $bankName;
             $order_no = self::createOrderNo($orderPrefix . 'jd' . $data['type'] . '_', $memberId);
             $data['order_no'] = $order_no;
@@ -457,7 +472,7 @@ class PaymentOrderService extends BaseService
         $data['member_id'] = $memberId;
         $data['amount'] = $amount;
         $data['channel'] = $channel;
-        $data['fee'] = $amount * $rate;
+        $data += self::feeFields($amount, $rate);
         $data['bank_name'] = SanJinService::getChannel($paymentType) ?? '';
         $order_no = self::createOrderNo($orderPrefix . 'sj' . $data['type'] . '_', $memberId);
         $data['order_no'] = $order_no;
@@ -952,7 +967,7 @@ class PaymentOrderService extends BaseService
             $order_no = self::createOrderNo('sj' . $data['type'] . '_', $memberId);
             $data['order_no'] = $order_no;
             $data['member_id'] = $memberId;
-            $data['fee'] = $amount * $channelRate + 2;
+            $data += self::feeFields($amount, $channelRate, self::PAYOUT_FIXED_FEE);
             $amount = number_format($amount, 2, '.', '');
             $data['amount'] = $amount;
             $data['channel'] = $channel;

+ 1 - 1
app/Services/QianBaoWithdrawService.php

@@ -479,7 +479,7 @@ class QianBaoWithdrawService
             $data['type'] = PaymentOrderService::TYPE_PAYOUT;
             $data['order_no'] = PaymentOrderService::createOrderNo('sj' . $data['type'] . '_', $memberId);
             $data['member_id'] = $memberId;
-            $data['fee'] = $amount * $channelRate + 2;
+            $data += PaymentOrderService::feeFields($amount, $channelRate, PaymentOrderService::PAYOUT_FIXED_FEE);
             $data['amount'] = number_format($amount, 2, '.', '');
             $data['channel'] = $channel;
             $data['bank_name'] = $bank_name;

+ 1 - 0
app/Services/Reporting/ReportSync.php

@@ -248,6 +248,7 @@ class ReportSync
             $kind = 'withdraw'; $amount = (int) $row['type'] === 2 ? Money::decimal($row['amount']) : null; $configuredFee = $row['fee'] ?? null;
         } elseif ($source === 'agent_withdrawals' && $row['status'] === 'paid') {
             $kind = 'agent_withdraw'; $amount = Money::decimal($row['actual_amount']); $customerFee = Money::decimal($row['fee'] ?? 0); $at = $this->time($row['paid_at'] ?? null);
+            $configuredFee = $row['channel_fee'] ?? null; // 三方代付成本,历史订单为 NULL(未记录)
         } elseif ($source === 'agent_balance_logs' && $row['type'] === 'commission' && $row['reference_type'] === 'agent_rebate_record') {
             $kind = 'agent_commission'; $amount = Money::decimal($row['amount']); $at = $candidate;
             $record = DB::table('agent_rebate_records')->where('id', $row['reference_id'])->first();

+ 58 - 0
database/migrations/2026_11_09_120000_add_fee_snapshots_to_payment_orders.php

@@ -0,0 +1,58 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+/**
+ * 充值/提现订单记录手续费率快照,方便统计公司手续费盈亏。费率统一存小数(0.02 = 2%)。
+ * - payment_orders / agent_payment_orders:fee 是三方向平台收的成本 = amount × fee_rate + fee_fixed,不扣客户;
+ * - agent_withdrawals:fee 是向代理收的手续费(收入),fee_rate 为当时的代理提现费率;
+ *   channel_fee* 是三方代付向平台收的成本,USDT / 人工提现为 0,NULL 表示历史订单未记录。
+ * 历史数据按已记录的 fee 反推费率;代理提现的历史三方成本无法还原,保留 NULL。
+ */
+return new class extends Migration {
+    public function up(): void
+    {
+        foreach (['payment_orders', 'agent_payment_orders'] as $table) {
+            if (!Schema::hasTable($table) || Schema::hasColumn($table, 'fee_rate')) continue;
+            Schema::table($table, function (Blueprint $t) {
+                $t->decimal('fee_rate', 10, 6)->default(0)->comment('三方手续费率快照(小数)');
+                $t->decimal('fee_fixed', 18, 4)->default(0)->comment('三方每笔固定手续费');
+            });
+            if (!Schema::hasColumns($table, ['type', 'amount', 'fee'])) continue;
+            // 三方代付(type 2)按 金额 × 费率 + 2 记录;其余类型 fee = 金额 × 费率。
+            DB::table($table)->where('type', 2)->where('fee', '>=', 2)->where('amount', '>', 0)
+                ->update(['fee_fixed' => 2, 'fee_rate' => DB::raw('ROUND((fee - 2) * 1.0 / amount, 6)')]);
+            DB::table($table)->where('type', '<>', 2)->where('fee', '>', 0)->where('amount', '>', 0)
+                ->update(['fee_rate' => DB::raw('ROUND(fee * 1.0 / amount, 6)')]);
+        }
+
+        if (Schema::hasTable('agent_withdrawals') && !Schema::hasColumn('agent_withdrawals', 'fee_rate')) {
+            Schema::table('agent_withdrawals', function (Blueprint $t) {
+                $t->decimal('fee_rate', 10, 6)->default(0)->comment('向代理收取的提现费率快照(小数)');
+                $t->decimal('channel_fee_rate', 10, 6)->nullable()->comment('三方代付费率快照(小数)');
+                $t->decimal('channel_fee_fixed', 18, 4)->nullable()->comment('三方代付每笔固定手续费');
+                $t->decimal('channel_fee', 18, 4)->nullable()->comment('三方代付成本,NULL 为未记录');
+            });
+            DB::table('agent_withdrawals')->where('fee', '>', 0)->where('amount', '>', 0)
+                ->update(['fee_rate' => DB::raw('ROUND(fee * 1.0 / amount, 6)')]);
+            // 早期订单 account_type 为空,按 USDT 处理(同 AgentWithdrawalService::audit)。
+            DB::table('agent_withdrawals')->whereIn('account_type', ['usdt', 'rgtx', ''])
+                ->update(['channel_fee_rate' => 0, 'channel_fee_fixed' => 0, 'channel_fee' => 0]);
+        }
+    }
+
+    public function down(): void
+    {
+        foreach (['payment_orders', 'agent_payment_orders'] as $table) {
+            if (Schema::hasColumn($table, 'fee_rate')) {
+                Schema::table($table, fn (Blueprint $t) => $t->dropColumn(['fee_rate', 'fee_fixed']));
+            }
+        }
+        if (Schema::hasColumn('agent_withdrawals', 'fee_rate')) {
+            Schema::table('agent_withdrawals', fn (Blueprint $t) => $t->dropColumn(['fee_rate', 'channel_fee_rate', 'channel_fee_fixed', 'channel_fee']));
+        }
+    }
+};

+ 73 - 0
docs/总后台/手续费费率与盈亏-前端接入.md

@@ -0,0 +1,73 @@
+# 总后台-订单手续费费率与手续费盈亏(前端接入)
+
+给总后台前端看。接口路径和返回结构不变,只新增字段。
+
+## 口径(页面文案需要用到)
+
+- 充值/提现通道的「费率」是**第三方向平台收的手续费**,平台承担,**不扣客户**:充值全额到账,三方提现全额出款。
+- 向客户收的手续费只有两种:会员 **USDT 提现**的固定手续费(`service_charge`,单位 U,系统配置),以及**代理提现**按代理提现费率收的手续费。
+- 人工提现(rgtx)暂不记手续费,固定为 0。USDT 链上成本不计。
+- 下面所有 `*_rate` 字段都是**小数**:`0.020000` 表示 2%,展示时乘以 100。
+
+## 1. 会员充值 / 提现订单(`payment_orders` 行)
+
+`GET /admin/paymentOrder` 等返回 payment_orders 行的列表,新增:
+
+| 字段 | 说明 |
+|---|---|
+| `fee_rate` | 三方手续费率快照,下单时的通道费率 |
+| `fee_fixed` | 三方每笔固定手续费,三方提现为 2,其他为 0 |
+| `fee` | 三方手续费 = 金额 × `fee_rate` + `fee_fixed`(原有字段,含义不变) |
+
+建议列名:「三方费率」「三方手续费」。历史订单的 `fee_rate` 由已记录的 `fee` 反推。
+
+## 2. 代理人民币充值 `GET /admin/agent/payment-orders`
+
+新增 `fee_rate`、`fee_fixed`,含义同上。代理端接口不再返回 `fee` / `fee_rate` / `fee_fixed`。
+
+## 3. 代理提现 `GET /admin/agent/withdrawals`
+
+| 字段 | 说明 |
+|---|---|
+| `fee` | 向代理收的提现手续费(收入),原有字段 |
+| `fee_rate` | 代理提现费率快照(小数) |
+| `channel_fee_rate` | 三方代付费率快照(小数) |
+| `channel_fee_fixed` | 三方代付每笔固定手续费(2) |
+| `channel_fee` | 平台付给三方的成本 = 到账金额 × `channel_fee_rate` + `channel_fee_fixed`;USDT / 人工提现为 0;**`null` 表示历史订单未记录**,显示「未记录」 |
+
+建议分两列:「代理手续费」(`fee`)和「三方成本」(`channel_fee`)。`channel_*` 字段只在总后台返回。
+
+## 4. 资金汇总 `GET /admin/funds/summary`
+
+- 收入里的「会员出款扣除」(`member_withdraw_deduction`)现在只含 USDT 提现手续费,不再把人工提现的 `fee` 算进去。
+- 支出 `expense` 里平台承担的三方手续费(都计入 `expense_total`):
+
+| key | 名称 | 说明 |
+|---|---|---|
+| `third_party_fee` | 第三方支付(入款)手续费 | 会员三方充值,原有项,名称由「第三方手续费」改为此名 |
+| `third_party_auto_withdrawal_fee` | 第三方支付自动出款手续费 | 会员三方提现,原有项 |
+| `agent_deposit_channel_fee` | 代理三方充值手续费 | **新增** |
+| `agent_payout_channel_fee` | 代理三方提现手续费 | **新增**,历史代理提现未记录成本的不计入 |
+
+- 新增 `fee_summary`:
+
+```json
+{
+  "income": [
+    {"key": "member_usdt_withdraw_fee", "label": "会员USDT提现手续费", "amount": "20.00", "people": 3},
+    {"key": "agent_withdraw_fee", "label": "代理提现手续费", "amount": "15.00", "people": 1}
+  ],
+  "cost": [
+    {"key": "member_deposit_channel_fee", "label": "会员三方充值手续费", "amount": "120.00", "people": 9},
+    {"key": "member_payout_channel_fee", "label": "会员三方提现手续费", "amount": "34.00", "people": 4},
+    {"key": "agent_deposit_channel_fee", "label": "代理三方充值手续费", "amount": "10.00", "people": 1},
+    {"key": "agent_payout_channel_fee", "label": "代理三方提现手续费", "amount": "4.00", "people": 1}
+  ],
+  "income_total": "35.00",
+  "cost_total": "168.00",
+  "net": "-133.00",
+  "unrecorded_agent_payout_count": 0
+}
+```
+
+`net` 为负表示手续费净支出。`unrecorded_agent_payout_count > 0` 时提示「有 N 笔代理三方提现未记录成本,三方成本偏低」。

+ 31 - 0
tests/Integration/AgentPaymentOrderTest.php

@@ -184,9 +184,13 @@ class AgentPaymentOrderTest extends TestCase
         $this->payments();
         $this->useGroup(2);
         $agent = $this->agent();
+        DB::table('recharge_channel')->where('data_type', 1)->where('type', 'JDpay')->update(['rate' => '0.0200']);
         $history = $this->fakeJd(['code' => 200, 'data' => ['orderNo' => 'JD-PAY-1', 'url' => '']]);
         $result = $this->payments->createOnline($agent, '100.50', 'JDpay');
         $order = $result['order'];
+        // 三方手续费是平台成本:记录费率快照,代理不可见,入账仍是全额。
+        $this->assertSame(['0.020000', '0.0000', '2.0100'], [bcadd((string) $order->fee_rate, '0', 6), bcadd((string) $order->fee_fixed, '0', 4), $order->fee]);
+        $this->assertArrayNotHasKey('fee', $this->payments->paginate([], $agent)['list'][0]->toArray(), 'channel cost is hidden from the agent');
         $this->assertStringStartsWith('agjd1_', $order->order_no);
         parse_str((string) $history[0]['request']->getBody(), $sent);
         $this->assertSame($order->order_no, $sent['orderCode']);
@@ -205,12 +209,14 @@ class AgentPaymentOrderTest extends TestCase
     {
         $this->payments();
         $this->fullPaymentOrders();
+        DB::table('recharge_channel')->where('data_type', 1)->where('type', 'JDpay')->update(['rate' => '0.0150']);
         $this->fakeJd(['code' => 200, 'data' => ['orderNo' => 'JD-PAY-2', 'url' => '']]);
         $result = PaymentOrderService::createPayOrder(PaymentOrder::class, 'M1001', 100, 'JDpay', 2,
             RechargeChannel::checkRechargeChannel('JDpay', 2));
         $this->assertSame(0, $result['code']);
         $this->assertStringStartsWith('jd1_', $result['order_no']);
         $found = PaymentOrderService::findPayOrder($result['order_no']);
+        $this->assertSame(['0.015000', '1.5000'], [bcadd((string) $found->fee_rate, '0', 6), bcadd((string) $found->fee, '0', 4)]);
         $this->assertSame(PaymentOrder::class, get_class($found));
         $this->assertSame('M1001', $found->member_id);
         $this->assertSame(0, DB::table('agent_payment_orders')->count());
@@ -221,6 +227,31 @@ class AgentPaymentOrderTest extends TestCase
         $this->assertNotInstanceOf(AgentPaymentOrder::class, PaymentOrderService::findPayOrder('ag-member-1'));
     }
 
+    public function test_fee_snapshot_migration_backfills_rates_from_recorded_fees(): void
+    {
+        $this->payments();
+        $this->fullPaymentOrders();
+        $migration = require dirname(__DIR__, 2) . '/database/migrations/2026_11_09_120000_add_fee_snapshots_to_payment_orders.php';
+        $migration->down();
+        $order = fn (int $type, string $amount, string $fee) => ['type' => $type, 'order_no' => 'o' . $type . $amount, 'member_id' => 'M1',
+            'amount' => $amount, 'fee' => $fee, 'status' => 2];
+        DB::table('payment_orders')->insert([$order(1, '100', '2'), $order(2, '100', '3'), $order(4, '50', '50'), $order(3, '80', '0')]);
+        DB::table('agent_payment_orders')->insert(['agent_id' => 1, 'type' => 1, 'order_no' => 'ag1', 'amount' => '200', 'fee' => '1']);
+        $withdrawal = fn (string $channel, string $no) => ['order_no' => $no, 'request_key' => $no, 'agent_id' => 1, 'account_type' => $channel,
+            'account_snapshot' => '{}', 'amount' => '100', 'fee' => '1', 'actual_amount' => '99', 'status' => 'paid'];
+        DB::table('agent_withdrawals')->insert([$withdrawal('JDpay', 'w1'), $withdrawal('usdt', 'w2'), $withdrawal('', 'w3')]);
+        $migration->up();
+
+        $rates = fn (string $table) => DB::table($table)->orderBy('id')->get()
+            ->map(fn ($row) => [bcadd((string) $row->fee_rate, '0', 6), bcadd((string) $row->fee_fixed, '0', 4)])->all();
+        $this->assertSame([['0.020000', '0.0000'], ['0.010000', '2.0000'], ['1.000000', '0.0000'], ['0.000000', '0.0000']], $rates('payment_orders'));
+        $this->assertSame([['0.005000', '0.0000']], $rates('agent_payment_orders'));
+        $rows = DB::table('agent_withdrawals')->orderBy('id')->get();
+        $this->assertSame(['0.010000', '0.010000', '0.010000'], $rows->map(fn ($row) => bcadd((string) $row->fee_rate, '0', 6))->all());
+        $this->assertSame([null, '0', '0'], $rows->map(fn ($row) => $row->channel_fee === null ? null : bcadd((string) $row->channel_fee, '0', 0))->all(),
+            'historical provider cost is unknown; USDT and manual payouts have none');
+    }
+
     public function test_manual_recharge_respects_channel_limits_and_can_be_cancelled_to_free_a_slot(): void
     {
         $this->payments();

+ 5 - 0
tests/Integration/AgentWithdrawalChannelTest.php

@@ -108,6 +108,10 @@ class AgentWithdrawalChannelTest extends TestCase
         $withdrawal = $this->withdrawals->request($agent, $account['id'], '100.00', 'withdraw-old', 'jd-1');
         $this->assertSame(['JDpay', '100.0000', '1.0000', '99.0000', 'pending'],
             [$withdrawal->channel, $withdrawal->amount, $withdrawal->fee, $withdrawal->actual_amount, $withdrawal->status]);
+        // 代理手续费 1% 是收入;平台付给 JD 的成本 = 到账 99 × 0.2% + 2,只给总后台看。
+        $this->assertSame(['0.010000', '0.002000', '2.0000', '2.1980'],
+            [$withdrawal->fee_rate, $withdrawal->channel_fee_rate, $withdrawal->channel_fee_fixed, $withdrawal->channel_fee]);
+        $this->assertArrayNotHasKey('channel_fee', $withdrawal->toArray(), 'channel cost is admin-only');
         $this->assertSame(['900.0000', '100.0000'], [$agent->fresh()->balance, $agent->fresh()->frozen_balance]);
 
         $history = $this->fakeJd(['code' => 200, 'data' => ['balance' => '5000']], ['code' => 200, 'data' => ['orderNo' => 'JDR-1']]);
@@ -164,6 +168,7 @@ class AgentWithdrawalChannelTest extends TestCase
         $withdrawal = $this->withdrawals->request($agent, $account['id'], '500', 'withdraw-old', 'manual');
         $this->assertSame('张三', $withdrawal->account_snapshot['holder_name']);
         $this->assertSame('https://example.test/qr.png', $withdrawal->account_snapshot['qr_image']);
+        $this->assertSame(['0.000000', '0.0000'], [$withdrawal->channel_fee_rate, $withdrawal->channel_fee], 'manual payout has no channel cost');
         $this->assertSame('approved', $this->withdrawals->audit($withdrawal->id, 'approve', 1)->status, 'no provider call for manual payout');
         $this->assertSame('paid', $this->withdrawals->audit($withdrawal->id, 'paid', 1)->status);
         $this->assertSame('0.0000', $agent->fresh()->frozen_balance);

+ 3 - 0
tests/Integration/ApiCryptoTest.php

@@ -268,6 +268,7 @@ class ApiCryptoTest extends TestCase
             self::assertSame($qr, $result['data']['qr_image']);
             $stored = DB::table('payment_orders')->where('id', $result['data']['id'])->first();
             self::assertSame(4, (int) $stored->type);
+            self::assertSame([0.0, 0.0], [(float) $stored->fee, (float) $stored->fee_rate], '人工提现暂不记手续费');
             self::assertSame($bankName, $stored->bank_name);
             self::assertSame('Fixture Name', $stored->account);
             self::assertSame($cardNo, $stored->card_no);
@@ -668,6 +669,8 @@ class ApiCryptoTest extends TestCase
             $table->string('remark')->nullable();
             $table->decimal('amount');
             $table->decimal('fee');
+            $table->decimal('fee_rate', 10, 6)->default(0);
+            $table->decimal('fee_fixed', 18, 4)->default(0);
             $table->timestamps();
         });
         Schema::create('backflow', function (Blueprint $table) {

+ 34 - 0
tests/Integration/ReportingTest.php

@@ -841,6 +841,40 @@ class ReportingTest extends TestCase
         $this->assertSame('0.00', $expense['third_party_fee']['amount']);
     }
 
+    public function test_fee_summary_separates_customer_fees_from_channel_costs(): void
+    {
+        DB::statement('CREATE TABLE bot_agent_payment_orders (id INTEGER PRIMARY KEY,agent_id INTEGER,type INTEGER,amount TEXT,fee TEXT,status INTEGER,created_at TEXT,updated_at TEXT)');
+        DB::statement('CREATE TABLE bot_agent_withdrawals (id INTEGER PRIMARY KEY,agent_id INTEGER,amount TEXT,fee TEXT,actual_amount TEXT,channel_fee TEXT,status TEXT,paid_at TEXT,created_at TEXT,updated_at TEXT)');
+        $at = '2026-10-26 10:00:00';
+        DB::table('payment_orders')->insert([
+            ['id' => 1, 'member_id' => '200', 'type' => 1, 'amount' => '1000', 'fee' => '20', 'status' => 2, 'created_at' => $at, 'updated_at' => $at],
+            ['id' => 2, 'member_id' => '200', 'type' => 2, 'amount' => '500', 'fee' => '7', 'status' => 2, 'created_at' => $at, 'updated_at' => $at],
+            // 历史人工提现记录过 fee,但并没有向会员收取,不能算作收入。
+            ['id' => 3, 'member_id' => '300', 'type' => 4, 'amount' => '200', 'fee' => '200', 'status' => 2, 'created_at' => $at, 'updated_at' => $at],
+            ['id' => 4, 'member_id' => '300', 'type' => 1, 'amount' => '900', 'fee' => '50', 'status' => 3, 'created_at' => $at, 'updated_at' => $at],
+        ]);
+        DB::table('withdraws')->insert(['id' => 1, 'member_id' => '200', 'amount' => '100', 'to_account' => '98', 'service_charge' => '2', 'exchange_rate' => '7', 'status' => 1, 'created_at' => $at, 'updated_at' => $at]);
+        DB::table('balance_logs')->insert(['member_id' => '200', 'amount' => '-700', 'change_type' => '提现', 'related_id' => 1, 'created_at' => $at, 'updated_at' => $at]);
+        DB::table('agent_payment_orders')->insert(['id' => 1, 'agent_id' => 10, 'type' => 1, 'amount' => '300', 'fee' => '3', 'status' => 2, 'created_at' => $at, 'updated_at' => $at]);
+        DB::table('agent_withdrawals')->insert([
+            ['id' => 1, 'agent_id' => 10, 'amount' => '100', 'fee' => '1', 'actual_amount' => '99', 'channel_fee' => '2.99', 'status' => 'paid', 'paid_at' => $at, 'created_at' => $at, 'updated_at' => $at],
+            ['id' => 2, 'agent_id' => 20, 'amount' => '500', 'fee' => '5', 'actual_amount' => '495', 'channel_fee' => null, 'status' => 'paid', 'paid_at' => $at, 'created_at' => $at, 'updated_at' => $at],
+            ['id' => 3, 'agent_id' => 20, 'amount' => '500', 'fee' => '5', 'actual_amount' => '495', 'channel_fee' => '3', 'status' => 'pending', 'paid_at' => null, 'created_at' => $at, 'updated_at' => $at],
+        ]);
+
+        $finance = \App\Services\FundsSummaryService::summary('2026-10-26', '2026-10-26');
+        $this->assertSame('14.00', collect($finance['income'])->keyBy('key')['member_withdraw_deduction']['amount'], 'only USDT service charges are deducted from members');
+        // 平台承担的三方手续费(会员和代理的充值、提现)都计入支出。
+        $expense = collect($finance['expense'])->keyBy('key');
+        $this->assertSame(['20.00', '7.00', '3.00', '2.99'], [$expense['third_party_fee']['amount'], $expense['third_party_auto_withdrawal_fee']['amount'],
+            $expense['agent_deposit_channel_fee']['amount'], $expense['agent_payout_channel_fee']['amount']]);
+        $this->assertSame([1, 1], [$expense['agent_deposit_channel_fee']['people'], $expense['agent_payout_channel_fee']['people']]);
+        $fees = $finance['fee_summary'];
+        $this->assertSame(['14.00', '6.00'], array_column($fees['income'], 'amount'));
+        $this->assertSame(['20.00', '7.00', '3.00', '2.99'], array_column($fees['cost'], 'amount'));
+        $this->assertSame(['20.00', '32.99', '-12.99', 1], [$fees['income_total'], $fees['cost_total'], $fees['net'], $fees['unrecorded_agent_payout_count']]);
+    }
+
     public function test_missing_platform_rates_are_unknown_zero_rates_are_valid_and_cash_flow_is_preserved(): void
     {
         $this->commissionOrders();

+ 3 - 1
tests/Support/AgentTestEnvironment.php

@@ -148,7 +148,8 @@ trait AgentTestEnvironment
         foreach (['2026_09_08_160000_add_payment_provider_status_and_channel_soft_deletes.php',
             '2026_09_08_170000_add_soft_deletes_to_recharge_channel_groups.php',
             '2026_10_28_150000_add_recharge_channel_logo.php', '2026_10_28_160000_create_recharge_method_logos.php',
-            '2026_11_08_120000_create_agent_payment_orders.php', '2026_11_08_170000_add_agent_withdrawal_channels.php'] as $file) {
+            '2026_11_08_120000_create_agent_payment_orders.php', '2026_11_08_170000_add_agent_withdrawal_channels.php',
+            '2026_11_09_120000_add_fee_snapshots_to_payment_orders.php'] as $file) {
             (require dirname(__DIR__, 2) . '/database/migrations/' . $file)->up();
         }
     }
@@ -160,6 +161,7 @@ trait AgentTestEnvironment
         Schema::create('payment_orders', function (Blueprint $t) {
             $t->id(); $t->tinyInteger('type'); $t->string('order_no')->unique(); $t->string('member_id');
             $t->decimal('amount', 18, 2); $t->decimal('fee', 18, 4)->default(0); $t->string('channel')->nullable();
+            $t->decimal('fee_rate', 10, 6)->default(0); $t->decimal('fee_fixed', 18, 4)->default(0);
             $t->string('bank_name')->nullable(); $t->integer('payment_type')->nullable(); $t->tinyInteger('status')->default(0);
             $t->string('state')->default('0'); $t->string('callback_url')->nullable(); $t->text('callback_data')->nullable();
             $t->string('pay_no')->nullable(); $t->text('pay_url')->nullable(); $t->text('pay_data')->nullable();