taler_leibacher.php (19593B)
1 <?php 2 /** 3 * @version 1.0.0 4 5 * @date 2023-05-29 6 * @copyright 2023 Tim Leibacher 7 * @license https://www.gnu.org/licenses/old-licenses/gpl-2.0.html GNU/GPLv2 8 */ 9 defined("_JEXEC") or die("Restricted Access"); 10 11 use Joomla\CMS\Uri\Uri; 12 13 /** 14 * @since 1.0.0 15 */ 16 class PayageModelTaler_Leibacher extends PayageModelAccount 17 { 18 /** 19 * Default response timeout (in seconds). 20 */ 21 public const DEFAULT_TIMEOUT = 10; 22 /** 23 * Default connect timeout (in seconds). 24 */ 25 public const DEFAULT_CONNECT_TIMEOUT = 5; 26 27 /** 28 * HTTP Methods 29 */ 30 public const HTTP_GET = "GET"; 31 public const HTTP_POST = "POST"; 32 33 var $app = null; 34 35 var $common_data = null; 36 37 var $specific_data = null; 38 39 function __construct() 40 { 41 parent::__construct(); 42 $xml_array = JInstaller::parseXMLInstallFile(JPATH_ADMINISTRATOR . '/components/com_payage/payage_taler_leibacher.xml'); 43 $this->gw_addon_version = $xml_array['version']; 44 LAPG_trace::trace("Taler: v" . $this->gw_addon_version); 45 } 46 47 // ------------------------------------------------------------------------------- 48 // Initialise data items specific to this gateway 49 // - the account class initialises the common data items 50 // 51 public function initData($gateway_info) 52 { 53 parent::initData($gateway_info); 54 $this->specific_data = new stdClass; 55 $this->specific_data->backend_url = ''; 56 $this->specific_data->backend_key = ''; 57 $this->specific_data->currency = ''; 58 $this->specific_data->refund_time = ''; 59 $this->specific_data->tid_optional = true; 60 61 } 62 63 // ------------------------------------------------------------------------------- 64 // Get the post data specific to this gateway 65 // - the account class gets the common data items 66 // 67 public function getPostData() 68 { 69 parent::getPostData(); 70 $this->specific_data = new stdClass; 71 $jinput = JFactory::getApplication()->input; 72 $this->specific_data->test_mode = $jinput->get('test_mode', '0', 'STRING'); 73 $this->specific_data->backend_url = $jinput->get('backend_url', '', 'STRING'); 74 $this->specific_data->backend_key = $jinput->get('backend_key', '', 'STRING'); 75 $this->specific_data->refund_time = $jinput->get('refund_time', '', 'STRING'); 76 $currency = $this->create_payment(self::HTTP_GET, $this->specific_data->backend_url . '/config', null)['currency']; 77 $this->specific_data->tid_optional = true; 78 79 // To allow for custom currencies which don't use 3 Letter abbreviation 80 if (strlen($currency) != 3) 81 { 82 $this->specific_data->currency = $this->convertCurrency($currency, "currency2Abr"); 83 } 84 else 85 { 86 $this->specific_data->currency = $currency; 87 } 88 89 $this->common_data->account_currency = $this->specific_data->currency; 90 91 $languages = PayageHelper::get_site_languages(); 92 93 foreach ($languages as $tag => $name) 94 { 95 $this->translations[$tag]['account_language'] = $jinput->get($tag . '_account_language', '', 'string'); 96 } 97 98 return $this->specific_data; 99 } 100 101 function convertCurrency($currency, $conversionType) 102 { 103 defined('JPATH_PAYMENT') or define('JPATH_PAYMENT', JPATH_SITE . '/administrator/components/com_payage'); 104 $file = JPATH_PAYMENT . '/currencies.csv'; 105 106 if (JFile::exists($file)) 107 { 108 // Open the CSV file 109 $file = fopen($file, 'r'); 110 111 while (($row = fgetcsv($file)) !== false) 112 { 113 if ($conversionType === 'currency2Abr' && $row[0] == $currency) 114 { 115 $value = $row[1]; 116 fclose($file); 117 LAPG_trace::trace($value); 118 119 return $value; 120 } 121 elseif ($conversionType === 'abr2Currency' && $row[1] == $currency) 122 { 123 $value = $row[0]; 124 fclose($file); 125 126 return $value; 127 } 128 } 129 } 130 131 $errors[] = JText::_('COM_PAYAGE_INVALID') . ' ' . JText::_('COM_PAYAGE_TALER_CURRENCY_NOT_FOUND'); 132 $this->app->enqueueMessage(implode('<br />', $errors), 'error'); 133 134 return 'ERR'; 135 } 136 137 // ------------------------------------------------------------------------------- 138 // Validate the account details 139 // - the account class checks the common data items 140 // 141 public function check_post_data() 142 { 143 $errors = array(); 144 $ok = parent::check_post_data(); // Check the common data 145 146 if (!str_starts_with($this->specific_data->backend_url, "http")) 147 { 148 $errors[] = JText::_('COM_PAYAGE_INVALID') . ' ' . JText::_('COM_PAYAGE_TALER_BACKEND_URL'); 149 } 150 151 if (!str_starts_with($this->specific_data->backend_key, "secret-token:")) 152 { 153 $errors[] = JText::_('COM_PAYAGE_INVALID') . ' ' . JText::_('COM_PAYAGE_TALER_KEY'); 154 } 155 156 if (!ctype_digit(trim($this->specific_data->refund_time))) 157 { 158 $errors[] = JText::_('COM_PAYAGE_INVALID') . ' ' . JText::_('COM_PAYAGE_TALER_REFUND_TIME'); 159 } 160 161 if (!empty($errors)) 162 { 163 $this->app->enqueueMessage(implode('<br />', $errors), 'error'); 164 $ok = false; 165 } 166 167 return $ok; 168 } 169 170 // ------------------------------------------------------------------------------- 171 // handle an incoming request from the payment gateway 172 // - we assume this is a genuine request because the front end found the account and payment records 173 // our model instance already has $this->common_data and $this->specific_data 174 // 175 public function Gateway_handle_request($payment_model) 176 { 177 178 $jinput = JFactory::getApplication()->input; 179 $task = $jinput->get('task', '', 'STRING'); 180 $this->payment_model = $payment_model; 181 $this->payment_data = $payment_model->data; 182 183 switch ($task) 184 { 185 case 'create': // Someone clicked a Payage Taler payment button 186 $action = $this->create(); 187 188 return $action; 189 190 case 'return': 191 case 'update': 192 case 'refund': // Used for the webhook 193 return $this->handle_return($task); 194 195 case 'cancel': 196 return LAPG_CALLBACK_CANCEL; 197 198 default: 199 LAPG_trace::trace("Taler handle_request() unknown task $task"); 200 201 return LAPG_CALLBACK_BAD; // Should never happen 202 } 203 204 } 205 206 // ------------------------------------------------------------------------------- 207 // Create a payment in Taler 208 // - we redirect here from Taler payment buttons 209 // 210 private function create() 211 { 212 LAPG_trace::trace('Taler create() for Payage transaction id: ' . $this->payment_data->pg_transaction_id); 213 214 $this->payment_data->account_id = $this->common_data->id; // Save the account_id now in case of errors 215 $this->payment_data->gw_addon_version = $this->gw_addon_version; 216 $stored = $this->payment_model->store(); 217 218 if (!function_exists('curl_version')) 219 { 220 LAPG_trace::trace("CURL not installed - cannot use Taler"); 221 $this->payment_data->pg_status_code = LAPG_STATUS_FAILED; 222 $this->payment_data->pg_status_text = JText::_('COM_PAYAGE_CURL_NOT_INSTALLED'); 223 $stored = $this->payment_model->store(); 224 225 return LAPG_CALLBACK_USER; // Return to the calling application 226 } 227 228 // Set up the payment in the gateway 229 230 $redirectUrl = htmlentities(JURI::root() . 'index.php?option=com_payage&task=return&aid=' . $this->common_data->id . '&tid=' . $this->payment_data->pg_transaction_id . '&tmpl=component&format=raw'); 231 $customer_fee = parent::calculate_gateway_fee($this->common_data, $this->payment_data->gross_amount); 232 $total_amount = $this->payment_data->gross_amount + $customer_fee; 233 $total_amount = number_format($total_amount, 2, '.', ''); // We currently only support currencies that use two decimal places 234 $currency = $this->convertCurrency($this->common_data->account_currency, "abr2Currency"); 235 236 if ($this->specific_data->refund_time < 10) 237 { 238 $refund_time = 0; 239 } 240 else 241 { 242 $refund_time = intval($this->specific_data->refund_time) * 1000; 243 } 244 245 try 246 { 247 $data = array( 248 'refund_delay' => array( 249 'd_us' => (int) $refund_time 250 ), 251 'order' => array( 252 'amount' => $currency . ':' . $total_amount, 253 'summary' => Uri::getInstance()->getHost() . ' - ' . $this->payment_data->item_name, 254 'fulfillment_url' => $redirectUrl 255 ) 256 ); 257 258 // Redirect the browser to the Taler gateway 259 260 $response_data = $this->create_payment(self::HTTP_POST, $this->specific_data->backend_url . '/private/orders', json_encode($data)); 261 262 $order_id = $response_data['order_id']; 263 $token = $response_data['token']; 264 265 // Save the payment details so far 266 267 $this->payment_data->gw_transaction_id = $order_id; 268 $stored = $this->payment_model->store(); 269 270 $url = $this->specific_data->backend_url . '/orders/' . $order_id . '?token=' . $token; 271 LAPG_trace::trace("Taler redirecting to: $url"); 272 $app = JFactory::getApplication(); 273 $app->redirect($url); 274 275 return LAPG_CALLBACK_NONE; // We are at the gateway - we will go back to the calling application later 276 } 277 catch (Exception $e) 278 { 279 $html = $e->getMessage() . '<br>' . JText::_('COM_PAYAGE_GATEWAY_TEST_RESPONSE_NOT_OK'); 280 $this->app->enqueueMessage($html, 'error'); 281 282 return; 283 } 284 } 285 286 private function create_payment($httpMethod, $url, $httpBody) 287 { 288 $curl = curl_init(); 289 290 $headers = array( 291 'Authorization: Bearer ' . $this->specific_data->backend_key, 292 'Content-Type: application/json' 293 ); 294 295 curl_setopt($curl, CURLOPT_URL, $url); 296 297 curl_setopt($curl, CURLOPT_HTTPHEADER, $headers); 298 curl_setopt($curl, CURLOPT_RETURNTRANSFER, true); 299 curl_setopt($curl, CURLOPT_CONNECTTIMEOUT, self::DEFAULT_CONNECT_TIMEOUT); 300 curl_setopt($curl, CURLOPT_TIMEOUT, self::DEFAULT_TIMEOUT); 301 302 switch ($httpMethod) 303 { 304 case self::HTTP_POST: 305 curl_setopt($curl, CURLOPT_POST, true); 306 curl_setopt($curl, CURLOPT_POSTFIELDS, $httpBody); 307 break; 308 case self::HTTP_GET: 309 break; 310 default: 311 throw new InvalidArgumentException("Invalid http method: " . $httpMethod); 312 } 313 314 $response = curl_exec($curl); 315 $httpCode = curl_getinfo($curl, CURLINFO_HTTP_CODE); 316 317 if ($response === false) 318 { 319 $curlErrorMessage = "Curl error: " . curl_error($curl); 320 curl_close($curl); 321 throw new ErrorException($curlErrorMessage); 322 } 323 324 curl_close($curl); 325 $responseData = json_decode($response, true); 326 $responseData['httpCode'] = $httpCode; 327 328 return $responseData; 329 } 330 331 // ------------------------------------------------------------------------------- 332 // handle a return from Taler 333 // - this is when Taler re-directs back to the client site after a payment 334 // 335 private function handle_return($task) 336 { 337 LAPG_trace::trace("Taler handle_return($task) for Payage transaction id: " . $this->payment_data->pg_transaction_id); 338 339 // Used for the Webhook to get the correct Order 340 if ($task == 'refund') 341 { 342 if ($_SERVER['REQUEST_METHOD'] === 'POST') 343 { 344 if ($_SERVER['CONTENT_TYPE'] === 'application/json') 345 { 346 $json_data = file_get_contents('php://input'); 347 $body = json_decode($json_data, true); 348 349 if ($body !== null) 350 { 351 $orderId = $body['order_id']; 352 353 LAPG_trace::trace('Order with Id: ' . $orderId . ' is being refunded'); 354 355 $this->payment_data = $this->payment_model->getOne($orderId, 'gw_transaction_id'); 356 } 357 else 358 { 359 LAPG_trace::trace("Missing Order ID in JSON body"); 360 361 return LAPG_STATUS_FAILED; 362 } 363 } 364 else 365 { 366 LAPG_trace::trace("refunds triggered but without a JSON header"); 367 368 return LAPG_STATUS_FAILED; 369 } 370 } 371 else 372 { 373 LAPG_trace::trace("refund triggered but wihtout a POST request"); 374 375 return LAPG_STATUS_FAILED; 376 } 377 } 378 379 // Get the payment status from the gateway 380 $response_data = $this->create_payment(self::HTTP_GET, $this->specific_data->backend_url . '/private/orders/' . $this->payment_data->gw_transaction_id, null); 381 $status = $response_data['order_status']; 382 383 switch ($status) 384 { 385 case 'paid': 386 $new_status_code = LAPG_STATUS_SUCCESS; 387 break; 388 case 'expired': 389 case 'canceled': 390 $new_status_code = LAPG_STATUS_CANCELLED; 391 break; 392 case 'failed': 393 $new_status_code = LAPG_STATUS_FAILED; 394 break; 395 case 'pending': 396 case 'unpaid': 397 $new_status_code = LAPG_STATUS_PENDING; 398 break; 399 default: 400 $new_status_code = LAPG_STATUS_FAILED; 401 } 402 403 // Check for refunds 404 405 $refund_description = ''; 406 407 if ($response_data['refunded']) 408 { 409 $amount_refunded = trim(explode(':', $response_data['refund_amount'])[1]); 410 $amount_total = trim(explode(':', $response_data['contract_terms']['amount'])[1]); 411 $refund_details = $response_data['refund_details']; 412 $refund_description = 'Taler: ' . JText::_('COM_PAYAGE_REFUNDED') . ' ' . $amount_refunded . ' details: ' . end($response_data['refund_details'])['reason']; 413 LAPG_trace::trace("Taler: $refund_description"); 414 $amount_remaining = (float) $amount_total - (float) $amount_refunded; 415 416 if ($amount_remaining == 0.0) 417 { 418 $new_status_code = LAPG_STATUS_REFUNDED; 419 $status = 'refunded'; 420 } 421 } 422 423 // Multiple calls 424 425 $result = $this->payment_model->ladb_lockTable('#__payage_payments'); 426 427 if ($result === true) 428 { 429 LAPG_trace::trace('Locked the payment table ok'); 430 } 431 else 432 { 433 LAPG_trace::trace('Failed to lock the payment table: ' . $this->payment_model->ladb_error_text); 434 } 435 436 $this->payment_data = $this->payment_model->getOne($this->payment_data->id); 437 $this->payment_data->account_id = $this->common_data->id; 438 $this->payment_data->gw_addon_version = $this->gw_addon_version; 439 $this->payment_data->pg_status_code = $new_status_code; 440 $this->payment_data->pg_status_text = $status; 441 442 // Update the history 443 444 if ($refund_description != '') 445 { 446 $this->payment_model->add_history_entry($refund_description); 447 } 448 else 449 { 450 $status_description = PayageHelper::getPaymentDescription($this->payment_data->pg_status_code); 451 $this->payment_model->add_history_entry("Taler: $status_description"); 452 } 453 454 // Update the payment record 455 // we store some details in the root of gw_transaction_details so they are easily visible 456 // we also store each update separately in case something goes wrong and we need to see the full history 457 458 $stored = $this->payment_model->store(); 459 $this->payment_model->ladb_unlock(); 460 461 // For a 'return' task, we redirect to the calling application 462 463 if ($task == 'return') 464 { 465 LAPG_trace::trace("Taler $task returning LAPG_CALLBACK_USER"); 466 467 return LAPG_CALLBACK_USER; 468 } 469 470 // It's an 'update' 471 // if this was a refund, we must update the application 472 473 if ($new_status_code == LAPG_STATUS_REFUNDED) 474 { 475 LAPG_trace::trace("Taler $task [refund], returning LAPG_CALLBACK_UPDATE"); 476 477 return LAPG_CALLBACK_UPDATE; 478 } 479 480 LAPG_trace::trace("Taler $task, returning LAPG_CALLBACK_NONE"); 481 482 return LAPG_CALLBACK_NONE; 483 } 484 485 486 // ------------------------------------------------------------------------------- 487 // Verify the amount, currency and recipient of a payment 488 // - set the payment status code and text accordingly 489 // 490 private function check_payment($customer_fee, $gross_received, $currency_received, $receiver_email) 491 { 492 $expected_gross = $this->payment_data->gross_amount; 493 $expected_total = $expected_gross + $customer_fee; 494 $str_expected_total = number_format($expected_total, 2); 495 $str_actual_total = number_format($gross_received, 2); 496 LAPG_trace::trace("check_payment() gross_amount = $expected_gross, customer_fee = $customer_fee, expected_payment_amount = $expected_total, gross_received = $gross_received"); 497 498 if (!isset($this->specific_data->auto_tax)) 499 { 500 // New parameter may not have been saved yet 501 $this->specific_data->auto_tax = 0; // default to no 502 } 503 504 if (($this->specific_data->auto_tax == 0) && ($str_expected_total != $str_actual_total)) 505 { 506 $this->payment_data->pg_status_code = LAPG_STATUS_FAILED; 507 $this->payment_data->pg_status_text = JText::sprintf("COM_PAYAGE_MISMATCH_AMOUNT", $str_expected_total, $str_actual_total); 508 LAPG_trace::trace($this->payment_data->pg_status_text); 509 } 510 511 if (($this->specific_data->auto_tax == 1) && ($str_actual_total < $str_expected_total)) 512 { 513 $this->payment_data->pg_status_code = LAPG_STATUS_FAILED; 514 $this->payment_data->pg_status_text = JText::sprintf("COM_PAYAGE_MISMATCH_AMOUNT", $str_expected_total, $str_actual_total); 515 LAPG_trace::trace($this->payment_data->pg_status_text); 516 } 517 518 if ($this->common_data->account_currency != $currency_received) 519 { 520 $this->payment_data->pg_status_code = LAPG_STATUS_FAILED; 521 $this->payment_data->pg_status_text = JText::sprintf("COM_PAYAGE_MISMATCH_CURRENCY", $this->common_data->account_currency, $currency_received); 522 LAPG_trace::trace($this->payment_data->pg_status_text); 523 } 524 525 if (strcasecmp($this->common_data->account_email, $receiver_email) == 0) 526 { 527 return; 528 } 529 530 if (isset($this->specific_data->account_primary_email) && strcasecmp($this->specific_data->account_primary_email, $receiver_email) == 0) 531 { 532 return; 533 } 534 535 $this->payment_data->pg_status_code = LAPG_STATUS_FAILED; 536 537 if (isset($this->specific_data->account_primary_email)) 538 { 539 $email_address = $this->common_data->account_email . ' OR ' . $this->specific_data->account_primary_email; 540 } 541 else 542 { 543 $email_address = $this->common_data->account_email; 544 } 545 546 $this->payment_data->pg_status_text = JText::sprintf("COM_PAYAGE_MISMATCH_RECIPIENT", $email_address, $receiver_email); 547 LAPG_trace::trace($this->payment_data->pg_status_text); 548 } 549 550 551 // ------------------------------------------------------------------------------- 552 // Build a Buy Now button 553 // 554 public function Gateway_make_button($payment_data, $call_array, $app_fee) 555 { 556 557 $process_url = JURI::root() . 'index.php?option=com_payage&task=create&aid=' . $this->common_data->id . '&tid=' . $payment_data->pg_transaction_id . '&tmpl=component&format=raw'; 558 $button_url = JURI::base(true) . '/' . $this->common_data->button_image; 559 $html = '<form class="pb-form pb-taler" action="' . $process_url . '" method="post" >'; 560 $html .= '<input type="image" src="' . $button_url . '" alt="Taler" title="' . $this->common_data->button_title . '" ' . $call_array['button_extra'] . '>'; 561 $html .= "</form>"; 562 563 return $html; 564 } 565 566 // ------------------------------------------------------------------------------- 567 // Test a Taler API call 568 // 569 public function Gateway_test() 570 { 571 LAPG_trace::trace("Taler gateway test with key " . $this->specific_data->backend_key); 572 $curl_info = curl_version(); 573 $curl_version = $curl_info['version']; 574 LAPG_trace::trace("PHP version: " . PHP_VERSION); 575 LAPG_trace::trace("CURL version: $curl_version"); 576 LAPG_trace::trace("Openssl version: " . OPENSSL_VERSION_TEXT); 577 578 if (!function_exists('curl_version')) 579 { 580 $this->app->enqueueMessage("FAIL: CURL is not installed", 'error'); 581 582 return; 583 } 584 585 try // Try to connect to Taler 586 { 587 $response_data = $this->create_payment(self::HTTP_GET, $this->specific_data->backend_url . '/private', json_encode($data)); 588 $httpCode = $response_data['httpCode']; 589 590 switch ($httpCode) 591 { 592 case 200: 593 case 201: 594 $msg = JText::_('COM_PAYAGE_GATEWAY_TEST_PASSED'); 595 $this->app->enqueueMessage($msg, 'message'); 596 break; 597 case 401: 598 $msg = JText::_('COM_PAYAGE_GATEWAY_TEST_BAD_KEY'); 599 $this->app->enqueueMessage($msg, 'error'); 600 break; 601 case 404: 602 $msg = JText::_('COM_PAYAGE_GATEWAY_TEST_BAD_URL'); 603 $this->app->enqueueMessage($msg, 'error'); 604 break; 605 case str_starts_with($httpCode, '5'): 606 $msg = JText::_('COM_PAYAGE_GATEWAY_TEST_SERVER_ERROR'); 607 $this->app->enqueueMessage($msg, 'error'); 608 break; 609 } 610 } 611 catch (Exception $e) 612 { 613 $html = $e->getMessage() . '<br>' . JText::_('COM_PAYAGE_GATEWAY_TEST_RESPONSE_NOT_OK'); 614 $this->app->enqueueMessage($html, 'error'); 615 616 return; 617 } 618 619 LAPG_trace::trace("Taler Gateway_test done"); 620 } 621 } 622