├── README.md ├── includes └── hooks │ └── PolygonForWHMCS.php └── modules └── gateways ├── polygonforwhmcs.php └── polygonforwhmcs ├── autoload.php ├── src ├── App.php ├── Exceptions │ └── NoAddressAvailable.php ├── Migrations │ └── CreatePolygonForWHMCSInvoicesTable.php └── Models │ ├── Invoice.php │ ├── PolygonForWHMCSInvoice.php │ └── Transaction.php └── templates ├── pay_with_usdt.tpl └── payment.tpl /README.md: -------------------------------------------------------------------------------- 1 | # PolygonForWHMCS 2 | WHMCS USDT Payment Gateway. 3 | 4 | [licson/beefyasian-pay](https://github.com/licson/beefyasian-pay) 的魔改版本. 5 | 6 | ### Feature 7 | - 插件内货币转换(默认货币 <=> USD) 8 | - 使用Polygon链 9 | - 修复一些bug 10 | 11 | ### Requirements 12 | 13 | 1. PHP 7.2 or greater. 14 | 2. WHMCS 8.1 or greater. (WHMCS 7 暂未测试) 15 | 16 | ### Installation 17 | 18 | 您可以使用以下命令下载最新版本的支付程序 19 | 20 | ``` 21 | git clone https://github.com/1-stream/PolygonForWHMCS 22 | ``` 23 | 24 | 下载过后请按照项目目录结构将文件分别复制到 `includes/hooks` 和 `modules/gateways` 目录。 25 | 26 | 并在 WHMCS `System Setting -> Payment Gateways -> All Payment Gateways ` 启用扩展,并在 `System Setting -> Payment Gateways -> Manage Existing Gateways` 中配置相关信息。 请注意 `Addresses` 需要每行一个,为了保证支付效率,请根据自己的订单数量准备 USDT 地址。 27 | 28 | ### 运行流程 29 | 30 | 当用户创建并选择使用 USDT 支付时,扩展程序会从你后台填写的 USDT 地址池中随机选择一个空闲地址分配给用,有效时间默认为 30 分钟(如果更改过默认值则为您更改的时间间隔),同时前台会发起异步请求后台获取支付状态,如果地址有效期即将过期那么后台会为该地址续期,直到用户关闭页面后由 cron job 终止关联关系或支付完成。 31 | 32 | 系统默认会在前台页面页面每 15 秒发起一次查询并确认订单情况,如果完成支付那么会标记订单支付完成并刷新账单页面。如果用户关闭了账单页面,那么会伴随您设置的 cron 任务频率查询订单状况并标记支付情况。当订单支付完成后系统默认会释放当前地址并等待下一次交易。如果用户部分交易那么会更新账单金额,并续期当前地址,等待支付完成。 33 | 34 | ### 致谢 35 | 36 | - 魔改项目赞助商 [1Stream](https://portal.1stream.icu) 37 | - 所有[原项目](https://github.com/licson/beefyasian-pay)的作者,赞助商 38 | -------------------------------------------------------------------------------- /includes/hooks/PolygonForWHMCS.php: -------------------------------------------------------------------------------- 1 | cron(); 9 | }); -------------------------------------------------------------------------------- /modules/gateways/polygonforwhmcs.php: -------------------------------------------------------------------------------- 1 | 'Polygon Crypto Payment for WHMCS', 25 | 'APIVersion' => '1.1', 26 | // Use API Version 1.1 27 | 'DisableLocalCreditCardInput' => true, 28 | 'TokenisedStorage' => false, 29 | ); 30 | } 31 | 32 | /** 33 | * Define gateway configuration options. 34 | * 35 | * The fields you define here determine the configuration options that are 36 | * presented to administrator users when activating and configuring your 37 | * payment gateway module for use. 38 | * 39 | * @see https://developers.whmcs.com/payment-gateways/configuration/ 40 | * 41 | * @return array 42 | */ 43 | function polygonforwhmcs_config() 44 | { 45 | return (new App)->install(); 46 | } 47 | 48 | /** 49 | * Payment link. 50 | * 51 | * Required by third party payment gateway modules only. 52 | * 53 | * Defines the HTML output displayed on an invoice. Typically consists of an 54 | * HTML form that will take the user to the payment gateway endpoint. 55 | * 56 | * @param array $params Payment Gateway Module Parameters 57 | * 58 | * @see https://developers.whmcs.com/payment-gateways/third-party-gateway/ 59 | * 60 | * @return string 61 | */ 62 | function polygonforwhmcs_link(array $params) 63 | { 64 | return (new App($params))->render($params); 65 | } -------------------------------------------------------------------------------- /modules/gateways/polygonforwhmcs/autoload.php: -------------------------------------------------------------------------------- 1 | [ 33 | 'Type' => 'System', 34 | 'Value' => 'PolygonForWHMCS', 35 | ], 36 | 'addresses' => [ 37 | 'FriendlyName' => 'USDT Addresses', 38 | 'Type' => 'textarea', 39 | 'Rows' => '20', 40 | 'Cols' => '30', 41 | ], 42 | 'timeout' => [ 43 | 'FriendlyName' => 'Timeout', 44 | 'Type' => 'text', 45 | 'Value' => 30, 46 | 'Description' => 'Minutes' 47 | ], 48 | 'apikey' => [ 49 | 'FriendlyName' => 'Polygonscan ApiKey', 50 | 'Type' => 'text', 51 | // 'Value' => 30, 52 | 'Description' => '' 53 | ] 54 | ]; 55 | 56 | /** 57 | * Smarty template engine. 58 | * 59 | * @var Smarty 60 | */ 61 | protected $smarty; 62 | 63 | /** 64 | * Create a new instance. 65 | * 66 | * @param string $addresses 67 | * @param bool $configMode 68 | * 69 | * @return void 70 | */ 71 | public function __construct(array $params = []) 72 | { 73 | if (!function_exists('getGatewayVariables')) { 74 | require_once dirname(__DIR__, 3) . DIRECTORY_SEPARATOR . 'init.php'; 75 | require_once dirname(__DIR__, 3) . DIRECTORY_SEPARATOR . 'includes/gatewayfunctions.php'; 76 | require_once dirname(__DIR__, 3) . DIRECTORY_SEPARATOR . 'includes/invoicefunctions.php'; 77 | } else { 78 | if (empty($params) && !$configMode) { 79 | try { 80 | $params = getGatewayVariables('polygonforwhmcs'); 81 | } catch (Throwable $e) { 82 | } 83 | } 84 | } 85 | 86 | $this->timeout = $params['timeout'] ?? 30; 87 | $this->addresses = array_filter(preg_split("/\r\n|\n|\r/", $params['addresses'] ?? '')); 88 | $this->apikey = $params['apikey']; 89 | 90 | $this->smarty = new Smarty(); 91 | $this->smarty->setTemplateDir(Polygon_PAY_ROOT . DIRECTORY_SEPARATOR . 'templates'); 92 | $this->smarty->setCompileDir(WHMCS_ROOT . DIRECTORY_SEPARATOR . 'templates_c'); 93 | } 94 | 95 | /** 96 | * Fetch smarty renderred template. 97 | * 98 | * @param string $viewName 99 | * @param array $arguments 100 | * 101 | * @return string 102 | */ 103 | protected function view(string $viewName, array $arguments = []) 104 | { 105 | foreach ($arguments as $name => $variable) { 106 | $this->smarty->assign($name, $variable); 107 | } 108 | 109 | return $this->smarty->fetch($viewName); 110 | } 111 | 112 | /** 113 | * Install PolygonForWHMCS. 114 | * 115 | * @return string[] 116 | */ 117 | public function install() 118 | { 119 | $this->runMigrations(); 120 | 121 | return $this->config; 122 | } 123 | 124 | /** 125 | * Run beefy asian pay migrations. 126 | * 127 | * @return void 128 | */ 129 | protected function runMigrations() 130 | { 131 | $migrationPath = __DIR__ . DIRECTORY_SEPARATOR . 'Migrations'; 132 | $migrations = array_diff(scandir($migrationPath), ['.', '..']); 133 | 134 | foreach ($migrations as $migration) { 135 | require_once $migrationPath . DIRECTORY_SEPARATOR . $migration; 136 | 137 | $migrationName = str_replace('.php', '', $migration); 138 | 139 | (new $migrationName)->execute(); 140 | } 141 | } 142 | 143 | /** 144 | * Render payment html. 145 | * 146 | * @param array $params 147 | * 148 | * @return mixed 149 | */ 150 | public function render(array $params) 151 | { 152 | switch ($_GET['act']) { 153 | case 'invoice_status': 154 | $this->renderInvoiceStatusJson($params); 155 | case 'create': 156 | $this->createPolygonForWHMCSInvoice($params); 157 | default: 158 | return $this->renderPaymentHTML($params); 159 | } 160 | } 161 | 162 | /** 163 | * Create beefy asian pay invoice. 164 | * 165 | * @param array $params 166 | * 167 | * @return void 168 | */ 169 | protected function createPolygonForWHMCSInvoice(array $params) 170 | { 171 | try { 172 | $invoice = (new Invoice())->find($params['invoiceid']); 173 | 174 | if (mb_strtolower($invoice['status']) === 'paid') { 175 | $this->json([ 176 | 'status' => false, 177 | 'error' => 'The invoice has been paid in full.' 178 | ]); 179 | } else { 180 | $address = $this->getAvailableAddress($params['invoiceid']); 181 | // $start_block = $this->getNowPolygonBlock(); 182 | 183 | $this->json([ 184 | 'status' => true, 185 | 'address' => $address, 186 | // 'start_block' => $start_block, 187 | ]); 188 | } 189 | } catch (Throwable $e) { 190 | $this->json([ 191 | 'status' => false, 192 | 'error' => $e->getMessage(), 193 | ]); 194 | } 195 | } 196 | 197 | /** 198 | * Get then invoice status json. 199 | * 200 | * @param array $params 201 | * 202 | * @return void 203 | */ 204 | protected function renderInvoiceStatusJson(array $params) 205 | { 206 | $polygonInvoice = (new PolygonForWHMCSInvoice())->firstValidByInvoiceId($params['invoiceid']); 207 | if ($polygonInvoice) { 208 | $invoice = (new Invoice())->with('transactions')->find($params['invoiceid']); 209 | $this->checkTransaction($polygonInvoice); 210 | $polygonInvoice = $polygonInvoice->refresh(); 211 | 212 | if (mb_strtolower($invoice['status']) === 'unpaid') { 213 | if ($polygonInvoice['expires_on']->subMinutes(3)->lt(Carbon::now())) { 214 | $polygonInvoice->renew($this->timeout); 215 | } 216 | 217 | $polygonInvoice = $polygonInvoice->refresh(); 218 | } 219 | 220 | $json = [ 221 | 'status' => $invoice['status'], 222 | 'amountin' => $invoice['transactions']->sum('amountin'), 223 | 'valid_till' => $polygonInvoice['expires_on']->toDateTimeString(), 224 | ]; 225 | 226 | $this->json($json); 227 | } 228 | 229 | $this->json([ 230 | 'status' => false, 231 | 'error' => 'invoice does not exists', 232 | ]); 233 | } 234 | 235 | /** 236 | * Responed with JSON. 237 | * 238 | * @param array $json 239 | * 240 | * @return void 241 | */ 242 | protected function json(array $json) 243 | { 244 | $json = json_encode($json); 245 | header('Content-Type: application/json'); 246 | echo $json; 247 | 248 | if (function_exists('fastcgi_finish_request')) { 249 | fastcgi_finish_request(); 250 | } else { 251 | exit(); 252 | } 253 | } 254 | 255 | /** 256 | * Render pay with usdt html. 257 | * 258 | * @param array $params 259 | * 260 | * @return string 261 | */ 262 | protected function renderPaymentHTML(array $params): string 263 | { 264 | $polygonInvoice = new PolygonForWHMCSInvoice(); 265 | 266 | if ($validAddress = $polygonInvoice->validInvoice($params['invoiceid'])) { 267 | $validAddress->renew($this->timeout); 268 | $validTill = Carbon::now()->addMinutes($this->timeout)->toDateTimeString(); 269 | 270 | $Currencyrate = Capsule::table("tblcurrencies")->where("code", "USD")->value("rate"); 271 | $Currencydefault = Capsule::table("tblcurrencies")->where("code", "USD")->value("default"); 272 | if ($Currencydefault == '1') { 273 | $amount = $params['amount']; 274 | } else { 275 | $amount = $params['amount'] * $Currencyrate; 276 | } 277 | return $this->view('payment.tpl', [ 278 | 'address' => $validAddress['to_address'], 279 | 'amount' => $amount, 280 | 'validTill' => $validTill, 281 | ]); 282 | } else { 283 | return $this->view('pay_with_usdt.tpl'); 284 | } 285 | } 286 | 287 | /** 288 | * Remove expired invoices. 289 | * 290 | * @return void 291 | */ 292 | public function cron() 293 | { 294 | $this->checkPaidInvoice(); 295 | 296 | (new PolygonForWHMCSInvoice())->markExpiredInvoiceAsReleased(); 297 | } 298 | 299 | /** 300 | * Check paid invoices. 301 | * 302 | * @return void 303 | */ 304 | protected function checkPaidInvoice() 305 | { 306 | $invoices = (new PolygonForWHMCSInvoice())->getValidInvoices(); 307 | 308 | $invoices->each(function ($invoice) { 309 | $this->checkTransaction($invoice); 310 | }); 311 | } 312 | 313 | /** 314 | * Check USDT Transaction. 315 | * 316 | * @param PolygonForWHMCSInvoice $invoice 317 | * 318 | * @return void 319 | */ 320 | protected function checkTransaction(PolygonForWHMCSInvoice $invoice) 321 | { 322 | $this->getTransactions($invoice['to_address'], $invoice['start_block']) 323 | ->each(function ($transaction) use ($invoice) { 324 | $whmcsTransaction = (new Transaction())->firstByTransId($transaction['hash']); 325 | $whmcsInvoice = Invoice::find($invoice['invoice_id']); 326 | // If current invoice has been paid ignore it. 327 | if ($whmcsTransaction) { 328 | return; 329 | } 330 | 331 | if (mb_strtolower($whmcsInvoice['status']) === 'paid') { 332 | return; 333 | } 334 | 335 | if (mb_strtolower($transaction['to']) != mb_strtolower($invoice['to_address']) || $transaction['tokenSymbol'] != "USDT") { 336 | return; 337 | } 338 | 339 | $Currencyrate = Capsule::table("tblcurrencies")->where("code", "USD")->value("rate"); 340 | $Currencydefault = Capsule::table("tblcurrencies")->where("code", "USD")->value("default"); 341 | if ($Currencydefault == '1') { 342 | $actualAmount = $transaction['value'] / 1000000; 343 | } else { 344 | $actualAmount = ($transaction['value'] / 1000000) / $Currencyrate; 345 | } 346 | 347 | AddInvoicePayment( 348 | $invoice['invoice_id'], // Invoice id 349 | $transaction['hash'], 350 | // Transaction id 351 | $actualAmount, 352 | // Paid amount 353 | 0, 354 | // Transaction fee 355 | 'polygonforwhmcs' // Gateway 356 | 357 | ); 358 | 359 | logTransaction('PolygonForWHMCS', $transaction, 'Successfully Paid'); 360 | 361 | $whmcsInvoice = $whmcsInvoice->refresh(); 362 | // If the invoice has been paid in full, release the address, otherwise renew it. 363 | if (mb_strtolower($whmcsInvoice['status']) === 'paid') { 364 | $invoice->markAsPaid($transaction['from'], $transaction['hash']); 365 | } else { 366 | $invoice->renew($this->timeout); 367 | } 368 | }); 369 | } 370 | 371 | /** 372 | * Get TRC 20 address transactions. 373 | * 374 | * @param string $address 375 | * @param int $startblock 376 | * 377 | * @return Collection 378 | */ 379 | protected function getTransactions(string $address, int $startblock): Collection 380 | { 381 | $http = new Client([ 382 | 'base_uri' => 'https://api.polygonscan.com', 383 | 'timeout' => 30, 384 | ]); 385 | 386 | $response = $http->get("/api", [ 387 | 'query' => [ 388 | 'module' => "account", 389 | 'action' => "tokentx", 390 | 'address' => $address, 391 | 'page' => 1, 392 | 'offset' => 5, 393 | 'startblock' => $startblock, 394 | 'sort' => "desc", 395 | 'apikey' => $this->apikey, 396 | ], 397 | ]); 398 | $response = json_decode($response->getBody()->getContents(), true); 399 | // var_dump(new Collection($response['result'])); 400 | return new Collection($response['result']); 401 | } 402 | 403 | /** 404 | * Get an available usdt address. 405 | * 406 | * @param int $invoiceId 407 | * 408 | * @return string 409 | * 410 | * @throws NoAddressAvailable 411 | * @throws RuntimeException 412 | */ 413 | protected function getAvailableAddress(int $invoiceId): string 414 | { 415 | $polygonInvoice = new PolygonForWHMCSInvoice(); 416 | 417 | if ($polygonInvoice->firstValidByInvoiceId($invoiceId)) { 418 | throw new RuntimeException("The invoice has been associated with a USDT address please refresh the invoice page."); 419 | } 420 | 421 | $inUseAddresses = $polygonInvoice->inUse()->get(['to_address']); 422 | 423 | $availableAddresses = array_values(array_diff($this->addresses, $inUseAddresses->pluck('to_address')->toArray())); 424 | 425 | if (count($availableAddresses) <= 0) { 426 | throw new NoAddressAvailable('no available address please try again later.'); 427 | } 428 | 429 | $address = $availableAddresses[array_rand($availableAddresses)]; 430 | $polygonInvoice->associate($address, $invoiceId, $this->timeout, $this->getNowPolygonBlock()); 431 | 432 | return $address; 433 | } 434 | 435 | protected function getNowPolygonBlock(): int 436 | { 437 | $http = new Client([ 438 | 'base_uri' => 'https://api.polygonscan.com', 439 | 'timeout' => 30, 440 | ]); 441 | 442 | $response = $http->get("/api", [ 443 | 'query' => [ 444 | 'module' => "block", 445 | 'action' => "getblocknobytime", 446 | 'timestamp' => time(), 447 | 'closest' => 'before', 448 | 'apikey' => $this->apikey, 449 | ], 450 | ]); 451 | $response = json_decode($response->getBody()->getContents(), true); 452 | 453 | return $response['result']; 454 | } 455 | } -------------------------------------------------------------------------------- /modules/gateways/polygonforwhmcs/src/Exceptions/NoAddressAvailable.php: -------------------------------------------------------------------------------- 1 | hasTable('mod_polygonforwhmcs_pay_invoices')) { 19 | $schema->create('mod_polygonforwhmcs_pay_invoices', function (Blueprint $table) { 20 | $table->id(); 21 | $table->integer('invoice_id'); 22 | $table->string('to_address'); 23 | $table->string('from_address')->nullable(); 24 | $table->string('transaction_id')->nullable(); 25 | $table->timestamp('expires_on')->nullable(); 26 | $table->boolean('is_released')->default(false); 27 | $table->integer("start_block")->nullable(); 28 | $table->timestamps(); 29 | }); 30 | } 31 | } 32 | } -------------------------------------------------------------------------------- /modules/gateways/polygonforwhmcs/src/Models/Invoice.php: -------------------------------------------------------------------------------- 1 | hasMany(Transaction::class, 'invoiceid'); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /modules/gateways/polygonforwhmcs/src/Models/PolygonForWHMCSInvoice.php: -------------------------------------------------------------------------------- 1 | 'datetime', 40 | ]; 41 | 42 | /** 43 | * In use condition. 44 | * 45 | * @param Builder $builder 46 | * 47 | * @return Builder 48 | */ 49 | public function scopeInUse(Builder $builder): Builder 50 | { 51 | return $builder->where('is_released', false); 52 | } 53 | 54 | /** 55 | * Assoicated with an invoice. 56 | * 57 | * @param string $address 58 | * @param int $invoiceId 59 | * @param int $timeout 60 | * 61 | * @return void 62 | */ 63 | public function associate(string $address, int $invoiceId, int $timeout = 30, string $start_block) 64 | { 65 | $this->newQuery()->create([ 66 | 'to_address' => $address, 67 | 'invoice_id' => $invoiceId, 68 | 'expires_on' => Carbon::now()->addMinutes($timeout), 69 | 'start_block' => $start_block, 70 | ]); 71 | } 72 | 73 | /** 74 | * Determine if there is an invoice within the validity period. 75 | * 76 | * @param int $invoiceId 77 | * 78 | * @return \Illuminate\Support\Collection 79 | */ 80 | public function validInvoice(int $invoiceId) 81 | { 82 | return $this->newQuery() 83 | ->where('invoice_id', $invoiceId) 84 | ->where('expires_on', '>', Carbon::now()) 85 | ->where('is_released', false) 86 | ->first(); 87 | } 88 | 89 | /** 90 | * Update the expires date. 91 | * 92 | * @param int $timeout 93 | * 94 | * @return bool 95 | */ 96 | public function renew(int $timeout = 30): bool 97 | { 98 | return $this->forceFill(['expires_on' => Carbon::now()->addMinutes($timeout)])->save(); 99 | } 100 | 101 | /** 102 | * Mark all expired invoices as released. 103 | * 104 | * @return void 105 | */ 106 | public function markExpiredInvoiceAsReleased() 107 | { 108 | $this->newQuery() 109 | ->where('expires_on', '<=', Carbon::now()) 110 | ->where('is_released', false) 111 | ->update([ 112 | 'is_released' => true, 113 | ]); 114 | } 115 | 116 | /** 117 | * Get all valid invoices. 118 | * 119 | * @return \Illuminate\Support\Collection 120 | */ 121 | public function getValidInvoices() 122 | { 123 | return $this->newQuery() 124 | ->where('expires_on', '>', Carbon::now()) 125 | ->where('is_released', 0) 126 | ->get(); 127 | } 128 | 129 | /** 130 | * Mark inovice as paid. 131 | * 132 | * @param string $fromAddress 133 | * @param string $transactionId 134 | * 135 | * @return void 136 | */ 137 | public function markAsPaid(string $fromAddress, string $transactionId) 138 | { 139 | $this->forceFill([ 140 | 'from_address' => $fromAddress, 141 | 'transaction_id' => $transactionId, 142 | 'is_released' => true, 143 | ]) 144 | ->save(); 145 | } 146 | 147 | /** 148 | * Get polygonforwhmcs pay valid invoice by invoice id. 149 | * 150 | * @param int $invoiceId 151 | * 152 | * @return \Illuminate\Database\Eloquent\Model|null 153 | */ 154 | public function firstValidByInvoiceId(int $invoiceId) 155 | { 156 | return $this->newQuery() 157 | ->where('invoice_id', $invoiceId) 158 | ->where('expires_on', '>', Carbon::now()) 159 | ->where('is_released', 0) 160 | ->latest('id') 161 | ->first(); 162 | } 163 | } -------------------------------------------------------------------------------- /modules/gateways/polygonforwhmcs/src/Models/Transaction.php: -------------------------------------------------------------------------------- 1 | newQuery() 26 | ->where('transid', $transId) 27 | ->first(); 28 | } 29 | } -------------------------------------------------------------------------------- /modules/gateways/polygonforwhmcs/templates/pay_with_usdt.tpl: -------------------------------------------------------------------------------- 1 |
Please pay $ {$amount} USDT
33 | USDT (Polygon) only, Other tokens are non-refundable
34 | Valid till {$validTill}
36 | 37 | 38 |