laowu 3 päivää sitten
vanhempi
sitoutus
385ef9c36d

+ 1 - 0
app/Http/Controllers/Admin/ChannelController.php

@@ -894,6 +894,7 @@ class ChannelController
             105 => 'AiNewPayOut',
             106 => 'SafePayOut',
             107 => 'BotImPayOut',
+            109 => 'PayPlusOut',
         ];
 
         return $map[(int)$agent] ?? null;

+ 108 - 0
app/Http/Controllers/Api/PayPlusController.php

@@ -0,0 +1,108 @@
+<?php
+
+namespace App\Http\Controllers\Api;
+
+use App\Http\logic\api\PayPlusCashierLogic;
+use App\Http\logic\api\PayPlusLogic;
+use App\Inter\PayMentInterFace;
+use App\Notification\TelegramBot;
+use App\Services\PayPlus;
+use App\Util;
+use App\Utility\SetNXLock;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Facades\Redis;
+
+class PayPlusController implements PayMentInterFace
+{
+    public function pay_order($userId, $payAmt, $userName, $userEmail, $userPhone, $GiftsID, $buyIP, $AdId, $eventType, $pay_method = '')
+    {
+        $logic = new PayPlusLogic();
+
+        try {
+            $res = $logic->pay_order($userId, $payAmt, $userPhone, $userEmail, $userName, $GiftsID, $buyIP, $AdId, $eventType, $pay_method);
+        } catch (\Exception $exception) {
+            Redis::set('PayErro_PayPlus', 1);
+            Redis::expire('PayErro_PayPlus', 600);
+            Util::WriteLog('PayPlus_error', $exception->getMessage());
+            TelegramBot::getDefault()->sendProgramNotify('PayPlus Except ', $exception->getMessage(), $exception);
+
+            return apiReturnFail($logic->getError() ?: 'Payment processing error');
+        }
+
+        if (!empty($res) && isset($res['code']) && (int) $res['code'] == 200 && !empty($res['decryptedComponentDelta']['link'])) {
+            
+            return apiReturnSuc([
+                'content' => $res['decryptedComponentDelta']['link'],
+                'money' => $payAmt,
+                'prdOrdNo' => $res['decryptedComponentDelta']['order_id'] ?? '',
+            ]);
+        }
+
+        return apiReturnFail($logic->getError() ?: 'Payment processing error');
+    }
+
+    public function notify(Request $request)
+    {
+        $post = $request->all();
+        $content = (string) $request->getContent();
+        $post = json_decode($content, true) ?: $post;
+        Util::WriteLog('PayPlus', 'pay notify: ' . $content);
+        Util::WriteLog('PayPlus', 'pay notify header: ' . json_encode($request->headers->all()));
+
+        $orderSn = $post['data']['platform_order_id'] ?? '';
+        if ($orderSn === '') {
+            return 'success';
+        }
+
+        $lockKey = 'pay_notify_PayPlus_' . $orderSn;
+        if (!SetNXLock::getExclusiveLock($lockKey, 60)) {
+            return 'success';
+        }
+
+        try {
+            return (new PayPlusLogic())->notify($post);
+        } catch (\Exception $exception) {
+            Redis::set('PayErro_PayPlus', 1);
+            Redis::expire('PayErro_PayPlus', 600);
+            TelegramBot::getDefault()->sendProgramNotify('PayPlus notify exception', json_encode($post), $exception);
+            Util::WriteLog('PayPlus_error', $exception->getMessage());
+
+            return 'success';
+        } finally {
+            SetNXLock::release($lockKey);
+        }
+    }
+
+    public function sync_notify(Request $request)
+    {
+        Log::info('PayPlus sync notify');
+
+        return 'PayPlus sync notify';
+    }
+
+    public function cash_notify(Request $request)
+    {
+        $post = $request->all();
+        $content = (string) $request->getContent();
+        $post = json_decode($content, true) ?: $post;
+        Util::WriteLog('PayPlus', 'cash notify: ' . $content);
+        Util::WriteLog('PayPlus', 'cash notify header: ' . json_encode($request->headers->all()));
+        $signature = $request->header('Authorization', '');
+        $service = (new PayPlus())->getPayoutService();
+        if (!$service->verifyPayoutSignature($post, $signature)) {
+            Util::WriteLog('PayPlus', 'cash notify verify failed');
+
+            return 'success';
+        }
+
+        try {
+            (new PayPlusCashierLogic($service))->notify($post);
+        } catch (\Exception $exception) {
+            TelegramBot::getDefault()->sendProgramNotify('PayPlus cash notify exception', json_encode($post), $exception);
+            Util::WriteLog('PayPlus_error', $exception->getMessage());
+        }
+
+        return 'success';
+    }
+}

+ 260 - 0
app/Http/logic/api/PayPlusCashierLogic.php

@@ -0,0 +1,260 @@
+<?php
+
+namespace App\Http\logic\api;
+
+use App\dao\Estatisticas\RechargeWithDraw;
+use App\Http\helper\NumConfig;
+use App\Inter\CashierInterFace;
+use App\Models\PrivateMail;
+use App\Models\RecordUserDataStatistics;
+use App\Services\PayPlus;
+use App\Services\StoredProcedure;
+use App\Util;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Redis;
+
+class PayPlusCashierLogic implements CashierInterFace
+{
+    const AGENT = 109;
+
+    protected $service;
+
+    public function __construct(PayPlus $service = null)
+    {
+        $this->service = $service ?: (new PayPlus())->getPayoutService();
+    }
+
+    public function payment(
+        $RecordID,
+        $amount,
+        $accountName,
+        $phone,
+        $email,
+        $OrderId,
+        $PixNum,
+        $PixType,
+        $IFSCNumber,
+        $BranchBank,
+        $BankNO
+    ) {
+        $query = DB::connection('write')
+            ->table('QPAccountsDB.dbo.OrderWithDraw')
+            ->where('RecordID', $RecordID)
+            ->first();
+        if (!$query) {
+            return 'fail';
+        }
+
+        $result = $this->createBeneficiaryAndPayout([
+            'user_id' => $query->UserID,
+            'amount' => round($amount / NumConfig::NUM_VALUE, 2),
+            'currency' => $this->service->getConfig()['currency'] ?? 'USD',
+            'order_id' => $OrderId,
+            'pix_type' => $PixType,
+            'cashapp_account' => $PixNum,
+            'email' => $email,
+            'phone' => $phone,
+            'name' => $accountName,
+        ]);
+
+        if ($result && isset($result['code']) && (int) $result['code'] === 200) {
+            return $result;
+        }
+
+        return 'fail';
+    }
+
+    public function createBeneficiaryAndPayout(array $input)
+    {
+        $beneficiary = $this->resolveBeneficiary($input);
+        if (!$beneficiary) {
+            return false;
+        }
+
+        $payload = [
+            'amount' => (float) $input['amount'],
+            'currency' => strtoupper($input['currency'] ?? ($this->service->getConfig()['currency'] ?? 'USD')),
+            'reference_id' => (string) $input['order_id'],
+            'beneficiary_id' => (int) $beneficiary['beneficiary_id'],
+        ];
+
+        if ((int) ($input['pix_type'] ?? 1) === 2) {
+            $payload['paypal_email'] = $input['email'] ?: $beneficiary['email'];
+            return $this->service->postPayout('/rest/v2/payouts/paypal', $payload);
+        }
+
+        $cashAppAccount = (string) ($input['cashapp_account'] ?? '');
+        if ($cashAppAccount !== '' && strpos($cashAppAccount, '$') !== 0) {
+            $cashAppAccount = '$' . $cashAppAccount;
+        }
+
+        $payload['cashapp_account'] = $cashAppAccount ?: '$unknown';
+
+        return $this->service->postPayout('/rest/v2/payouts/cashApp', $payload);
+    }
+
+    public function resolveBeneficiary(array $input)
+    {
+        $payload = $this->service->buildBeneficiaryPayload($input);
+        $created = $this->service->postPayout('/rest/v2/beneficiaries/create', $payload);
+
+        if ((int) ($created['code'] ?? 0) === 200 && !empty($created['data']['beneficiary_id'])) {
+            return $created['data'];
+        }
+
+        if ((int) ($created['code'] ?? 0) !== 4002001) {
+            Util::WriteLog('PayPlus_error', 'beneficiary create failed: ' . json_encode($created));
+            return false;
+        }
+
+        $query = $this->service->postPayout('/rest/v2/beneficiaries/query', [
+            'payee_id' => $payload['payee_id'],
+        ]);
+
+        if ((int) ($query['code'] ?? 0) === 200 && !empty($query['data']['beneficiary_id'])) {
+            return $query['data'];
+        }
+
+        Util::WriteLog('PayPlus_error', 'beneficiary query failed: ' . json_encode($query));
+
+        return false;
+    }
+
+    public function notify($post)
+    {
+        if (!is_array($post)) {
+            $post = json_decode($post, true) ?: [];
+        }
+
+        $detail = $this->queryPayoutDetailForNotify($post);
+        if (empty($detail)) {
+            return 'success';
+        }
+
+        $orderId = $detail['reference_id'] ?? ($post['reference_id'] ?? '');
+        if ($orderId === '') {
+            return 'success';
+        }
+
+        $query = DB::connection('write')
+            ->table('QPAccountsDB.dbo.OrderWithDraw')
+            ->where('OrderId', $orderId)
+            ->first();
+        if (!$query || ($query->State != 5 && $query->State != 7)) {
+            return 'success';
+        }
+
+        $orderStatus = $this->resolvePayoutStatus($detail['transaction_status'] ?? '');
+        if (!$orderStatus) {
+            return 'success';
+        }
+
+        $agent = DB::connection('write')->table('agent.dbo.admin_configs')
+            ->where('config_value', self::AGENT)
+            ->where('type', 'cash')
+            ->select('id')
+            ->first();
+        $agentId = $agent->id ?? '';
+
+        $now = now();
+        $userId = $query->UserID;
+        $takeMoney = $query->WithDraw + $query->ServiceFee;
+        $withdrawData = [
+            'agent' => $agentId,
+            'finishDate' => $now,
+        ];
+
+        if ($orderStatus === 1) {
+            $withdrawData['State'] = 2;
+            $this->handleSuccess($query, $takeMoney);
+        } else {
+            $withdrawData['State'] = 6;
+            $failedMessage = $detail['description'] ?? ($post['msg'] ?? 'REJECTED');
+            PrivateMail::failMail($userId, $orderId, $takeMoney, $failedMessage, '30000,' . $takeMoney);
+        }
+
+        DB::connection('write')->table('QPAccountsDB.dbo.AccountsRecord')->updateOrInsert(
+            ['RecordID' => $query->RecordID, 'type' => 1],
+            [
+                'before_state' => $query->State,
+                'after_state' => $withdrawData['State'],
+                'RecordID' => $query->RecordID,
+                'update_at' => date('Y-m-d H:i:s'),
+            ]
+        );
+        DB::connection('write')
+            ->table('QPAccountsDB.dbo.OrderWithDraw')
+            ->where('OrderId', $query->OrderId)
+            ->update($withdrawData);
+
+        return 'success';
+    }
+
+    public function queryPayoutDetailForNotify(array $post)
+    {
+        $transactionId = (string) ($post['transaction_id'] ?? '');
+        $referenceId = (string) ($post['reference_id'] ?? '');
+        if ($transactionId === '' && $referenceId === '') {
+            return [];
+        }
+
+        try {
+            $result = $this->service->queryPayoutOrder($transactionId, $referenceId);
+        } catch (\Exception $exception) {
+            Util::WriteLog('PayPlus_error', 'payout query failed: ' . $exception->getMessage());
+            return [];
+        }
+
+        if ((int) ($result['code'] ?? 0) !== 200 || empty($result['data']) || !is_array($result['data'])) {
+            Util::WriteLog('PayPlus_error', 'payout query invalid response: ' . json_encode($result));
+            return [];
+        }
+
+        return $result['data'];
+    }
+
+    public function resolvePayoutStatus($status)
+    {
+        switch (strtoupper((string) $status)) {
+            case 'PAID':
+                return 1;
+            case 'REJECTED':
+            case 'REFUNDED':
+                return 2;
+            default:
+                return 0;
+        }
+    }
+
+    protected function handleSuccess($query, $takeMoney)
+    {
+        $userId = $query->UserID;
+        $first = DB::connection('write')
+            ->table('QPAccountsDB.dbo.UserTabData')
+            ->where('UserID', $userId)
+            ->first();
+        if ($first) {
+            DB::connection('write')
+                ->table('QPAccountsDB.dbo.UserTabData')
+                ->where('UserID', $userId)
+                ->increment('TakeMoney', $takeMoney);
+        } else {
+            DB::connection('write')
+                ->table('QPAccountsDB.dbo.UserTabData')
+                ->insert(['TakeMoney' => $takeMoney, 'UserID' => $userId]);
+            PrivateMail::praiseSendMail($userId);
+        }
+
+        try {
+            StoredProcedure::addPlatformData($userId, 4, $takeMoney);
+        } catch (\Exception $exception) {
+            Util::WriteLog('StoredProcedure', $exception);
+        }
+
+        RecordUserDataStatistics::updateOrAdd($userId, $takeMoney, 0, $query->ServiceFee);
+        (new RechargeWithDraw())->withDraw($userId, $takeMoney, 0, $query->ServiceFee);
+        Redis::connection()->incr('draw_' . date('Ymd') . $userId);
+        PrivateMail::successMail($userId, $query->OrderId, $takeMoney);
+        StoredProcedure::user_label($userId, 2, $takeMoney);
+    }
+}

+ 309 - 0
app/Http/logic/api/PayPlusLogic.php

@@ -0,0 +1,309 @@
+<?php
+
+namespace App\Http\logic\api;
+
+use App\dao\Pay\AccountPayInfo;
+use App\dao\Pay\PayController;
+use App\Http\helper\CreateOrder;
+use App\Http\helper\NumConfig;
+use App\Jobs\Order;
+use App\Notification\TelegramBot;
+use App\Services\CreateLog;
+use App\Services\OrderServices;
+use App\Services\PayConfig;
+use App\Services\PayPlus;
+use App\Util;
+use Illuminate\Support\Facades\DB;
+
+class PayPlusLogic extends BaseApiLogic
+{
+    protected $service;
+
+    public function __construct(PayPlus $service = null)
+    {
+        $this->service = $service ?: new PayPlus();
+    }
+
+    public function pay_order(
+        $userId,
+        $payAmount,
+        $userPhone,
+        $userEmail,
+        $userName,
+        $GiftsID,
+        $buyIP,
+        $AdId,
+        $eventType,
+        $payMethod = ''
+    ) {
+        $dao = new AccountPayInfo();
+        list($userPhone, $userName, $userEmail) = $dao->payInfo($userId);
+
+        $payVerify = new PayController();
+        $payAmount = $payVerify->verify($userId, $GiftsID, $payAmount);
+        if ($payAmount === false || $payAmount < 0) {
+            $this->error = $payVerify->getError() ?: 'Payment error_4';
+            return false;
+        }
+
+        $orderSn = CreateOrder::order_sn($userId);
+        $amount = (int) round($payAmount * NumConfig::NUM_VALUE);
+
+        $logic = new OrderLogic();
+        if (
+            !$logic->orderCreate(
+                $orderSn,
+                $amount,
+                'PayPlus',
+                $userId,
+                $payMethod,
+                $GiftsID,
+                $AdId,
+                $eventType
+            )
+        ) {
+            $this->error = $logic->getError();
+            return false;
+        }
+
+        $payload = $this->buildPaymentPayload([
+            'order_sn' => $orderSn,
+            'amount' => $payAmount,
+            'user_id' => $userId,
+            'user_email' => $userEmail,
+            'user_phone' => $userPhone,
+            'user_name' => $userName,
+            'buy_ip' => $buyIP,
+            'pay_method' => $payMethod,
+        ]);
+
+        CreateLog::pay_request(
+            $userPhone,
+            json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
+            $orderSn,
+            $userEmail,
+            $userId,
+            $userName
+        );
+
+        Util::WriteLog('PayPlus', 'PayPlus payment request: ' . json_encode($payload));
+
+        try {
+            $result = $this->service->postPayin($payload);
+            Util::WriteLog('PayPlus', 'PayPlus payment response: ' . json_encode($result));
+            if ($result['code'] !== 200) {
+                TelegramBot::getDefault()->sendProgramNotify(
+                    'PayPlus payment failed',
+                    'Response: ' . json_encode($result),
+                    null
+                );
+            }
+            return $result;
+            
+        } catch (\Exception $exception) {
+            Util::WriteLog('PayPlus_error', $exception->getMessage());
+            $this->error = 'Payment processing error';
+            return false;
+        }
+    }
+
+    public function buildPaymentPayload(array $input)
+    {
+        $config = $this->service->getConfig();
+        $payMethod = (int) ($input['pay_method'] ?: 1);
+        $methodMap = $config['payment_methods'] ?? [
+            1 => 8,
+            2 => 2,
+            4 => 1,
+            8 => 5,
+        ];
+
+        $nameParts = preg_split('/\s+/', trim((string) ($input['user_name'] ?? '')), 2);
+
+        return [
+            'order_type' => 'RECHARGE',
+            'platform_order_id' => (string) $input['order_sn'],
+            'currency' => strtoupper($config['currency'] ?? 'USD'),
+            'amount' => number_format((float) $input['amount'], 2, '.', ''),
+            'payment_method' => $methodMap[$payMethod] ?? 8,
+            'return_url' => $config['return'] ?? '',
+            'cancel_url' => $config['cancel'] ?? ($config['return'] ?? ''),
+            'connection_info' => [
+                'ip' => $input['buy_ip'] ?: '0.0.0.0',
+                'country' => $config['country'] ?? 'US',
+                'state' => $config['state'] ?? 'NY',
+                'zip_code' => $config['zip'] ?? '00000',
+                'media_source' => $config['media_source'] ?? 'organic',
+                'language' => $config['language'] ?? 'en-US',
+            ],
+            'account_info' => [
+                'merchant_user_id' => (string) $input['user_id'],
+                'create_time' => time()*1000,
+                'role' => 'PRIVATE',
+                'email' => $this->emailOrDefault($input['user_email'] ?? '', $input['user_id']),
+                'phone' => preg_replace('/\D+/', '', (string) ($input['user_phone'] ?? '')) ?: '0000000000',
+                'area_code' => $config['area_code'] ?? '1',
+                'first_name' => chr(mt_rand(65, 90)) . 'user',
+                'last_name' => chr(mt_rand(65, 90)) . 'user',
+                'vip_level' => 0,
+            ],
+        ];
+    }
+
+    public function notify(array $post)
+    {
+        $orderSn = $post['data']['platform_order_id'] ?? '';
+        if ($orderSn === '') {
+            return 'success';
+        }
+
+        $order = DB::connection('write')
+            ->table('agent.dbo.order')
+            ->where('order_sn', $orderSn)
+            ->first();
+        if (!$order || !empty($order->pay_at) || !empty($order->finished_at)) {
+            return 'success';
+        }
+
+        $data = $post['data'] ?? [];
+        $body = [
+            'payment_sn' => $data['order_id'] ?? '',
+            'updated_at' => date('Y-m-d H:i:s'),
+        ];
+
+        if ($this->isSuccessfulPayment($post)) {
+            if (!$this->confirmSuccessfulPayment($post)) {
+                Util::WriteLog('PayPlus', 'PayPlus order query not successful: ' . json_encode($post));
+                return 'success';
+            }
+
+            $AdId = $order->AdId ?: '';
+            $eventType = $order->eventType ?: '';
+
+            $payAmount = round((float) ($data['amount'] ?? 0), 2);
+            $body['pay_status'] = 1;
+            $body['pay_at'] = date('Y-m-d H:i:s');
+            $body['finished_at'] = date('Y-m-d H:i:s');
+            $body['amount'] = (int) round($payAmount * NumConfig::NUM_VALUE);
+
+            $config = (new PayConfig())->getConfig('PayPlus');
+            // 根据支付方式计算代收手续费: 费率% * 金额 + 固定$
+            // pay_rate 格式: [1=>[10,0.3], 2=>[13,0.3], 4=>[11,0.3], 8=>[12,0.3]]
+            $payRates = $config['pay_rate'] ?? null;
+            if (is_array($payRates)) {
+                $payMethod = $order->order_title ?? 1;
+                $payRate = $payRates[$payMethod] ?? ($payRates[1] ?? [10, 0.3]);
+                $feePercent = $payRate[0] ?? 10;
+                $feeFixed = $payRate[1] ?? 0.3;
+                $body['payment_fee'] = intval(($body['amount'] * $feePercent) / 100)
+                    + (int)($feeFixed * NumConfig::NUM_VALUE);
+            }
+
+            $service = new OrderServices();
+            list($give, $favorablePrice, $recharge, $czReason, $cjReason) = $service->getPayInfo(
+                $order->GiftsID ?: '',
+                $order->user_id ?: '',
+                $payAmount
+            );
+            list($score) = $service->addRecord(
+                $order->user_id,
+                $payAmount,
+                $favorablePrice,
+                $orderSn,
+                $order->GiftsID,
+                $recharge,
+                $czReason,
+                $give,
+                $cjReason,
+                $order->AdId ?: '',
+                $order->eventType ?: '',
+                $body['payment_fee'] ?? 0
+            );
+
+            Order::dispatch([
+                $order->user_id,
+                $payAmount,
+                $score,
+                $favorablePrice,
+                $order->GiftsID,
+                $orderSn,
+            ]);
+        } else {
+            return 'success';
+        }
+
+        DB::connection('write')
+            ->table('agent.dbo.order')
+            ->where('order_sn', $orderSn)
+            ->update($body);
+
+        return 'success';
+    }
+
+    public function isSuccessfulPayment(array $post)
+    {
+        return ($post['event'] ?? '') === 'PAYMENT.CAPTURE.COMPLETED'
+            && ($post['event_detail_name'] ?? '') === 'payment_captured'
+            && (int) ($post['data']['order_status'] ?? 0) === 3;
+    }
+
+    public function confirmSuccessfulPayment(array $post)
+    {
+        $data = $post['data'] ?? [];
+        $platformOrderId = $data['platform_order_id'] ?? '';
+        if ($platformOrderId === '') {
+            return false;
+        }
+
+        try {
+            $result = $this->service->queryPayinOrder($platformOrderId, $data['order_id'] ?? '');
+        } catch (\Exception $exception) {
+            Util::WriteLog('PayPlus_error', 'PayPlus query order failed: ' . $exception->getMessage());
+            return false;
+        }
+
+        return $this->isSuccessfulPaymentQueryResult($result);
+    }
+
+    public function isSuccessfulPaymentQueryResult(array $result)
+    {
+        $data = $result['decryptedComponentDelta'] ?? ($result['data'] ?? $result);
+
+        return (int) ($data['order_status'] ?? 0) === 3;
+    }
+
+    public function isFailedPayment(array $post)
+    {
+        $event = $post['event'] ?? '';
+        $detail = $post['event_detail_name'] ?? '';
+        $status = (int) ($post['data']['order_status'] ?? 0);
+
+        return ($event === 'PAYMENT.CAPTURE.COMPLETED' && in_array($status, [4, 10, 11], true))
+            || $event === 'PAYMENT.ORDER.TIMEOUT'
+            || in_array($detail, ['payment_declined', 'payment_timeout'], true);
+    }
+
+    protected function resolvePaymentUrl(array $result)
+    {
+        $data = $result['data'] ?? $result;
+        foreach (['cashierUrl', 'cashier_url', 'payment_url', 'paymentUrl', 'redirect_url', 'url'] as $key) {
+            if (!empty($data[$key])) {
+                return $data[$key];
+            }
+        }
+
+        return '';
+    }
+
+    protected function emailOrDefault($email, $userId)
+    {
+        return filter_var($email, FILTER_VALIDATE_EMAIL) ? $email : 'unknown' . $userId . '@example.com';
+    }
+
+    protected function stringOrDefault($value, $default)
+    {
+        $value = trim((string) $value);
+
+        return $value === '' ? $default : $value;
+    }
+}

+ 4 - 0
app/Services/CashService.php

@@ -12,6 +12,7 @@ use App\Http\logic\api\AiNewPayCashierLogic;
 use App\Http\logic\api\SafePayCashierLogic;
 use App\Http\logic\api\SfPayCashierLogic;
 use App\Http\logic\api\BotImPayCashierLogic;
+use App\Http\logic\api\PayPlusCashierLogic;
 
 
 class CashService
@@ -48,6 +49,9 @@ class CashService
             case BotImPayCashierLogic::AGENT:
                 return new BotImPayCashierLogic();
 
+            case PayPlusCashierLogic::AGENT:
+                return new PayPlusCashierLogic();
+
         }
     }
 }

+ 4 - 0
app/Services/PayMentService.php

@@ -18,6 +18,7 @@ use App\Http\Controllers\Api\SafePayController;
 use App\Http\Controllers\Api\SfPayController;
 use App\Http\Controllers\Api\AiNewPayController;
 use App\Http\Controllers\Api\BotImPayController;
+use App\Http\Controllers\Api\PayPlusController;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Log;
 use Illuminate\Support\Facades\Redis;
@@ -47,6 +48,9 @@ class PayMentService
             case 'AiNewPay':
                 return new AiNewPayController();
 
+            case 'PayPlus':
+                return new PayPlusController();
+
             case 'SafePay':
                 return new SafePayController();
 

+ 424 - 0
app/Services/PayPlus.php

@@ -0,0 +1,424 @@
+<?php
+
+namespace App\Services;
+
+use App\Util;
+use Exception;
+
+class PayPlus
+{
+    const PAYIN_CONFIG = 'PayPlus';
+    const PAYOUT_CONFIG = 'PayPlusOut';
+
+    protected $config;
+
+    public function __construct(array $config = null)
+    {
+        if ($config === null) {
+            $config = (new PayConfig())->getConfig(self::PAYIN_CONFIG) ?: [];
+        }
+
+        $this->config = $config;
+    }
+
+    public function getConfig()
+    {
+        return $this->config;
+    }
+
+    public function getPayoutService()
+    {
+        return new self((new PayConfig())->getConfig(self::PAYOUT_CONFIG) ?: []);
+    }
+
+    public function signPayoutPayload(array $payload)
+    {
+        return hash('sha256', $this->buildPayoutSignString($payload) . ($this->config['appKey'] ?? ''));
+    }
+
+    public function verifyPayoutSignature(array $payload, $signature)
+    {
+        $sign = hash(
+            'sha256',
+            $this->buildPayoutSignString($this->formatPayoutNotifyNumbers($payload)) . ($this->config['appKey'] ?? '')
+        );
+
+        return hash_equals($sign, strtolower((string) $signature));
+    }
+
+    public function buildPayoutSignString(array $payload)
+    {
+        $flat = $this->flattenPayload($payload);
+        ksort($flat);
+
+        $items = [];
+        foreach ($flat as $key => $value) {
+            if ($value === null || (is_string($value) && trim($value) === '')) {
+                continue;
+            }
+
+            if (is_bool($value)) {
+                $value = $value ? 'true' : 'false';
+            }
+
+            $items[] = $key . '=' . $value;
+        }
+
+        return implode('&', $items);
+    }
+
+    public function flattenPayload(array $payload, $prefix = '')
+    {
+        $result = [];
+        foreach ($payload as $key => $value) {
+            if ($key === 'Authorization' || $key === 'sign') {
+                continue;
+            }
+
+            $flatKey = $prefix === '' ? $key : $prefix . '_' . $key;
+            if (is_array($value) && $this->isAssoc($value)) {
+                $result += $this->flattenPayload($value, $flatKey);
+                continue;
+            }
+
+            if (is_array($value)) {
+                $value = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+            }
+
+            $result[$flatKey] = $value;
+        }
+
+        return $result;
+    }
+
+    public function encryptComponentDelta($payload, $aesKey, $iv)
+    {
+        $tag = '';
+        $cipherText = openssl_encrypt(
+            $payload,
+            'aes-256-gcm',
+            hex2bin($aesKey),
+            OPENSSL_RAW_DATA,
+            hex2bin($iv),
+            $tag,
+            '',
+            16
+        );
+
+        if ($cipherText === false) {
+            throw new Exception('PayPlus AES encrypt failed.');
+        }
+
+        return base64_encode(hex2bin($iv) . $cipherText . $tag);
+    }
+
+    public function decryptComponentDelta($componentDelta, $aesKey, $iv)
+    {
+        $raw = base64_decode($componentDelta, true);
+        if ($raw === false || strlen($raw) <= 28) {
+            throw new Exception('PayPlus AES response is invalid.');
+        }
+
+        $cipherTextWithTag = substr($raw, 12);
+        $tag = substr($cipherTextWithTag, -16);
+        $cipherText = substr($cipherTextWithTag, 0, -16);
+
+        $plainText = openssl_decrypt(
+            $cipherText,
+            'aes-256-gcm',
+            hex2bin($aesKey),
+            OPENSSL_RAW_DATA,
+            hex2bin($iv),
+            $tag
+        );
+
+        if ($plainText === false) {
+            throw new Exception('PayPlus AES decrypt failed.');
+        }
+
+        return $plainText;
+    }
+
+    public function encryptPayinPayload(array $payload)
+    {
+        $aesKey = bin2hex(random_bytes(32));
+        $iv = bin2hex(random_bytes(12));
+
+        return [
+            'aes_key' => $aesKey,
+            'iv' => $iv,
+            'body' => [
+                'componentX' => $this->rsaEncrypt($aesKey),
+                'componentY' => $this->rsaEncrypt($iv),
+                'componentDelta' => $this->encryptComponentDelta(
+                    json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
+                    $aesKey,
+                    $iv
+                ),
+            ],
+        ];
+    }
+
+    public function decryptPayinResponse(array $response, $aesKey, $iv)
+    {
+        $componentDelta = $response['data']['componentDelta'] ?? '';
+        if ($componentDelta === '') {
+            return [];
+        }
+
+        return json_decode($this->decryptComponentDelta($componentDelta, $aesKey, $iv), true) ?: [];
+    }
+
+    public function postPayin(array $payload)
+    {
+        return $this->postPayinPath('/up-apis/merchant/payment', $payload);
+    }
+
+    public function queryPayinOrder($platformOrderId, $orderId = '')
+    {
+        if ($platformOrderId) {
+            $payload = [
+                'platform_order_id' => (string) $platformOrderId,
+            ];
+        } elseif ($orderId) {
+            $payload = [
+                'order_id' => (string) $orderId,
+            ];
+        } else {
+            throw new Exception('Either platformOrderId or orderId must be provided for querying PayPlus order.');
+        }
+
+        Util::WriteLog('PayPlus', 'Query PayPlus order: ' . json_encode($payload));
+
+        return $this->postPayinPath(
+            $this->config['query_path'] ?? '/up-apis/merchant/payment/detail',
+            $payload
+        );
+    }
+
+    protected function postPayinPath($path, array $payload)
+    {
+        $encrypted = $this->encryptPayinPayload($payload);
+        $response = $this->curlJson(
+            rtrim($this->config['apiUrl'] ?? '', '/') . $path,
+            $encrypted['body'],
+            $this->buildPayinHeaders()
+        );
+
+        $decoded = json_decode($response, true) ?: [];
+        Util::WriteLog('PayPlus', 'PayPlus raw response: ' . json_encode($decoded));
+        $componentDelta = $this->decryptPayinResponse($decoded, $encrypted['aes_key'], $encrypted['iv']);
+        if (!empty($componentDelta)) {
+            Util::WriteLog('PayPlus', 'PayPlus decrypted componentDelta: ' . json_encode($componentDelta));
+        }
+        $decoded['decryptedComponentDelta'] = $componentDelta;
+        return $decoded;
+    }
+
+    public function queryPayoutOrder($transactionId = '', $referenceId = '')
+    {
+        $payload = [];
+        if ($transactionId !== '') {
+            $payload['transaction_id'] = (string) $transactionId;
+        }
+
+        if ($referenceId !== '') {
+            $payload['reference_id'] = (string) $referenceId;
+        }
+
+        if (empty($payload)) {
+            throw new Exception('Either transactionId or referenceId must be provided for querying PayPlus payout.');
+        }
+
+        return $this->postPayout(
+            $this->config['payout_query_path'] ?? '/rest/v2/payouts/detail',
+            $payload
+        );
+    }
+
+    public function postPayout(string $path, array $payload)
+    {
+        $payload['timestamp'] = $payload['timestamp'] ?? $this->milliseconds();
+        $headers = [
+            'Content-Type: application/json',
+            'AppId: ' . ($this->config['appId'] ?? ''),
+            'Authorization: ' . $this->signPayoutPayload($payload),
+        ];
+        Util::WriteLog('PayPlus', 'PayPlus request(' . $path . '): ' . json_encode($payload) . ' | Headers: ' . json_encode($headers));
+        $response = $this->curlJson(rtrim($this->config['apiUrl'] ?? '', '/') . $path, $payload, $headers);
+        Util::WriteLog('PayPlus', 'PayPlus response(' . $path . '): ' . json_encode($response));
+        return json_decode($response, true) ?: [];
+    }
+
+    public function buildBeneficiaryPayload(array $user, $timestamp = null)
+    {
+        $userId = (string) ($user['user_id'] ?? $user['payee_id'] ?? 0);
+        $name = trim((string) ($user['name'] ?? $user['user_name'] ?? ''));
+        $names = preg_split('/\s+/', $name, 2);
+
+        return [
+            'payee_id' => $userId,
+            'language' => $this->stringOrDefault($user['language'] ?? null, 'en'),
+            'country' => strtoupper($this->stringOrDefault($user['country'] ?? null, 'US')),
+            'phone' => $this->normalizePhone($user['phone'] ?? ''),
+            'birthdate' => $this->stringOrDefault($user['birthdate'] ?? null, '1970-01-01'),
+            'email' => $this->emailOrDefault($user['email'] ?? '', $userId),
+            'first_name' => $this->englishNameOrDefault($user['first_name'] ?? ($names[0] ?? null), 'unknown'),
+            'last_name' => $this->englishNameOrDefault($user['last_name'] ?? ($names[1] ?? null), 'user'),
+            'zip' => $this->stringOrDefault($user['zip'] ?? null, '00000'),
+            'city' => $this->stringOrDefault($user['city'] ?? null, 'unknown'),
+            'state' => $this->stringOrDefault($user['state'] ?? null, 'NA'),
+            'address' => $this->stringOrDefault($user['address'] ?? null, 'unknown'),
+            'timestamp' => $timestamp ?? $this->milliseconds(),
+        ];
+    }
+
+    protected function rsaEncrypt($value)
+    {
+        $publicKey = $this->config['publicKey'] ?? '';
+        if ($publicKey === '') {
+            throw new Exception('PayPlus public key is empty.');
+        }
+
+        if (!class_exists('\phpseclib\Crypt\RSA')) {
+            throw new Exception('phpseclib is required for PayPlus RSA OAEP SHA-512.');
+        }
+
+        $rsa = new \phpseclib\Crypt\RSA();
+        $rsa->setEncryptionMode(\phpseclib\Crypt\RSA::ENCRYPTION_OAEP);
+        $rsa->setHash('sha512');
+        $rsa->setMGFHash('sha512');
+        $rsa->loadKey($this->normalizePublicKey($publicKey));
+
+        $encrypted = $rsa->encrypt($value);
+        if ($encrypted === false) {
+            throw new Exception('PayPlus RSA encrypt failed.');
+        }
+
+        return base64_encode($encrypted);
+    }
+
+    protected function buildPayinHeaders()
+    {
+        return [
+            'Content-Type: application/json',
+            'x-api-key: ' . ($this->config['apiKey'] ?? ''),
+            'x-client-id: ' . ($this->config['clientId'] ?? ''),
+            'x-app-id: ' . ($this->config['appId'] ?? ''),
+        ];
+    }
+
+    protected function curlJson($url, array $payload, array $headers)
+    {
+        $data = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+        $ch = curl_init();
+        curl_setopt($ch, CURLOPT_URL, $url);
+        curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
+        curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
+        curl_setopt($ch, CURLOPT_POST, true);
+        curl_setopt($ch, CURLOPT_POSTFIELDS, $data);
+        curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 20);
+        curl_setopt($ch, CURLOPT_TIMEOUT, 20);
+        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
+        curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
+
+        $result = curl_exec($ch);
+        $curl_errno = curl_errno($ch);
+        if ($curl_errno) {
+            $error = curl_error($ch);
+            curl_close($ch);
+            Util::WriteLog('PayPlus_error', 'CURL Error: ' . "$curl_errno" . $error);
+            throw new Exception($error);
+        }
+
+        curl_close($ch);
+
+        return $result;
+    }
+
+    protected function normalizePublicKey($key)
+    {
+        if (strpos($key, 'BEGIN PUBLIC KEY') !== false) {
+            return $key;
+        }
+
+        return "-----BEGIN PUBLIC KEY-----\n"
+            . chunk_split($key, 64, "\n")
+            . "-----END PUBLIC KEY-----";
+    }
+
+    protected function normalizePhone($phone)
+    {
+        $phone = trim((string) $phone);
+        if ($phone === '') {
+            return '+1-0000000000';
+        }
+
+        if (strpos($phone, '+') === 0) {
+            return $phone;
+        }
+
+        return '+1-' . preg_replace('/\D+/', '', $phone);
+    }
+
+    protected function emailOrDefault($email, $userId)
+    {
+        $email = trim((string) $email);
+        if (filter_var($email, FILTER_VALIDATE_EMAIL)) {
+            return $email;
+        }
+
+        return 'unknown' . $userId . '@example.com';
+    }
+
+    protected function stringOrDefault($value, $default)
+    {
+        $value = trim((string) $value);
+
+        return $value === '' ? $default : $value;
+    }
+
+    protected function englishNameOrDefault($value, $default)
+    {
+        $value = preg_replace('/[^A-Za-z]+/', '', (string) $value);
+
+        return $this->stringOrDefault($value, $default);
+    }
+
+    protected function formatPayoutNotifyNumbers(array $payload)
+    {
+        foreach ($payload as $key => $value) {
+            if (is_array($value)) {
+                $payload[$key] = $this->formatPayoutNotifyNumbers($value);
+                continue;
+            }
+
+            if ($this->isPayoutNotifyAmountKey($key) && is_numeric($value)) {
+                $payload[$key] = number_format((float) $value, 4, '.', '');
+            }
+        }
+
+        return $payload;
+    }
+
+    protected function isPayoutNotifyAmountKey($key)
+    {
+        return in_array($key, [
+            'amount',
+            'payout_amount',
+            'source_amount',
+            'payout_fee',
+            'fee',
+        ], true);
+    }
+
+    protected function milliseconds()
+    {
+        return (int) round(microtime(true) * 1000);
+    }
+
+    private function isAssoc(array $value)
+    {
+        return array_keys($value) !== range(0, count($value) - 1);
+    }
+}

+ 1 - 0
composer.json

@@ -12,6 +12,7 @@
         "laravel/tinker": "^1.0",
         "longman/telegram-bot": "^0.81.0",
         "maatwebsite/excel": "~2.1.0",
+        "phpseclib/phpseclib": "^2.0",
         "themsaid/laravel-langman": "*",
         "yansongda/laravel-pay": "^2.2"
     },

+ 37 - 0
config/payTest.php

@@ -90,4 +90,41 @@ return [
         'platform_public_key' => 'MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDHCMT8mq2XZPoLii5wYVgg9NlMZadXhwyxJZ5duAX4hxXT1OiPpHkj2PpNyMhcBhn+O8p4FjWGUrTRoL29b0X/IlEuGD+u6QosimqYta4l6S47tNyTUqh9zrPmlXn6qow1JY9rL2eSs30NdvB0oFcitSsn68kNcRUDZ9FGtOZiRQIDAQAB',
     ],
 
+    'PayPlus' => [
+        'apiUrl' => env('PAYPLUS_PAYIN_URL', 'https://pay-sandbox.payplus.net'),
+        'query_path' => env('PAYPLUS_QUERY_PATH', '/up-apis/merchant/payment/query'),
+        'apiKey' => env('PAYPLUS_API_KEY', 'bPBYkV408FnADYDVJ4GlIHUB2CczfTiO'),
+        'clientId' => env('PAYPLUS_CLIENT_ID', '3ebabd36-91a5-44cb-8ed9-5c3e49de5848'),
+        'appId' => env('PAYPLUS_APP_ID', '104101'),
+        'publicKey' => env(
+            'PAYPLUS_PUBLIC_KEY',
+            'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnuDnY8u7a9EvUBAG7jJdKNkPS2vPpHo/gp7f2TA62cHXRW0AmeqHoWkcW6s8mMyP9EC6zNOcaVbPZrh6o9q0X06eZadexSK8bBY24c1j+uR1mivRcyB8bPiaNMGLaKKXpkbwttP+No7AOpAkWXx3pemXJeO8CRZGGRT79gv4v8Sd9KXJvsV4tdOU/RObMBtjtyWnGbNWWkxyJfq/sU4eDnFUr9J3jc1U6pPty/6ziukt6dMXth5qaod8kf7QMI0gXMlIi9znTLLSsQUzcZA0pB4/QvEExJxq0mtCtrq80UWITeE0frgbS4djq9XFGU+YlEXEX9DWgby5E9uX05vVUQIDAQAB'
+        ),
+        'currency' => env('PAYPLUS_CURRENCY', 'USD'),
+        'country' => env('PAYPLUS_COUNTRY', 'US'),
+        'state' => env('PAYPLUS_STATE', 'NY'),
+        'zip' => env('PAYPLUS_ZIP', '10002'),
+        'area_code' => env('PAYPLUS_AREA_CODE', '1'),
+        'language' => env('PAYPLUS_LANGUAGE', 'en-US'),
+        'media_source' => env('PAYPLUS_MEDIA_SOURCE', 'organic'),
+        'return' => env('APP_URL', '') . '/api/payplus/return',
+        'cancel' => env('APP_URL', '') . '/api/payplus/return',
+        'notify' => env('APP_URL', '') . '/api/payplus/notify',
+        'payment_methods' => [
+            1 => 8,
+            2 => 2,
+            4 => 1,
+            8 => 5,
+        ],
+    ],
+
+    'PayPlusOut' => [
+        'apiUrl' => env('PAYPLUS_PAYOUT_URL', 'https://sandbox-api.payplus.net'),
+        'appId' => env('PAYPLUS_PAYOUT_APP_ID', ''),
+        'appKey' => env('PAYPLUS_PAYOUT_APP_KEY', ''),
+        'currency' => env('PAYPLUS_PAYOUT_CURRENCY', 'USD'),
+        'payout_query_path' => env('PAYPLUS_PAYOUT_QUERY_PATH', '/rest/v2/payouts/detail'),
+        'cashNotify' => env('APP_URL', '') . '/api/payplus/payout_notify',
+    ],
+
 ];

+ 6 - 1
routes/api.php

@@ -317,6 +317,11 @@ Route::any('/ainewpay/notify', 'Api\AiNewPayController@notify');
 Route::any('/ainewpay/payout_notify', 'Api\AiNewPayController@cash_notify');
 Route::any('/ainewpay/return', 'Api\AiNewPayController@sync_notify');
 
+// PayPlus支付渠道
+Route::any('/payplus/notify', 'Api\PayPlusController@notify');
+Route::any('/payplus/payout_notify', 'Api\PayPlusController@cash_notify');
+Route::any('/payplus/return', 'Api\PayPlusController@sync_notify');
+
 // WDPay支付渠道
 Route::any('/wdpay/notify', 'Api\WDPayController@notify');
 Route::any('/wdpay/payout_notify', 'Api\WDPayController@cash_notify');
@@ -510,4 +515,4 @@ Route::get('/agent/click-stats', 'Api\AgentClickController@getClickStats');
 // 公开获取成功提现记录展示
 //Route::get('/agent/recent-withdrawals', 'Api\AgentController@getRecentWithdrawals');
 // 获取按小时分布的注册用户、首充付费人数和付费率统计
-Route::any('/stats/register-hourly', 'Api\RegisterHourlyStatsController@getHourlyStats');
+Route::any('/stats/register-hourly', 'Api\RegisterHourlyStatsController@getHourlyStats');

+ 176 - 0
tests/Unit/PayPlusCashierLogicTest.php

@@ -0,0 +1,176 @@
+<?php
+
+namespace Tests\Unit;
+
+use App\Http\logic\api\PayPlusCashierLogic;
+use App\Services\PayPlus;
+use Tests\TestCase;
+
+class PayPlusCashierLogicTest extends TestCase
+{
+    /** @test */
+    public function it_queries_existing_beneficiary_when_create_reports_duplicate()
+    {
+        $service = new FakePayPlusForCashier([
+            ['code' => 4002001, 'message' => 'beneficiary exists'],
+            ['code' => 200, 'data' => ['beneficiary_id' => 5006]],
+            [
+                'code' => 200,
+                'data' => [
+                    'transaction_status' => 'PROCESSING',
+                    'transaction_id' => 'T100',
+                ],
+            ],
+        ]);
+
+        $logic = new PayPlusCashierLogic($service);
+        $result = $logic->createBeneficiaryAndPayout([
+            'user_id' => 10001,
+            'amount' => 10,
+            'currency' => 'USD',
+            'order_id' => 'TX100',
+            'pix_type' => 1,
+            'cashapp_account' => 'cashuser',
+            'email' => 'user@example.com',
+        ]);
+
+        $this->assertSame('PROCESSING', $result['data']['transaction_status']);
+        $this->assertSame('/rest/v2/beneficiaries/create', $service->calls[0]['path']);
+        $this->assertSame('/rest/v2/beneficiaries/query', $service->calls[1]['path']);
+        $this->assertSame('/rest/v2/payouts/cashApp', $service->calls[2]['path']);
+        $this->assertSame('$cashuser', $service->calls[2]['payload']['cashapp_account']);
+    }
+
+    /** @test */
+    public function it_does_not_payout_when_beneficiary_id_cannot_be_resolved()
+    {
+        $service = new FakePayPlusForCashier([
+            ['code' => 4002001, 'message' => 'beneficiary exists'],
+            ['code' => 200],
+        ]);
+
+        $logic = new PayPlusCashierLogic($service);
+        $result = $logic->createBeneficiaryAndPayout([
+            'user_id' => 10001,
+            'amount' => 10,
+            'currency' => 'USD',
+            'order_id' => 'TX100',
+            'pix_type' => 2,
+            'email' => 'user@example.com',
+        ]);
+
+        $this->assertFalse($result);
+        $this->assertCount(2, $service->calls);
+    }
+
+    /** @test */
+    public function it_maps_payplus_payout_statuses()
+    {
+        $logic = new PayPlusCashierLogic(new PayPlus([]));
+
+        $this->assertSame(1, $logic->resolvePayoutStatus('PAID'));
+        $this->assertSame(2, $logic->resolvePayoutStatus('REJECTED'));
+        $this->assertSame(2, $logic->resolvePayoutStatus('REFUNDED'));
+        $this->assertSame(0, $logic->resolvePayoutStatus('PROCESSING'));
+    }
+
+    /** @test */
+    public function it_queries_payout_detail_for_notify_and_uses_query_status()
+    {
+        $service = new FakePayPlusForCashier([
+            [
+                'code' => 200,
+                'data' => [
+                    'reference_id' => 'TX100',
+                    'transaction_id' => 'T100',
+                    'transaction_status' => 'PAID',
+                    'description' => 'paid',
+                ],
+            ],
+        ]);
+
+        $logic = new PayPlusCashierLogic($service);
+        $detail = $logic->queryPayoutDetailForNotify([
+            'reference_id' => 'TX100',
+            'transaction_id' => 'T100',
+            'status' => 'REJECTED',
+        ]);
+
+        $this->assertSame('/rest/v2/payouts/detail', $service->calls[0]['path']);
+        $this->assertSame('TX100', $service->calls[0]['payload']['reference_id']);
+        $this->assertSame('T100', $service->calls[0]['payload']['transaction_id']);
+        $this->assertSame('PAID', $detail['transaction_status']);
+        $this->assertSame(1, $logic->resolvePayoutStatus($detail['transaction_status']));
+    }
+
+    /** @test */
+    public function it_returns_empty_notify_detail_when_query_has_no_order_data()
+    {
+        $service = new FakePayPlusForCashier([
+            ['code' => 200],
+        ]);
+
+        $logic = new PayPlusCashierLogic($service);
+        $detail = $logic->queryPayoutDetailForNotify([
+            'reference_id' => 'TX100',
+            'transaction_id' => 'T100',
+            'status' => 'PAID',
+        ]);
+
+        $this->assertSame([], $detail);
+        $this->assertCount(1, $service->calls);
+    }
+
+    /** @test */
+    public function it_keeps_processing_payout_notify_unhandled_after_query()
+    {
+        $service = new FakePayPlusForCashier([
+            [
+                'code' => 200,
+                'data' => [
+                    'reference_id' => 'TX100',
+                    'transaction_id' => 'T100',
+                    'transaction_status' => 'PROCESSING',
+                ],
+            ],
+        ]);
+
+        $logic = new PayPlusCashierLogic($service);
+        $detail = $logic->queryPayoutDetailForNotify([
+            'reference_id' => 'TX100',
+            'transaction_id' => 'T100',
+            'status' => 'REJECTED',
+        ]);
+
+        $this->assertSame('PROCESSING', $detail['transaction_status']);
+        $this->assertSame(0, $logic->resolvePayoutStatus($detail['transaction_status']));
+    }
+}
+
+class FakePayPlusForCashier extends PayPlus
+{
+    public $calls = [];
+
+    private $responses;
+
+    public function __construct(array $responses)
+    {
+        parent::__construct([
+            'appId' => 200000,
+            'appKey' => 'test-key',
+            'currency' => 'USD',
+        ]);
+
+        $this->responses = $responses;
+    }
+
+    public function postPayout(string $path, array $payload)
+    {
+        $this->calls[] = [
+            'path' => $path,
+            'payload' => $payload,
+        ];
+
+        return array_shift($this->responses);
+    }
+}

+ 132 - 0
tests/Unit/PayPlusLogicTest.php

@@ -0,0 +1,132 @@
+<?php
+
+namespace Tests\Unit;
+
+use App\Http\logic\api\PayPlusLogic;
+use App\Services\PayPlus;
+use Tests\TestCase;
+
+class PayPlusLogicTest extends TestCase
+{
+    /** @test */
+    public function it_builds_payment_payload_with_configured_method_mapping()
+    {
+        $service = new PayPlus([
+            'currency' => 'USD',
+            'return' => 'https://example.com/return',
+            'payment_methods' => [
+                1 => 8,
+                2 => 2,
+            ],
+        ]);
+
+        $logic = new PayPlusLogic($service);
+        $payload = $logic->buildPaymentPayload([
+            'order_sn' => 'P100',
+            'amount' => 12.34,
+            'user_id' => 10001,
+            'user_email' => 'user@example.com',
+            'user_phone' => '1234567890',
+            'user_name' => 'Nico Smith',
+            'buy_ip' => '8.8.8.8',
+            'pay_method' => 2,
+        ]);
+
+        $this->assertSame('P100', $payload['platform_order_id']);
+        $this->assertSame('RECHARGE', $payload['order_type']);
+        $this->assertSame('12.34', $payload['amount']);
+        $this->assertSame(2, $payload['payment_method']);
+        $this->assertSame('10001', $payload['account_info']['merchant_user_id']);
+        $this->assertRegExp('/^[A-Z]user$/', $payload['account_info']['first_name']);
+        $this->assertRegExp('/^[A-Z]user$/', $payload['account_info']['last_name']);
+    }
+
+    /** @test */
+    public function it_maps_successful_capture_only_as_paid()
+    {
+        $logic = new PayPlusLogic(new PayPlus([]));
+
+        $this->assertTrue($logic->isSuccessfulPayment([
+            'event' => 'PAYMENT.CAPTURE.COMPLETED',
+            'event_detail_name' => 'payment_captured',
+            'data' => ['order_status' => 3],
+        ]));
+
+        $this->assertFalse($logic->isSuccessfulPayment([
+            'event' => 'PAYMENT.CAPTURE.COMPLETED',
+            'event_detail_name' => 'payment_declined',
+            'data' => ['order_status' => 4],
+        ]));
+    }
+
+    /** @test */
+    public function it_confirms_successful_notify_with_order_query()
+    {
+        $service = new FakePayPlusForPayinQuery([
+            'decryptedComponentDelta' => [
+                'order_status' => 3,
+                'event_detail_name' => 'payment_captured',
+            ],
+        ]);
+
+        $logic = new PayPlusLogic($service);
+
+        $this->assertTrue($logic->confirmSuccessfulPayment([
+            'event' => 'PAYMENT.CAPTURE.COMPLETED',
+            'event_detail_name' => 'payment_captured',
+            'data' => [
+                'platform_order_id' => 'P100',
+                'order_id' => 'O100',
+                'order_status' => 3,
+            ],
+        ]));
+
+        $this->assertSame('P100', $service->queriedPlatformOrderId);
+        $this->assertSame('O100', $service->queriedOrderId);
+    }
+
+    /** @test */
+    public function it_rejects_successful_notify_when_order_query_is_not_successful()
+    {
+        $service = new FakePayPlusForPayinQuery([
+            'decryptedComponentDelta' => [
+                'order_status' => 4,
+                'event_detail_name' => 'payment_declined',
+            ],
+        ]);
+
+        $logic = new PayPlusLogic($service);
+
+        $this->assertFalse($logic->confirmSuccessfulPayment([
+            'event' => 'PAYMENT.CAPTURE.COMPLETED',
+            'event_detail_name' => 'payment_captured',
+            'data' => [
+                'platform_order_id' => 'P100',
+                'order_id' => 'O100',
+                'order_status' => 3,
+            ],
+        ]));
+    }
+}
+
+class FakePayPlusForPayinQuery extends PayPlus
+{
+    public $queriedPlatformOrderId;
+    public $queriedOrderId;
+
+    private $queryResult;
+
+    public function __construct(array $queryResult)
+    {
+        parent::__construct([]);
+        $this->queryResult = $queryResult;
+    }
+
+    public function queryPayinOrder($platformOrderId, $orderId = '')
+    {
+        $this->queriedPlatformOrderId = $platformOrderId;
+        $this->queriedOrderId = $orderId;
+
+        return $this->queryResult;
+    }
+}

+ 122 - 0
tests/Unit/PayPlusServiceTest.php

@@ -0,0 +1,122 @@
+<?php
+
+namespace Tests\Unit;
+
+use App\Services\PayPlus;
+use Tests\TestCase;
+
+class PayPlusServiceTest extends TestCase
+{
+    /** @test */
+    public function it_builds_payout_sign_with_flattened_sorted_payload()
+    {
+        $service = new PayPlus([
+            'appKey' => '123456',
+        ]);
+
+        $payload = [
+            'k2' => [1, 2],
+            'empty' => '',
+            'k3' => [
+                'b1' => 'test1',
+                'a2' => 'test2',
+            ],
+            'k1' => 1,
+            'null_value' => null,
+        ];
+
+        $sign = $service->signPayoutPayload($payload);
+
+        $this->assertSame(
+            hash('sha256', 'k1=1&k2=[1,2]&k3_a2=test2&k3_b1=test1' . '123456'),
+            $sign
+        );
+    }
+
+    /** @test */
+    public function it_formats_payout_notify_amounts_to_four_decimals_when_verifying_signature()
+    {
+        $service = new PayPlus([
+            'appKey' => '123456',
+        ]);
+
+        $payload = [
+            'transaction_id' => 'T100',
+            'reference_id' => 'TX100',
+            'status' => 'PAID',
+            'timestamp' => 162856465000,
+            'payout_amount' => 10,
+            'source_amount' => '11',
+            'payout_fee' => 1,
+        ];
+
+        $signString = 'payout_amount=10.0000&payout_fee=1.0000&reference_id=TX100'
+            . '&source_amount=11.0000&status=PAID&timestamp=162856465000&transaction_id=T100';
+
+        $this->assertTrue($service->verifyPayoutSignature(
+            $payload,
+            hash('sha256', $signString . '123456')
+        ));
+    }
+
+    /** @test */
+    public function it_encrypts_and_decrypts_component_delta_with_aes_gcm()
+    {
+        $service = new PayPlus([]);
+        $aesKey = str_repeat('01', 32);
+        $iv = str_repeat('02', 12);
+        $payload = '{"hello":"payplus"}';
+
+        $encrypted = $service->encryptComponentDelta($payload, $aesKey, $iv);
+        $decrypted = $service->decryptComponentDelta($encrypted, $aesKey, $iv);
+
+        $this->assertSame($payload, $decrypted);
+    }
+
+    /** @test */
+    public function it_builds_beneficiary_payload_with_safe_defaults()
+    {
+        $service = new PayPlus([]);
+
+        $payload = $service->buildBeneficiaryPayload([
+            'user_id' => 10001,
+            'email' => '',
+            'phone' => '',
+            'first_name' => '',
+            'last_name' => '',
+        ], 1234567890);
+
+        $this->assertSame('10001', $payload['payee_id']);
+        $this->assertSame('en', $payload['language']);
+        $this->assertSame('US', $payload['country']);
+        $this->assertSame('+1-0000000000', $payload['phone']);
+        $this->assertSame('unknown10001@example.com', $payload['email']);
+        $this->assertSame('unknown', $payload['first_name']);
+        $this->assertSame('user', $payload['last_name']);
+        $this->assertSame(1234567890, $payload['timestamp']);
+    }
+
+    /** @test */
+    public function it_keeps_only_english_letters_for_beneficiary_names()
+    {
+        $service = new PayPlus([]);
+
+        $payload = $service->buildBeneficiaryPayload([
+            'user_id' => 10002,
+            'first_name' => '张San-123',
+            'last_name' => '李User_456',
+        ], 1234567890);
+
+        $this->assertSame('San', $payload['first_name']);
+        $this->assertSame('User', $payload['last_name']);
+
+        $payload = $service->buildBeneficiaryPayload([
+            'user_id' => 10003,
+            'first_name' => '张三123',
+            'last_name' => '456-',
+        ], 1234567890);
+
+        $this->assertSame('unknown', $payload['first_name']);
+        $this->assertSame('user', $payload['last_name']);
+    }
+}