diff --git a/modules/ppcp-wc-gateway/services.php b/modules/ppcp-wc-gateway/services.php index fd25e701ad..355ffec8af 100644 --- a/modules/ppcp-wc-gateway/services.php +++ b/modules/ppcp-wc-gateway/services.php @@ -80,6 +80,9 @@ use WooCommerce\PayPalCommerce\WcGateway\Processor\AuthorizedPaymentsProcessor; use WooCommerce\PayPalCommerce\WcGateway\Processor\OrderProcessor; use WooCommerce\PayPalCommerce\WcGateway\Processor\RefundProcessor; +use WooCommerce\PayPalCommerce\WcGateway\Service\FailedOrders\FailedOrdersRestEndpoint; +use WooCommerce\PayPalCommerce\WcGateway\Service\FailedOrders\FailedOrderTracker; +use WooCommerce\PayPalCommerce\WcGateway\Service\FailedOrders\WordPressOptionsFailedOrderPersistence; use WooCommerce\PayPalCommerce\WcGateway\Settings\HeaderRenderer; use WooCommerce\PayPalCommerce\WcGateway\Settings\SectionsRenderer; use WooCommerce\PayPalCommerce\WcGateway\Settings\Settings; @@ -2418,4 +2421,19 @@ static function ( ContainerInterface $container ): DisplayManager { getenv( 'PCP_APPSWITCH_ENABLED' ) === '1' ); }, + + 'wcgateway.service.failed-order-persistence' => static function (): WordPressOptionsFailedOrderPersistence { + return new WordPressOptionsFailedOrderPersistence(); + }, + + 'wcgateway.service.failed-order-tracker' => static function ( ContainerInterface $container ): FailedOrderTracker { + return new FailedOrderTracker( + $container->get( 'wcgateway.service.failed-order-persistence' ) + ); + }, + 'wcgateway.endpoint.failed-order-tracker' => static function ( ContainerInterface $container ): FailedOrdersRestEndpoint { + return new FailedOrdersRestEndpoint( + $container->get( 'wcgateway.service.failed-order-tracker' ), + ); + }, ); diff --git a/modules/ppcp-wc-gateway/src/Service/FailedOrders/FailedOrderPersistenceInterface.php b/modules/ppcp-wc-gateway/src/Service/FailedOrders/FailedOrderPersistenceInterface.php new file mode 100644 index 0000000000..f8e75faccf --- /dev/null +++ b/modules/ppcp-wc-gateway/src/Service/FailedOrders/FailedOrderPersistenceInterface.php @@ -0,0 +1,36 @@ +persistence = $persistence; + } + + /** + * Track failed card order transactions. + * + * @param WC_Order $wc_order The WooCommerce order. + * @param Order $paypal_order The PayPal order. + * + * @return void + */ + public function track_failed_card_order( WC_Order $wc_order, Order $paypal_order ): void { + if ( $this->is_failed_credit_card_transaction( $wc_order ) ) { + $this->record_failed_transaction( $wc_order, $paypal_order ); + } + } + + /** + * Get recent failed orders. + * + * @param int $limit Number of orders to retrieve. + * + * @return array + */ + public function get_recent_failed_orders( int $limit = 10 ): array { + $failed_orders = $this->persistence->get_failed_orders(); + + // Sort by timestamp descending (newest first). + usort( + $failed_orders, + function ( $a, $b ) { + return $b['timestamp'] - $a['timestamp']; + } + ); + + return array_slice( $failed_orders, 0, $limit ); + } + + /** + * Get failed orders count for time period. + * + * @param int $minutes Time period in minutes. + * + * @return int + */ + public function get_failed_orders_count( int $minutes = 60 ): int { + $failed_orders = $this->persistence->get_failed_orders(); + $cutoff_time = current_time( 'timestamp' ) - ( $minutes * 60 ); + + $recent_failures = array_filter( + $failed_orders, + function ( $order ) use ( $cutoff_time ) { + return $order['timestamp'] > $cutoff_time; + } + ); + + return count( $recent_failures ); + } + + /** + * Clear all stored failed orders (for testing). + * + * @return void + */ + public function clear_failed_orders(): void { + $this->persistence->clear_failed_orders(); + } + + /** + * Check if this is a failed credit card transaction. + * + * @param WC_Order $order The WooCommerce order. + * + * @return bool + */ + private function is_failed_credit_card_transaction( WC_Order $order ): bool { + return $order->has_status( 'failed' ) && + $order->get_payment_method() === CreditCardGateway::ID; + } + + /** + * Record failed transaction details. + * + * @param WC_Order $wc_order The WooCommerce order. + * @param Order $paypal_order The PayPal order. + * + * @return void + */ + private function record_failed_transaction( WC_Order $wc_order, Order $paypal_order ): void { + $fraud_data = $wc_order->get_meta( PayPalGateway::FRAUD_RESULT_META_KEY ); + + $failed_transaction_data = array( + 'timestamp' => current_time( 'timestamp' ), + 'order_id' => $wc_order->get_id(), + 'total' => $wc_order->get_total(), + 'currency' => $wc_order->get_currency(), + 'customer_ip' => $wc_order->get_customer_ip_address(), + 'billing_email' => $wc_order->get_billing_email(), + 'billing_country' => $wc_order->get_billing_country(), + 'fraud_data' => $fraud_data, + 'paypal_order_id' => $paypal_order->id(), + 'failure_reason' => $this->get_failure_reason( $wc_order ), + ); + + $this->persistence->store_failed_order( $failed_transaction_data ); + } + + /** + * Get failure reason from order. + * + * @param WC_Order $order The WooCommerce order. + * + * @return string + */ + private function get_failure_reason( WC_Order $order ): string { + $order_notes = wc_get_order_notes( + array( + 'order_id' => $order->get_id(), + 'limit' => 1, + ) + ); + + if ( ! empty( $order_notes ) ) { + return $order_notes[0]->content; + } + + return 'Payment failed'; + } +} diff --git a/modules/ppcp-wc-gateway/src/Service/FailedOrders/FailedOrdersRestEndpoint.php b/modules/ppcp-wc-gateway/src/Service/FailedOrders/FailedOrdersRestEndpoint.php new file mode 100644 index 0000000000..d7742b6f8c --- /dev/null +++ b/modules/ppcp-wc-gateway/src/Service/FailedOrders/FailedOrdersRestEndpoint.php @@ -0,0 +1,121 @@ +failed_order_tracker = $failed_order_tracker; + } + + /** + * Configure REST API routes. + */ + public function register_routes(): void { + /** + * GET /wp-json/wc/v3/wc_paypal/failed_orders + * Query parameters: + * - limit: Number of orders to retrieve (default: 10) + * - minutes: Time period in minutes for count (default: 60) + */ + register_rest_route( + static::NAMESPACE, + '/' . $this->rest_base, + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_failed_orders' ), + 'permission_callback' => current_user_can( 'manage_woocommerce' ), + 'args' => array( + 'limit' => array( + 'default' => 10, + 'type' => 'integer', + 'minimum' => 1, + 'maximum' => 100, + 'sanitize_callback' => 'absint', + ), + 'minutes' => array( + 'default' => 60, + 'type' => 'integer', + 'minimum' => 1, + 'sanitize_callback' => 'absint', + ), + ), + ) + ); + } + + /** + * Returns recent failed orders. + * + * @param WP_REST_Request $request The request object. + * + * @return WP_REST_Response The failed orders data or an error response. + */ + public function get_failed_orders( WP_REST_Request $request ): WP_REST_Response { + $limit = $request->get_param( 'limit' ); + + try { + $failed_orders = $this->failed_order_tracker->get_recent_failed_orders( $limit ); + + return rest_ensure_response( $failed_orders ); + } catch ( \Exception $e ) { + return rest_ensure_response( new \WP_Error( 'failed_orders_error', $e->getMessage() ) ); + } + } + + /** + * Returns the count of failed orders for a time period. + * + * @param WP_REST_Request $request The request object. + * + * @return WP_REST_Response The failed orders count or an error response. + */ + public function get_failed_orders_count( WP_REST_Request $request ): WP_REST_Response { + $minutes = $request->get_param( 'minutes' ); + + try { + $count = $this->failed_order_tracker->get_failed_orders_count( $minutes ); + + return rest_ensure_response( + array( + 'count' => $count, + 'time_period_minutes' => $minutes, + ) + ); + } catch ( \Exception $e ) { + return rest_ensure_response( new \WP_Error( 'failed_orders_count_error', $e->getMessage() ) ); + } + } +} diff --git a/modules/ppcp-wc-gateway/src/Service/FailedOrders/WordPressOptionsFailedOrderPersistence.php b/modules/ppcp-wc-gateway/src/Service/FailedOrders/WordPressOptionsFailedOrderPersistence.php new file mode 100644 index 0000000000..dba3705011 --- /dev/null +++ b/modules/ppcp-wc-gateway/src/Service/FailedOrders/WordPressOptionsFailedOrderPersistence.php @@ -0,0 +1,53 @@ + self::MAX_FAILED_ORDERS_TO_STORE ) { + $failed_orders = array_slice( $failed_orders, - self::MAX_FAILED_ORDERS_TO_STORE ); + } + + update_option( self::OPTION_KEY, $failed_orders ); + } + + /** + * Retrieve all stored failed orders. + * + * @return array Array of failed order transaction data. + */ + public function get_failed_orders(): array { + return get_option( self::OPTION_KEY, array() ); + } + + /** + * Clear all stored failed orders. + * + * @return void + */ + public function clear_failed_orders(): void { + delete_option( self::OPTION_KEY ); + } +} diff --git a/modules/ppcp-wc-gateway/src/WCGatewayModule.php b/modules/ppcp-wc-gateway/src/WCGatewayModule.php index 67f31ec484..635bde86a3 100644 --- a/modules/ppcp-wc-gateway/src/WCGatewayModule.php +++ b/modules/ppcp-wc-gateway/src/WCGatewayModule.php @@ -17,6 +17,7 @@ use WooCommerce\PayPalCommerce\AdminNotices\Repository\Repository; use WooCommerce\PayPalCommerce\ApiClient\Entity\Authorization; use WooCommerce\PayPalCommerce\ApiClient\Entity\Capture; +use WooCommerce\PayPalCommerce\ApiClient\Entity\Order; use WooCommerce\PayPalCommerce\ApiClient\Entity\OrderStatus; use WooCommerce\PayPalCommerce\ApiClient\Exception\RuntimeException; use WooCommerce\PayPalCommerce\ApiClient\Helper\ReferenceTransactionStatus; @@ -57,6 +58,8 @@ use WooCommerce\PayPalCommerce\WcGateway\Notice\UnsupportedCurrencyAdminNotice; use WooCommerce\PayPalCommerce\WcGateway\Processor\AuthorizedPaymentsProcessor; use WooCommerce\PayPalCommerce\WcGateway\Processor\CreditCardOrderInfoHandlingTrait; +use WooCommerce\PayPalCommerce\WcGateway\Service\FailedOrders\FailedOrdersRestEndpoint; +use WooCommerce\PayPalCommerce\WcGateway\Service\FailedOrders\FailedOrderTracker; use WooCommerce\PayPalCommerce\WcGateway\Settings\HeaderRenderer; use WooCommerce\PayPalCommerce\WcGateway\Settings\SectionsRenderer; use WooCommerce\PayPalCommerce\WcGateway\Settings\Settings; @@ -616,6 +619,32 @@ static function ( array $data, string $payment_method, array $request ) use ( $c 3 ); + // Track failed credit card orders for fraud analysis and monitoring. + add_action( + 'woocommerce_paypal_payments_fraud_result_added', + function ( WC_Order $wc_order, Order $order ) use ( $c ) { + $failed_order_tracker = $c->get( 'wcgateway.service.failed-order-tracker' ); + assert( $failed_order_tracker instanceof FailedOrderTracker ); + + $failed_order_tracker->track_failed_card_order( $wc_order, $order ); + }, + 10, + 2 + ); + + add_action( + 'rest_api_init', + function () use ( $c ) { + $tracker = $c->get( 'wcgateway.service.failed-order-tracker' ); + assert( $tracker instanceof FailedOrderTracker ); + + $endpoint = $c->get( 'wcgateway.endpoint.failed-order-tracker' ); + assert( $endpoint instanceof FailedOrdersRestEndpoint ); + + $endpoint->register_routes(); + } + ); + return true; }