laowu 1 week ago
parent
commit
2c15d3ccaf

+ 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;

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

@@ -0,0 +1,102 @@
+<?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'] === 0) {
+            return apiReturnSuc([
+                'content' => $res['data']['cashierUrl'] ?? '',
+                'money' => $payAmt,
+                'prdOrdNo' => $res['data']['mchOrderNo'] ?? '',
+            ]);
+        }
+
+        return apiReturnFail($logic->getError() ?: 'Payment processing error');
+    }
+
+    public function notify(Request $request)
+    {
+        $post = $request->all();
+        Util::WriteLog('PayPlus', 'pay notify: ' . json_encode($post, JSON_UNESCAPED_UNICODE));
+
+        $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();
+        Util::WriteLog('PayPlus', 'cash notify: ' . json_encode($post, JSON_UNESCAPED_UNICODE));
+
+        $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';
+    }
+}

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

@@ -0,0 +1,232 @@
+<?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) ?: [];
+        }
+
+        $orderId = $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($post['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,
+            'remark' => json_encode($post, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
+        ];
+
+        if ($orderStatus === 1) {
+            $withdrawData['State'] = 2;
+            $this->handleSuccess($query, $takeMoney);
+        } else {
+            $withdrawData['State'] = 6;
+            PrivateMail::failMail($userId, $orderId, $takeMoney, $post['msg'] ?? 'REJECTED', '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 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);
+    }
+}

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

@@ -0,0 +1,262 @@
+<?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\Services\CreateLog;
+use App\Services\OrderServices;
+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);
+        } catch (\Exception $exception) {
+            Util::WriteLog('PayPlus_error', $exception->getMessage());
+            $this->error = 'Payment processing error';
+            return false;
+        }
+
+        return [
+            'code' => 0,
+            'data' => [
+                'cashierUrl' => $this->resolvePaymentUrl($result),
+                'mchOrderNo' => $orderSn,
+                'raw' => $result,
+            ],
+        ];
+    }
+
+    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'] ?? 'NA',
+                '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' => 0,
+                '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' => $this->stringOrDefault($nameParts[0] ?? '', 'unknown'),
+                'last_name' => $this->stringOrDefault($nameParts[1] ?? '', '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)) {
+            $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);
+
+            $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 ?: '',
+                0
+            );
+
+            Order::dispatch([
+                $order->user_id,
+                $payAmount,
+                $score,
+                $favorablePrice,
+                $order->GiftsID,
+                $orderSn,
+            ]);
+        } elseif ($this->isFailedPayment($post)) {
+            $body['pay_status'] = 2;
+        } 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 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();
 

+ 331 - 0
app/Services/PayPlus.php

@@ -0,0 +1,331 @@
+<?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)
+    {
+        return hash_equals($this->signPayoutPayload($payload), 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'] ?? $response['componentDelta'] ?? '';
+        if ($componentDelta === '') {
+            return $response;
+        }
+
+        return json_decode($this->decryptComponentDelta($componentDelta, $aesKey, $iv), true) ?: [];
+    }
+
+    public function postPayin(array $payload)
+    {
+        $encrypted = $this->encryptPayinPayload($payload);
+        $response = $this->curlJson(
+            rtrim($this->config['apiUrl'] ?? '', '/') . '/up-apis/merchant/payment',
+            $encrypted['body'],
+            $this->buildPayinHeaders()
+        );
+
+        $decoded = json_decode($response, true) ?: [];
+
+        return $this->decryptPayinResponse($decoded, $encrypted['aes_key'], $encrypted['iv']);
+    }
+
+    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),
+        ];
+
+        $response = $this->curlJson(rtrim($this->config['apiUrl'] ?? '', '/') . $path, $payload, $headers);
+
+        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->stringOrDefault($user['first_name'] ?? ($names[0] ?? null), 'unknown'),
+            'last_name' => $this->stringOrDefault($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);
+        if (curl_errno($ch)) {
+            $error = curl_error($ch);
+            curl_close($ch);
+            Util::WriteLog('PayPlus_error', 'CURL Error: ' . $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 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"
     },

+ 35 - 0
config/pay.php

@@ -0,0 +1,35 @@
+<?php
+
+return [
+    'PayPlus' => [
+        'apiUrl' => env('PAYPLUS_PAYIN_URL', ''),
+        'apiKey' => env('PAYPLUS_API_KEY', ''),
+        'clientId' => env('PAYPLUS_CLIENT_ID', ''),
+        'appId' => env('PAYPLUS_APP_ID', ''),
+        'publicKey' => env('PAYPLUS_PUBLIC_KEY', ''),
+        'currency' => env('PAYPLUS_CURRENCY', 'USD'),
+        'country' => env('PAYPLUS_COUNTRY', 'US'),
+        'state' => env('PAYPLUS_STATE', 'NA'),
+        'zip' => env('PAYPLUS_ZIP', '00000'),
+        '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', ''),
+        'appId' => env('PAYPLUS_PAYOUT_APP_ID', ''),
+        'appKey' => env('PAYPLUS_PAYOUT_APP_KEY', ''),
+        'currency' => env('PAYPLUS_PAYOUT_CURRENCY', 'USD'),
+        'cashNotify' => env('APP_URL', '') . '/api/payplus/payout_notify',
+    ],
+];

+ 32 - 0
config/payTest.php

@@ -90,4 +90,36 @@ return [
         'platform_public_key' => 'MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDHCMT8mq2XZPoLii5wYVgg9NlMZadXhwyxJZ5duAX4hxXT1OiPpHkj2PpNyMhcBhn+O8p4FjWGUrTRoL29b0X/IlEuGD+u6QosimqYta4l6S47tNyTUqh9zrPmlXn6qow1JY9rL2eSs30NdvB0oFcitSsn68kNcRUDZ9FGtOZiRQIDAQAB',
     ],
 
+    'PayPlus' => [
+        'apiUrl' => env('PAYPLUS_PAYIN_URL', 'https://pay-sandbox.payplus.net'),
+        'apiKey' => env('PAYPLUS_API_KEY', ''),
+        'clientId' => env('PAYPLUS_CLIENT_ID', ''),
+        'appId' => env('PAYPLUS_APP_ID', ''),
+        'publicKey' => env('PAYPLUS_PUBLIC_KEY', ''),
+        'currency' => env('PAYPLUS_CURRENCY', 'USD'),
+        'country' => env('PAYPLUS_COUNTRY', 'US'),
+        'state' => env('PAYPLUS_STATE', 'NA'),
+        'zip' => env('PAYPLUS_ZIP', '00000'),
+        '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'),
+        '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');

+ 104 - 0
tests/Unit/PayPlusCashierLogicTest.php

@@ -0,0 +1,104 @@
+<?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'));
+    }
+}
+
+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);
+    }
+}

+ 61 - 0
tests/Unit/PayPlusLogicTest.php

@@ -0,0 +1,61 @@
+<?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->assertSame('Nico', $payload['account_info']['first_name']);
+        $this->assertSame('Smith', $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],
+        ]));
+    }
+}

+ 72 - 0
tests/Unit/PayPlusServiceTest.php

@@ -0,0 +1,72 @@
+<?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_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']);
+    }
+}