| 1 |
<?php |
| 2 |
|
| 3 |
if ( ! defined( 'ABSPATH' ) ) { |
| 4 |
exit; |
| 5 |
} |
| 6 |
|
| 7 |
require_once(__DIR__ . '/flutter-base.php'); |
| 8 |
|
| 9 |
/* |
| 10 |
* Base REST Controller for flutter |
| 11 |
* |
| 12 |
* @since 1.4.0 |
| 13 |
* |
| 14 |
* @package PayStack |
| 15 |
*/ |
| 16 |
|
| 17 |
class FlutterExpressPay extends FlutterBaseController |
| 18 |
{ |
| 19 |
private function expresspay_post_json( $url, $payload ) { |
| 20 |
return wp_remote_post( |
| 21 |
$url, |
| 22 |
array( |
| 23 |
'timeout' => 15, |
| 24 |
'headers' => array( |
| 25 |
'Content-Type' => 'application/json', |
| 26 |
), |
| 27 |
'body' => wp_json_encode( $payload ), |
| 28 |
) |
| 29 |
); |
| 30 |
} |
| 31 |
|
| 32 |
private function expresspay_post_form( $url, $payload ) { |
| 33 |
return wp_remote_post( |
| 34 |
$url, |
| 35 |
array( |
| 36 |
'timeout' => 15, |
| 37 |
'headers' => array( |
| 38 |
'Content-Type' => 'application/x-www-form-urlencoded', |
| 39 |
), |
| 40 |
'body' => $payload, |
| 41 |
) |
| 42 |
); |
| 43 |
} |
| 44 |
|
| 45 |
/** |
| 46 |
* Endpoint namespace |
| 47 |
* |
| 48 |
* @var string |
| 49 |
*/ |
| 50 |
protected $namespace = 'api/flutter_expresspay'; |
| 51 |
|
| 52 |
/** |
| 53 |
* Register all routes related with stores |
| 54 |
* |
| 55 |
* @return void |
| 56 |
*/ |
| 57 |
public function __construct() |
| 58 |
{ |
| 59 |
add_action('rest_api_init', array($this, 'register_flutter_expresspay_routes')); |
| 60 |
} |
| 61 |
|
| 62 |
public function register_flutter_expresspay_routes() |
| 63 |
{ |
| 64 |
register_rest_route($this->namespace, '/card_checkout', array( |
| 65 |
array( |
| 66 |
'methods' => "POST", |
| 67 |
'callback' => array($this, 'card_checkout'), |
| 68 |
'permission_callback' => function () { |
| 69 |
return parent::checkApiPermission(); |
| 70 |
} |
| 71 |
), |
| 72 |
)); |
| 73 |
|
| 74 |
register_rest_route($this->namespace, '/verify_payment', array( |
| 75 |
array( |
| 76 |
'methods' => "POST", |
| 77 |
'callback' => array($this, 'verify_payment'), |
| 78 |
'permission_callback' => function () { |
| 79 |
return parent::checkApiPermission(); |
| 80 |
} |
| 81 |
), |
| 82 |
)); |
| 83 |
} |
| 84 |
|
| 85 |
public function verify_payment($request) |
| 86 |
{ |
| 87 |
|
| 88 |
if (!is_plugin_active('woo-web-payment-getaway/web-payment-gateway.php')) { |
| 89 |
return parent::send_invalid_plugin_error("You need to install ShahbandrPay plugin to use this api"); |
| 90 |
} |
| 91 |
|
| 92 |
$json = file_get_contents('php://input'); |
| 93 |
$body = json_decode($json, TRUE); |
| 94 |
$order_id = sanitize_text_field($body['order_id']); |
| 95 |
$transaction_id = sanitize_text_field($body['transaction_id']); |
| 96 |
|
| 97 |
if (empty($order_id) || empty($transaction_id)) { |
| 98 |
return new WP_Error('missing_parameters', 'Order ID and Transaction ID are required.', array('status' => 400)); |
| 99 |
} |
| 100 |
|
| 101 |
$order = wc_get_order($order_id); |
| 102 |
if (!$order) { |
| 103 |
return new WP_Error('order_not_found', 'Order not found.', array('status' => 404)); |
| 104 |
} |
| 105 |
|
| 106 |
// SECURITY CHECK 0: Authorise the caller against this specific order. |
| 107 |
// These routes are otherwise gated only by the site-global purchase-code |
| 108 |
// check, so nothing ties the caller to the order they name. |
| 109 |
$key_check = mstore_api_check_payment_order_key($order, $body); |
| 110 |
if (is_wp_error($key_check)) { |
| 111 |
return $key_check; |
| 112 |
} |
| 113 |
|
| 114 |
// SECURITY CHECK 1: Prevent double processing |
| 115 |
if (!$order->needs_payment()) { |
| 116 |
return rest_ensure_response(['success' => true, 'message' => 'Order already processed.']); |
| 117 |
} |
| 118 |
|
| 119 |
// SECURITY CHECK 2: Replay Attack Prevention (HPOS Compatible) |
| 120 |
$existing_orders = wc_get_orders(array( |
| 121 |
'transaction_id' => $transaction_id, |
| 122 |
'status' => array('processing', 'completed', 'on-hold'), |
| 123 |
'exclude' => array($order_id), |
| 124 |
'limit' => 1, |
| 125 |
'return' => 'ids' |
| 126 |
)); |
| 127 |
|
| 128 |
if (!empty($existing_orders)) { |
| 129 |
$order->add_order_note(sprintf('Security Alert: ExpressPay transaction ID %s already used for another order. Replay attack blocked.', $transaction_id)); |
| 130 |
return new WP_Error('replay_attack', 'This payment transaction was already used for another order.', array('status' => 403)); |
| 131 |
} |
| 132 |
|
| 133 |
$options = get_option( 'woocommerce_shahbandrpay_settings'); |
| 134 |
$password = isset($options['password']) ? $options['password'] : ''; |
| 135 |
$secret = isset($options['secret']) ? $options['secret'] : ''; |
| 136 |
$new_order_status = !empty($options['new_order_status']) ? $options['new_order_status'] : 'processing'; |
| 137 |
|
| 138 |
if (empty($password) || empty($secret)) { |
| 139 |
return new WP_Error('expresspay_misconfigured', 'ExpressPay (ShahbandrPay) is not configured.', array('status' => 500)); |
| 140 |
} |
| 141 |
|
| 142 |
$hash = sha1(md5(strtoupper($transaction_id . $password))); |
| 143 |
$url = 'https://pay.expresspay.sa/api/v1/payment/status'; |
| 144 |
|
| 145 |
$main_json = [ |
| 146 |
"merchant_key" => $secret, |
| 147 |
"payment_id" => $transaction_id, |
| 148 |
"hash" => $hash |
| 149 |
]; |
| 150 |
|
| 151 |
$result = $this->expresspay_post_json( $url, $main_json ); |
| 152 |
$http_code = is_wp_error( $result ) ? 0 : wp_remote_retrieve_response_code( $result ); |
| 153 |
$response_body = is_wp_error( $result ) ? '' : wp_remote_retrieve_body( $result ); |
| 154 |
|
| 155 |
if ( is_wp_error( $result ) || $http_code !== 200 ) { |
| 156 |
$order->add_order_note('Security Alert: ExpressPay S2S verification request failed or returned HTTP ' . $http_code); |
| 157 |
return new WP_Error('s2s_verification_failed', 'Could not verify payment with ExpressPay.', array('status' => 502)); |
| 158 |
} |
| 159 |
|
| 160 |
$response = json_decode($response_body, true); |
| 161 |
|
| 162 |
// SECURITY CHECK 3: Validate Payment Status |
| 163 |
if (isset($response['status']) && $response['status'] == 'settled') { |
| 164 |
|
| 165 |
// SECURITY CHECK 4: Bind the transaction to THIS order's amount and currency. |
| 166 |
// |
| 167 |
// 'settled' on its own only proves the transaction is real, not that it |
| 168 |
// paid for this order. Without this an attacker can settle a cheap order, |
| 169 |
// withhold the payment_success call so it stays pending (and therefore |
| 170 |
// out of the replay check above), then present that transaction id |
| 171 |
// against an expensive order. |
| 172 |
// |
| 173 |
// ExpressPay echoes the fields card_checkout() submits (order_amount / |
| 174 |
// order_currency); the alternatives are accepted so a response shape |
| 175 |
// change does not silently disable the check. |
| 176 |
$paid_amount = $this->first_present($response, array('order_amount', 'amount', 'total')); |
| 177 |
$paid_currency = $this->first_present($response, array('order_currency', 'currency')); |
| 178 |
|
| 179 |
if ($paid_amount === null) { |
| 180 |
// Fail closed: an unverifiable amount is exactly the case being exploited. |
| 181 |
$order->add_order_note('Security Alert: ExpressPay status response carried no amount field, so the transaction could not be bound to this order. Payment not applied. Response keys: ' . esc_html(implode(', ', array_keys((array) $response)))); |
| 182 |
return new WP_Error('amount_unverifiable', 'Could not verify the paid amount with ExpressPay.', array('status' => 502)); |
| 183 |
} |
| 184 |
|
| 185 |
$order_total = (float) $order->get_total(); |
| 186 |
|
| 187 |
if (abs($order_total - (float) $paid_amount) > 0.01) { |
| 188 |
$order->add_order_note(sprintf( |
| 189 |
'Security Alert: ExpressPay amount mismatch. Order total: %s, Paid: %s. Order status unchanged.', |
| 190 |
esc_html($order_total), |
| 191 |
esc_html($paid_amount) |
| 192 |
)); |
| 193 |
return new WP_Error('amount_mismatch', 'Paid amount does not match order total.', array('status' => 400)); |
| 194 |
} |
| 195 |
|
| 196 |
if ($paid_currency !== null |
| 197 |
&& strtoupper((string) $paid_currency) !== strtoupper($order->get_currency())) { |
| 198 |
$order->add_order_note(sprintf( |
| 199 |
'Security Alert: ExpressPay currency mismatch. Order currency: %s, Paid: %s. Order status unchanged.', |
| 200 |
esc_html($order->get_currency()), |
| 201 |
esc_html($paid_currency) |
| 202 |
)); |
| 203 |
return new WP_Error('currency_mismatch', 'Paid currency does not match order currency.', array('status' => 400)); |
| 204 |
} |
| 205 |
|
| 206 |
// All checks passed. |
| 207 |
$order->payment_complete($transaction_id); |
| 208 |
$order->add_order_note('ExpressPay Verified: S2S Payment successful.<br/>Transaction ID: ' . esc_html($transaction_id)); |
| 209 |
$order->update_meta_data('_expresspay_transaction_id', $transaction_id); |
| 210 |
$order->save(); |
| 211 |
|
| 212 |
if ($order->get_status() !== $new_order_status) { |
| 213 |
$order->update_status($new_order_status); |
| 214 |
} |
| 215 |
|
| 216 |
return ['success' => true]; |
| 217 |
} else { |
| 218 |
$order->add_order_note('Security Alert: ExpressPay payment not settled. Status: ' . esc_html($response['status'] ?? 'unknown')); |
| 219 |
return new WP_Error('payment_not_settled', $response['reason'] ?? 'ExpressPay payment failed.', array('status' => 400)); |
| 220 |
} |
| 221 |
} |
| 222 |
|
| 223 |
/** |
| 224 |
* Return the first key present and non-empty-string in $data, or null. |
| 225 |
* |
| 226 |
* @param array $data |
| 227 |
* @param array $keys Candidate keys, in priority order. |
| 228 |
* @return mixed|null |
| 229 |
*/ |
| 230 |
private function first_present($data, $keys) |
| 231 |
{ |
| 232 |
if (!is_array($data)) { |
| 233 |
return null; |
| 234 |
} |
| 235 |
|
| 236 |
foreach ($keys as $key) { |
| 237 |
if (isset($data[$key]) && $data[$key] !== '') { |
| 238 |
return $data[$key]; |
| 239 |
} |
| 240 |
} |
| 241 |
|
| 242 |
return null; |
| 243 |
} |
| 244 |
|
| 245 |
public function card_checkout($request) |
| 246 |
{ |
| 247 |
if (!is_plugin_active('woo-web-payment-getaway/web-payment-gateway.php')) { |
| 248 |
return parent::send_invalid_plugin_error("You need to install ShahbandrPay plugin to use this api"); |
| 249 |
} |
| 250 |
|
| 251 |
$json = file_get_contents('php://input'); |
| 252 |
$body = json_decode($json, TRUE); |
| 253 |
$order_id = sanitize_text_field($body['order_id']); |
| 254 |
$card_number = sanitize_text_field($body['card_number']); |
| 255 |
$card_exp = sanitize_text_field($body['card_exp']); |
| 256 |
$card_cvc = sanitize_text_field($body['card_cvc']); |
| 257 |
$return_url = sanitize_text_field($body['return_url']); |
| 258 |
|
| 259 |
|
| 260 |
$options = get_option( 'woocommerce_shahbandrpay_settings'); |
| 261 |
$password = $options['password']; |
| 262 |
$secret = $options['secret']; |
| 263 |
|
| 264 |
global $woocommerce; |
| 265 |
|
| 266 |
$order = new WC_Order($order_id); |
| 267 |
$user = $order->get_user(); |
| 268 |
$user_id = $order->get_user_id(); |
| 269 |
$currency = method_exists( $order, 'get_currency' ) ? $order->get_currency() : $order->order_currency; |
| 270 |
|
| 271 |
$action_adr = 'https://api.expresspay.sa/post'; |
| 272 |
$customerName = ''; |
| 273 |
if(mb_detect_encoding($order->get_billing_first_name()) !== 'UTF-8' && mb_detect_encoding($order->get_billing_last_name()) !== 'UTF-8') { |
| 274 |
$customerName = $order->get_billing_first_name() . ' ' . $order->get_billing_last_name(); |
| 275 |
} |
| 276 |
$email = $order->get_billing_email() ? $order->get_billing_email() : $user->email; |
| 277 |
|
| 278 |
if ($customerName == '') { |
| 279 |
$customer = array( |
| 280 |
'email' => $email |
| 281 |
); |
| 282 |
} else { |
| 283 |
$customer = array( |
| 284 |
'name' => $customerName, |
| 285 |
'email' => $email |
| 286 |
); |
| 287 |
} |
| 288 |
|
| 289 |
$billing_address = array( |
| 290 |
'country' => $order->get_billing_country() ? $order->get_billing_country() : 'NA', |
| 291 |
'state' => $order->get_billing_state() ? $order->get_billing_state() : 'NA', |
| 292 |
'city' => $order->get_billing_city() ? $order->get_billing_city() : 'NA', |
| 293 |
'address' => $order->get_billing_address_1() ? $order->get_billing_address_1() : 'NA', |
| 294 |
'zip' => $order->get_billing_postcode() ? $order->get_billing_postcode() : '12271', |
| 295 |
'phone' => $order->get_billing_phone() ? $order->get_billing_phone() : '', |
| 296 |
'email' => $email |
| 297 |
); |
| 298 |
|
| 299 |
$amount = number_format($order->get_total(), 2, '.', ''); |
| 300 |
|
| 301 |
$order_json = array( |
| 302 |
'number' => "$order_id", |
| 303 |
'description' => __('Payment Order # ', 'mstore-api') . $order_id . __(' in the store ', 'mstore-api') . home_url('/'), |
| 304 |
'amount' => $amount, |
| 305 |
'currency' => $currency, |
| 306 |
); |
| 307 |
|
| 308 |
$card_number = str_replace(" ","",$card_number); |
| 309 |
if ($card_exp) { |
| 310 |
$exp_array = explode('/', $card_exp); |
| 311 |
$month = str_replace(" ", "",$exp_array[0]); |
| 312 |
$year = str_replace(" ", "",'20'.$exp_array[1]); |
| 313 |
} else { |
| 314 |
$month = ''; |
| 315 |
$year = ''; |
| 316 |
} |
| 317 |
|
| 318 |
$hash = md5(strtoupper(strrev($email).$password.strrev(substr($card_number,0,6).substr($card_number,-4)))); |
| 319 |
|
| 320 |
$data = [ |
| 321 |
'action' => 'SALE', |
| 322 |
'client_key' => $secret, |
| 323 |
'order_id' => 'ORDER-' . $order_id . time(), |
| 324 |
'order_amount' => $amount, |
| 325 |
'order_currency' => $currency, |
| 326 |
'order_description' => __('Product Order # ', 'mstore-api') . $order_id, |
| 327 |
'card_number' => $card_number, |
| 328 |
'card_exp_month' => $month, |
| 329 |
'card_exp_year' => $year, |
| 330 |
'card_cvv2' => $card_cvc, |
| 331 |
'payer_first_name' => $order->get_billing_first_name(), |
| 332 |
'payer_last_name' => $order->get_billing_last_name(), |
| 333 |
'payer_address' => $billing_address['address'], |
| 334 |
'payer_country' => $billing_address['country'], |
| 335 |
'payer_city' => $billing_address['city'], |
| 336 |
'payer_zip' => $billing_address['zip'], |
| 337 |
'payer_email' => $billing_address['email'], |
| 338 |
'payer_phone' => $billing_address['phone'], |
| 339 |
'payer_ip' => '123.123.123.123', |
| 340 |
'term_url_3ds' => $return_url, |
| 341 |
'hash' => $hash, |
| 342 |
]; |
| 343 |
|
| 344 |
|
| 345 |
$result = $this->expresspay_post_form( $action_adr, $data ); |
| 346 |
$httpcode = is_wp_error( $result ) ? 0 : wp_remote_retrieve_response_code( $result ); |
| 347 |
$response_body = is_wp_error( $result ) ? '' : wp_remote_retrieve_body( $result ); |
| 348 |
$response = json_decode($response_body, true); |
| 349 |
|
| 350 |
if (is_wp_error($result) || $httpcode != 200) { |
| 351 |
$errors = ''; |
| 352 |
if (isset($response['errors']) && is_array($response['errors'])) { |
| 353 |
foreach($response['errors'] as $value){ |
| 354 |
$errors .= $value['error_code'] . ' : ' .$value['error_message'].'<br>'; |
| 355 |
} |
| 356 |
} |
| 357 |
if ($errors === '') { |
| 358 |
$errors = 'Please try again.'; |
| 359 |
} |
| 360 |
return parent::sendError("invalid_payment", $errors, 400); |
| 361 |
} |
| 362 |
|
| 363 |
if ($response['result'] == 'SUCCESS' && $response['status'] == 'SETTLED') { |
| 364 |
|
| 365 |
$order->payment_complete($order_id); |
| 366 |
$order->update_status($new_order_status, 'ShahbandrPay successfully paid'); |
| 367 |
$order->add_order_note( 'ShahbandrPay successfully paid' ); |
| 368 |
|
| 369 |
update_post_meta( $order_id, 'trans_id', $response['trans_id'] ); |
| 370 |
update_post_meta( $order_id, 'trans_date', $response['trans_date'] ); |
| 371 |
update_post_meta( $order_id, 'trans_hash', $hash ); |
| 372 |
|
| 373 |
return array( |
| 374 |
'success' => true, |
| 375 |
); |
| 376 |
}elseif($response['result'] == 'REDIRECT' && $response['status'] == 'REDIRECT' ){ |
| 377 |
$order->update_status('on-hold', 'Awaiting 3-D Secure Payment'); |
| 378 |
update_post_meta( $order_id, 'trans_id', $response['trans_id'] ); |
| 379 |
update_post_meta( $order_id, 'trans_date', $response['trans_date'] ); |
| 380 |
update_post_meta( $order_id, 'trans_hash', $hash ); |
| 381 |
|
| 382 |
$body = $response['redirect_params']['body']; |
| 383 |
$url = $response['redirect_url']; |
| 384 |
$method = $response['redirect_method']; |
| 385 |
|
| 386 |
return array( |
| 387 |
'body' => $response['redirect_params']['body'], |
| 388 |
'url' => $response['redirect_url'], |
| 389 |
'method' => $response['redirect_method'], |
| 390 |
'trans_id' => $response['trans_id'] |
| 391 |
); |
| 392 |
} |
| 393 |
else { |
| 394 |
return parent::sendError("invalid_payment", 'Please try again.', 400); |
| 395 |
} |
| 396 |
} |
| 397 |
} |
| 398 |
|
| 399 |
new FlutterExpressPay; |
| 400 |
|