log($level, $message, array('source' => 'woocommerce-alatpay')); } if (function_exists('error_log')) { error_log('[ALATPay] ' . $message); } } } if (! function_exists('alatpay_transaction_log')) { /** * Log payment transaction events in a dedicated WooCommerce log. * * @param string $level Log level, e.g. info, warning. * @param string $message Message to log. * @param array $context Optional context passed to the logger. * @return void */ function alatpay_transaction_log($level, $message, $context = array()) { if (function_exists('wc_get_logger')) { $logger_context = array_merge(array('source' => 'woocommerce-alatpay-payments'), $context); wc_get_logger()->log($level, $message, $logger_context); } if (function_exists('error_log')) { error_log('[ALATPay][Txn] ' . $message); } } } if (! function_exists('alatpay_add_success_note_if_needed')) { /** * Attach a single success note to an order. * * @param mixed $order WooCommerce order instance. * @return bool True when meta updates are pending a save. */ function alatpay_add_success_note_if_needed($order) { if (! ($order instanceof WC_Order)) { return false; } if ('yes' === $order->get_meta('_alatpay_success_note_added')) { return false; } $order->add_order_note(__('Payment received via ALATPay (Success).', 'alatpay')); $order->update_meta_data('_alatpay_success_note_added', 'yes'); return true; } } if (! function_exists('alatpay_add_initiated_note_if_needed')) { /** * Attach a single note when checkout is initiated. * * @param mixed $order WooCommerce order instance. * @return bool True when meta updates are pending a save. */ function alatpay_add_initiated_note_if_needed($order) { if (! ($order instanceof WC_Order)) { return false; } if ('yes' === $order->get_meta('_alatpay_initiated_note_added')) { return false; } $order->add_order_note(__('Customer clicked "Pay with ALATPay". Awaiting payment confirmation.', 'alatpay')); $order->update_meta_data('_alatpay_initiated_note_added', 'yes'); return true; } } if (! function_exists('alatpay_add_failure_note_if_needed')) { /** * Attach a single failure note to an order. * * @param mixed $order WooCommerce order instance. * @return bool True when meta updates are pending a save. */ function alatpay_add_failure_note_if_needed($order) { if (! ($order instanceof WC_Order)) { return false; } if ('yes' === $order->get_meta('_alatpay_failure_note_added')) { return false; } $order->add_order_note(__('Payment failed via ALATPay (Failed).', 'alatpay')); $order->update_meta_data('_alatpay_failure_note_added', 'yes'); return true; } } add_filter('woocommerce_payment_gateways', 'alatpay_add'); add_action('woocommerce_receipt_alatpay', 'alatpay_payment_page', 10, 1); function alatpay_add($gateways) { $gateways[] = 'Alatpay_Payment_Gateway'; return $gateways; } /** * Custom function to declare compatibility with cart_checkout_blocks feature */ function alatpay_checkout_blocks_compatibility() { // Check if the required class exists if (class_exists('\Automattic\WooCommerce\Utilities\FeaturesUtil')) { // Declare compatibility for 'cart_checkout_blocks' \Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility('cart_checkout_blocks', __FILE__, true); } } // Hook the custom function to the 'before_woocommerce_init' action add_action('before_woocommerce_init', 'alatpay_checkout_blocks_compatibility'); // Hook the custom function to the 'woocommerce_blocks_loaded' action add_action('woocommerce_blocks_loaded', 'alatpay_register_order_approval_payment_method_type'); /** * Custom function to register a payment method type */ function alatpay_register_order_approval_payment_method_type() { // Check if the required class exists if (! class_exists('Automattic\WooCommerce\Blocks\Payments\Integrations\AbstractPaymentMethodType')) { return; } // Include the custom Blocks Checkout class require_once plugin_dir_path(__FILE__) . 'class-gateway-alatpay-block.php'; // Hook the registration function to the 'woocommerce_blocks_payment_method_type_registration' action add_action( 'woocommerce_blocks_payment_method_type_registration', function (Automattic\WooCommerce\Blocks\Payments\PaymentMethodRegistry $payment_method_registry) { // Register an instance of Alatpay_Payment_Gateway_Blocks $payment_method_registry->register(new Alatpay_Payment_Gateway_Blocks); } ); } add_action('rest_api_init', 'alatpay_register_webhook'); if (! function_exists('alatpay_get_gateway_instance')) { /** * Build the ALATPay gateway instance. * * @return Alatpay_Payment_Gateway */ function alatpay_get_gateway_instance() { return new Alatpay_Payment_Gateway(); } } if (! function_exists('alatpay_get_requery_url')) { /** * Resolve ALATPay requery endpoint for current mode. * * @param string $transaction_id Transaction identifier. * @param Alatpay_Payment_Gateway $gateway Gateway settings instance. * @return string */ function alatpay_get_requery_url($transaction_id, $gateway) { $is_test_mode = ('yes' === $gateway->get_option('test_mode')); $base_url = $is_test_mode ? 'https://alatpay.azure-api.net/transaction/api/v1/transactions/' : 'https://apibox.alatpay.ng/alatpaytransaction/api/v1/transactions/'; return esc_url_raw($base_url . rawurlencode($transaction_id)); } } if (! function_exists('alatpay_extract_transaction_payload')) { /** * Extract transaction payload from different ALATPay response envelopes. * * @param mixed $decoded Response body decoded from JSON. * @return array */ function alatpay_extract_transaction_payload($decoded) { if (! is_array($decoded)) { return array(); } if (isset($decoded['Value']['Data']) && is_array($decoded['Value']['Data'])) { return $decoded['Value']['Data']; } if (isset($decoded['value']['data']) && is_array($decoded['value']['data'])) { return $decoded['value']['data']; } if (isset($decoded['value']) && is_array($decoded['value'])) { return $decoded['value']; } if (isset($decoded['data']) && is_array($decoded['data'])) { return $decoded['data']; } return $decoded; } } if (! function_exists('alatpay_payload_get')) { /** * Safely read a key from ALATPay payload, supporting mixed key casing. * * @param array $payload Transaction payload. * @param array $path Nested path segments. * @param mixed $default Fallback value. * @return mixed */ function alatpay_payload_get($payload, $path, $default = null) { if (! is_array($payload)) { return $default; } $cursor = $payload; foreach ((array) $path as $segment) { if (! is_array($cursor)) { return $default; } if (array_key_exists($segment, $cursor)) { $cursor = $cursor[$segment]; continue; } $matched = false; foreach ($cursor as $key => $value) { if (0 === strcasecmp((string) $key, (string) $segment)) { $cursor = $value; $matched = true; break; } } if (! $matched) { return $default; } } return $cursor; } } if (! function_exists('alatpay_get_payload_status')) { /** * Resolve transaction status from payload. * * @param array $payload Transaction payload. * @return string */ function alatpay_get_payload_status($payload) { return strtolower((string) alatpay_payload_get($payload, array('status'), '')); } } if (! function_exists('alatpay_get_payload_currency')) { /** * Resolve transaction currency from payload. * * @param array $payload Transaction payload. * @return string */ function alatpay_get_payload_currency($payload) { return strtoupper((string) alatpay_payload_get($payload, array('currency'), '')); } } if (! function_exists('alatpay_get_payload_amount')) { /** * Resolve transaction amount from payload. * * @param array $payload Transaction payload. * @return float */ function alatpay_get_payload_amount($payload) { return floatval(alatpay_payload_get($payload, array('amount'), 0)); } } if (! function_exists('alatpay_extract_order_id_from_payload')) { /** * Extract WooCommerce order ID from ALATPay payload metadata. * * @param array $payload Transaction payload. * @return int */ function alatpay_extract_order_id_from_payload($payload) { if (! is_array($payload)) { return 0; } $metadata_json = alatpay_payload_get($payload, array('customer', 'metadata'), '{}'); $metadata = json_decode((string) $metadata_json, true); if (! is_array($metadata)) { return 0; } return absint($metadata['order_id'] ?? 0); } } if (! function_exists('alatpay_extract_order_token_from_payload')) { /** * Extract order token from ALATPay payload metadata. * * @param array $payload Transaction payload. * @return string */ function alatpay_extract_order_token_from_payload($payload) { if (! is_array($payload)) { return ''; } $metadata_json = alatpay_payload_get($payload, array('customer', 'metadata'), '{}'); $metadata = json_decode((string) $metadata_json, true); if (! is_array($metadata)) { return ''; } return sanitize_text_field((string) ($metadata['order_token'] ?? '')); } } if (! function_exists('alatpay_get_transaction_id_from_payload')) { /** * Extract transaction ID/reference from ALATPay payload. * * @param array $payload Transaction payload. * @return string */ function alatpay_get_transaction_id_from_payload($payload) { if (! is_array($payload)) { return ''; } $transaction_id = alatpay_payload_get($payload, array('customer', 'transactionId'), ''); if (! $transaction_id) { $transaction_id = alatpay_payload_get($payload, array('transactionId'), ''); } if (! $transaction_id) { $transaction_id = alatpay_payload_get($payload, array('reference'), ''); } if (! $transaction_id) { $transaction_id = alatpay_payload_get($payload, array('id'), ''); } return sanitize_text_field((string) $transaction_id); } } if (! function_exists('alatpay_get_processed_transactions')) { /** * Retrieve processed transaction IDs for idempotency. * * @param WC_Order $order WooCommerce order. * @return array */ function alatpay_get_processed_transactions($order) { $processed = $order->get_meta('_alatpay_processed_transaction_ids', true); if (! is_array($processed)) { $processed = array(); } return array_values(array_filter(array_map('strval', $processed))); } } if (! function_exists('alatpay_is_transaction_processed')) { /** * Determine if transaction was already processed. * * @param WC_Order $order WooCommerce order. * @param string $transaction_id Transaction identifier. * @return bool */ function alatpay_is_transaction_processed($order, $transaction_id) { if ('' === $transaction_id) { return false; } return in_array($transaction_id, alatpay_get_processed_transactions($order), true); } } if (! function_exists('alatpay_mark_transaction_processed')) { /** * Mark transaction as processed for idempotency. * * @param WC_Order $order WooCommerce order. * @param string $transaction_id Transaction identifier. * @return void */ function alatpay_mark_transaction_processed($order, $transaction_id) { if ('' === $transaction_id) { return; } $processed = alatpay_get_processed_transactions($order); if (! in_array($transaction_id, $processed, true)) { $processed[] = $transaction_id; $order->update_meta_data('_alatpay_processed_transaction_ids', $processed); } } } if (! function_exists('alatpay_requery_transaction')) { /** * Requery transaction from ALATPay API. * * @param string $transaction_id Transaction identifier. * @param Alatpay_Payment_Gateway $gateway Gateway settings instance. * @return array|WP_Error */ function alatpay_requery_transaction($transaction_id, $gateway) { $transaction_id = sanitize_text_field((string) $transaction_id); if ('' === $transaction_id) { return new WP_Error('bad_request', 'Missing transaction_id for requery.', array('status' => 400)); } $api_key = sanitize_text_field((string) $gateway->get_option('api_key')); if ('' === $api_key) { return new WP_Error('unauthorized', 'API key is not configured.', array('status' => 401)); } $response = wp_remote_request( alatpay_get_requery_url($transaction_id, $gateway), array( 'method' => 'GET', 'headers' => array( 'Content-Type' => 'application/json', 'Accept' => 'application/json', 'Ocp-Apim-Subscription-Key' => $api_key, 'Ocp-Apim-Trace' => 'true', ), 'timeout' => 30, ) ); if (is_wp_error($response)) { return new WP_Error('requery_failed', $response->get_error_message(), array('status' => 502)); } $status_code = (int) wp_remote_retrieve_response_code($response); $body = wp_remote_retrieve_body($response); $decoded = json_decode($body, true); if ($status_code < 200 || $status_code >= 300 || ! is_array($decoded)) { return new WP_Error('requery_failed', 'Unable to verify transaction from ALATPay.', array('status' => 502)); } $payload = alatpay_extract_transaction_payload($decoded); if (! is_array($payload) || empty($payload)) { return new WP_Error('requery_failed', 'ALATPay requery returned an invalid payload.', array('status' => 502)); } return $payload; } } function alatpay_register_webhook() { register_rest_route('alatpay/v1', '/webhook', array( 'methods' => 'POST', 'callback' => 'alatpay_handle_webhook', 'permission_callback' => '__return_true' )); } function alatpay_handle_webhook($request) { $request_method = strtoupper((string) $request->get_method()); if ('POST' !== $request_method) { return new WP_Error('method_not_allowed', 'Invalid request method', array('status' => 405)); } // Get the signature from the request header $signature = $request->get_header('x-signature'); // If the signature is not present, return an error if (! $signature) { alatpay_log('error', 'Missing signature. Contact ALATPay support.'); return new WP_Error('unauthorized', 'Missing signature', array('status' => 401)); } // Get the request body $body = $request->get_body(); // Get the webhook secret from the gateway settings $gateway = new Alatpay_Payment_Gateway(); $secret_key = $gateway->get_option('webhook_secret'); // If the secret key is not set, return an error if (empty($secret_key)) { alatpay_log('error', 'Webhook secret is not configured.'); return new WP_Error('unauthorized', 'Webhook secret is not configured', array('status' => 401)); } // Calculate the HMAC-SHA256 signature of the request body $calculated_signature = hash_hmac('sha256', $body, $secret_key, true); $calculated_signature_base64 = base64_encode($calculated_signature); // Compare the calculated signature with the one from the request if (! hash_equals($calculated_signature_base64, $signature)) { alatpay_log('error', 'Invalid signature. Contact ALATPay support.'); return new WP_Error( 'unauthorized', 'Invalid signature', array('status' => 401) ); } $params = json_decode($body, true); if (! is_array($params)) { alatpay_log('error', 'Invalid request. Contact ALATPay support.'); return new WP_Error('bad_request', 'Invalid request', array('status' => 400)); } $webhook_data = alatpay_extract_transaction_payload($params); $status = alatpay_get_payload_status($webhook_data); if ($status !== 'completed') { alatpay_log('error', 'Invalid status. Contact ALATPay support.'); return new WP_Error('bad_request', 'Invalid status', array('status' => 400)); } // error_log('ALATPay Webhook Request: ' . print_r($params, true)); alatpay_log('info', 'ALATPay Webhook Request: ' . print_r($params, true)); $payment_order_id = alatpay_extract_order_id_from_payload($webhook_data); if ($payment_order_id <= 0) { return new WP_Error('bad_request', 'Missing order_id', array('status' => 400)); } $order = wc_get_order($payment_order_id); if (! $order) { return new WP_Error('not_found', 'Order not found', array('status' => 404)); } $webhook_transaction_id = alatpay_get_transaction_id_from_payload($webhook_data); if ('' === $webhook_transaction_id) { return new WP_Error('bad_request', 'Missing transaction_id', array('status' => 400)); } if (alatpay_is_transaction_processed($order, $webhook_transaction_id)) { return new WP_REST_Response(array('status' => 'success', 'message' => 'Transaction already processed'), 200); } $stored_order_token = sanitize_text_field((string) $order->get_meta('_alatpay_order_token', true)); $payload_order_token = alatpay_extract_order_token_from_payload($webhook_data); if ($stored_order_token && $stored_order_token !== $payload_order_token) { return new WP_Error('invalid_reference', 'Order token mismatch', array('status' => 400)); } $requery_data = alatpay_requery_transaction($webhook_transaction_id, $gateway); if (is_wp_error($requery_data)) { alatpay_log('error', 'ALATPay requery failed in webhook: ' . $requery_data->get_error_message()); return $requery_data; } $requery_transaction_id = alatpay_get_transaction_id_from_payload($requery_data); if ($requery_transaction_id && $requery_transaction_id !== $webhook_transaction_id) { return new WP_Error('invalid_reference', 'Transaction mismatch', array('status' => 400)); } $requery_order_id = alatpay_extract_order_id_from_payload($requery_data); if ($requery_order_id && $requery_order_id !== $payment_order_id) { return new WP_Error('invalid_reference', 'Order ID mismatch', array('status' => 400)); } $requery_order_token = alatpay_extract_order_token_from_payload($requery_data); if ($stored_order_token && $requery_order_token && $stored_order_token !== $requery_order_token) { return new WP_Error('invalid_reference', 'Order token mismatch on verification', array('status' => 400)); } $verified_status = alatpay_get_payload_status($requery_data); if ('completed' !== $verified_status) { return new WP_Error('bad_request', 'Transaction is not completed', array('status' => 400)); } $order_currency = strtoupper((string) $order->get_currency()); $payment_currency = alatpay_get_payload_currency($requery_data); if ($order_currency !== $payment_currency) { return new WP_Error('currency_mismatch', 'Order currency mismatch', array('status' => 400)); } $amount = alatpay_get_payload_amount($requery_data); $order_total = floatval($order->get_total()); if ($amount + 0.00001 < $order_total) { $order->update_status('on-hold', __('ALATPay payment amount is lower than the order total. Manual review required.', 'alatpay')); $order->add_order_note( sprintf( __('ALATPay amount mismatch. Paid: %1$s %2$s, Order total: %3$s %4$s.', 'alatpay'), number_format($amount, 2, '.', ''), $payment_currency, number_format($order_total, 2, '.', ''), $order_currency ) ); $order->save(); return new WP_Error('amount_mismatch', 'Paid amount is lower than order total', array('status' => 400)); } $transaction_id = $webhook_transaction_id; $auto_complete_order = $gateway->get_option('auto_complete_order'); $target_status = ('yes' === $auto_complete_order) ? 'completed' : 'processing'; $needs_save = false; if (! empty($transaction_id)) { $order->set_transaction_id($transaction_id); $order->add_order_note(sprintf('ALATPay transaction ID: %s', $transaction_id)); $needs_save = true; } if (alatpay_add_success_note_if_needed($order)) { $needs_save = true; } alatpay_mark_transaction_processed($order, $transaction_id); $order->update_meta_data('_alatpay_last_verified_transaction_id', $transaction_id); $needs_save = true; if ($needs_save) { $order->save(); } alatpay_transaction_log( 'info', sprintf( 'Webhook marked order %d as %s. Amount: %s %s. Transaction ID: %s.', $payment_order_id, $target_status, number_format($amount, 2, '.', ''), $payment_currency, $transaction_id ? $transaction_id : 'N/A' ), array( 'order_id' => $payment_order_id, 'transaction_id' => $transaction_id, 'amount' => $amount, 'currency' => $payment_currency, 'event' => 'webhook_payment_confirmed', ) ); if (! $order->has_status($target_status)) { $order->update_status( $target_status, ('completed' === $target_status) ? __('Payment received via ALATPay webhook.', 'alatpay') : __('Payment received via ALATPay webhook. Awaiting fulfillment.', 'alatpay') ); } return new WP_REST_Response(array('status' => 'success'), 200); } function alatpay_payment_page($order_id) { $order = wc_get_order($order_id); if (! $order) { wp_safe_redirect(wc_get_checkout_url() . '?result=fail'); exit; } $gateway = new Alatpay_Payment_Gateway(); $api_key = $gateway->get_option('api_key'); $business_id = $gateway->get_option('business_id'); $auto_complete_order = $gateway->get_option('auto_complete_order'); // Get the currency set in WooCommerce settings $currency = get_woocommerce_currency(); if (function_exists('WC') && WC()->session) { WC()->session->__unset('order_awaiting_payment'); WC()->session->__unset('order_awaiting_payment_' . $gateway->id); } $order_token = wp_generate_password(24, false, false); $order->update_meta_data('_alatpay_order_token', $order_token); $order->save(); $gateway_data = array( 'apiKey' => sanitize_text_field($api_key), 'businessId' => sanitize_text_field($business_id), 'orderEmail' => sanitize_email($order->get_billing_email()), 'orderAmount' => floatval($order->get_total()), 'currency' => $currency, 'orderFirstName' => $order->get_billing_first_name(), 'orderLastName' => $order->get_billing_last_name(), 'orderId' => strval($order->get_id()), 'orderToken' => $order_token, 'updateOrderUrl' => esc_url(admin_url('admin-ajax.php')) . "?action=alatpay_update_order_status&order_id={$order_id}&status=" . ('yes' === $auto_complete_order ? 'completed' : 'processing') . "&nonce=" . wp_create_nonce('alatpay_update_order_status'), 'failUrl' => esc_url(wc_get_checkout_url() . '?result=fail'), 'closeOrderUrl' => esc_url(admin_url('admin-ajax.php')), 'closeOrderNonce' => wp_create_nonce('alatpay_popup_closed_' . $order_id), 'failOrderUrl' => esc_url(admin_url('admin-ajax.php')), 'failOrderNonce' => wp_create_nonce('alatpay_payment_failed_' . $order_id), 'clickOrderUrl' => esc_url(admin_url('admin-ajax.php')), 'clickOrderNonce' => wp_create_nonce('alatpay_payment_initiated_' . $order_id), ); wp_register_script( 'alatpay-gateway', esc_url(plugin_dir_url(__FILE__) . '/assets/js/alatpay.js'), array('jquery', 'alatpay-js'), '1.0.0', true ); wp_localize_script('alatpay-gateway', 'gatewayData', $gateway_data); wp_enqueue_script('alatpay-gateway'); ?> ' . esc_html__('Thank you for your order, please click the button below to pay via ALATPay.', 'alatpay') . '
'; ?>