repository = $repository ?? new AbandonedCartRepository(); $this->dispatcher = $dispatcher ?? new AsyncEventDispatcher(); } /** * Upsert cart snapshot for the current session. * * @param string $session_id WooCommerce customer session id. * @param string $email Customer email (checkout or capture). * @param string|null $phone Raw billing phone or null. * @param int|null $user_id WordPress user id or null for guest. * @return int|false Row id or false. */ public function captureCart( string $session_id, string $email = '', ?string $phone = null, ?int $user_id = null ) { $session_id = trim( $session_id ); if ( '' === $session_id ) { return false; } if (is_numeric($session_id) && $user_id === null) { $user_id = $session_id; } $phone_normalized = null; if ( null !== $phone && '' !== trim( (string) $phone ) ) { $n = $this->normalizePhone( (string) $phone ); $phone_normalized = '' !== $n ? $n : null; } $snapshot = CartSnapshot::fromCart( $phone_normalized ?? '' ); $cart_json = wp_json_encode( $snapshot->toArray() ); $token = $this->getCartToken(); if ('' === $token) { return false; } return $this->repository->upsert( $token, $session_id, $user_id, $phone_normalized, sanitize_email( $email ), (string) $cart_json ); } /** * Mark matching abandoned carts recovered when an order is placed. * * @param \WC_Order $order Order. * @return void */ public function markRecovered( \WC_Order $order ): void { $token = $this->getCookie( 'afromewo_cart_token' ); $this->destroyCookie( 'afromewo_cart_token' ); $phone = $this->normalizePhone( $order->get_billing_phone() ); $user_id = $order->get_customer_id(); $session_id = ( WC()->session ) ? ( WC()->session->get_customer_id() ?: $user_id ) : $user_id; $email = sanitize_email( $order->get_billing_email() ); Log::info( 'Marking cart recovered for order', array( 'order_id' => $order->get_id(), 'user_id' => $user_id, 'session_id' => $session_id, 'token' => $token, ) ); $this->repository->markRecoveredForOrder( $user_id, $phone, (string) $email, $session_id, $token ); /** * Fires after an order is attributed to captured carts. * * @param \WC_Order $order Order that completed checkout. * @param array $match Matching identifiers used for attribution. */ do_action( 'afromewo_cart_marked_recovered', $order, array( 'user_id' => $user_id, 'phone' => $phone, 'email' => $email, 'session_id' => $session_id, 'token' => $token, ) ); } /** * Minutes of inactivity before a pending cart is treated as abandoned. * * @return int */ public function getAbandonmentMinutes(): int { $minutes = (int) apply_filters( 'afromewo_abandonment_minutes', self::ABANDONMENT_MINUTES ); return max( 1, $minutes ); } /** * Process abandoned carts - find pending, emit events, remove rows (prevents duplicate abandon events). * * @return int Number of carts processed. */ public function processAbandonedCarts(): int { $carts = $this->repository->findPendingOlderThan( $this->getAbandonmentMinutes(), 20 ); $count = 0; foreach ( $carts as $cart ) { $this->repository->updateStatus( (int) $cart->id, self::STATUS_PROCESSING ); $cart_data = json_decode( $cart->cart_data, true ); $cart_data = is_array( $cart_data ) ? $cart_data : array(); $phone = ''; if ( isset( $cart->phone ) && null !== $cart->phone && '' !== (string) $cart->phone ) { $phone = (string) $cart->phone; } elseif ( isset( $cart_data['phone'] ) ) { $phone = (string) $cart_data['phone']; } $cart_data['phone'] = $phone; $cart_data['email'] = isset($cart->email) ? sanitize_email($cart->email) : ''; // @TODO: If phone and email are empty, check if you can get using user_id // @TODO: If you can get using user_id, update the cart_data with the phone and email // @TODO: If you can't get using user_id, delete the item (row) from abandoned carts table and continue to next cart if (empty($cart_data['email']) && empty($cart_data['phone'])) { // Delete item (row) from abandoned carts table and continue to next cart $this->repository->delete((int) $cart->id); continue; } try { $event = new CartAbandonedEvent( (int) $cart->id, $cart_data ); $this->dispatcher->dispatch( $event ); $this->repository->updateStatus( (int) $cart->id, self::STATUS_PROCESSED ); } catch (\Exception $e) { $this->repository->updateStatus( (int) $cart->id, self::STATUS_FAILED ); Log::error('Failed to process abandoned cart (general error)', [ 'error' => $e->getMessage(), ]); } ++$count; } return $count; } /** * Normalize phone number. * * @param string $phone Raw phone. * @return string */ private function normalizePhone( string $phone ): string { $phone = trim( $phone ); if ( strpos( $phone, '+' ) === 0 ) { // Plus is first character, keep it and strip other non-digits return '+' . preg_replace( '/\D/', '', substr( $phone, 1 ) ); } return preg_replace( '/[^0-9+]/', '', trim( $phone ) ); } private function getCartToken(): string { $cookie_name = 'afromewo_cart_token'; if (!$this->hasConsent()) { Log::warning( "Your Site doesn't have consent to recover captured carts. Check this plugin settings.The filter afromewo_customer_has_cookie_consented is returning false." ); if ( '' !== $this->getCookie( $cookie_name ) ) { $this->destroyCookie($cookie_name); } return ''; // empty string to indicate no consent } $stored_token = $this->getCookie( $cookie_name ); if ( '' !== $stored_token ) { return $stored_token; } $token = wp_generate_uuid4(); $this->setCartTokenCookie( $token ); return $token; } /** * Persist the cart token on this device so later checkout can be attributed. * * @param string $token Cart token. * @return void */ private function setCartTokenCookie( string $token ): void { $cookie_name = 'afromewo_cart_token'; setcookie( $cookie_name, $token, array( 'expires' => time() + ( 86400 * 30 ), 'path' => COOKIEPATH ? COOKIEPATH : '/', 'domain' => COOKIE_DOMAIN, 'secure' => is_ssl(), 'httponly' => false, ) ); $_COOKIE[ $cookie_name ] = $token; } private function destroyCookie( string $cookie_name ): void { setcookie( $cookie_name, '', [ 'expires' => time() - 3600, 'path' => COOKIEPATH ? COOKIEPATH : '/', 'domain' => COOKIE_DOMAIN, 'secure' => is_ssl(), 'httponly' => false ]); if (isset($_COOKIE[$cookie_name])) { unset($_COOKIE[$cookie_name]); } } public function hasConsent(): bool { if (PluginSettings::get_instance()->check_tracking_consent()) { return apply_filters('afromewo_customer_has_cookie_consented', false); } return true; } private function getCookie( string $cookie_name ): string { return isset( $_COOKIE[ $cookie_name ] ) ? sanitize_text_field( wp_unslash( $_COOKIE[ $cookie_name ] ) ) : ''; } public function recoverCart( string $token ) { $token = sanitize_text_field( $token ); if ( ! $this->isValidCartToken( $token ) ) { return false; } $cart = $this->repository->getByToken( $token ); if ( empty( $cart ) ) { return false; } if ( ! function_exists( 'WC' ) || ! WC() ) { return false; } if ( ! WC()->cart && function_exists( 'wc_load_cart' ) ) { wc_load_cart(); } if ( ! WC()->cart ) { return false; } $cart_data_array = json_decode( $cart->cart_data, true ); $cart_items = isset( $cart_data_array['items'] ) && is_array( $cart_data_array['items'] ) ? $cart_data_array['items'] : array(); WC()->cart->empty_cart( false ); foreach ( $cart_items as $item ) { if ( ! is_array( $item ) ) { continue; } $product_id = isset( $item['product_id'] ) ? $item['product_id'] : ( $item[0] ?? null ); $qty = isset( $item['quantity'] ) ? $item['quantity'] : ( $item[1] ?? 1 ); $variation_id = isset( $item['variation_id'] ) ? $item['variation_id'] : ( $item[2] ?? 0 ); $variation = isset( $item['variation'] ) ? $item['variation'] : array(); if ( $product_id ) { WC()->cart->add_to_cart( $product_id, $qty, $variation_id, $variation ); } } WC()->cart->set_session(); $this->setCartTokenCookie( $token ); $session_id = WC()->session ? (string) WC()->session->get_customer_id() : ''; if ( '' !== $session_id ) { $this->repository->bindTokenToSession( $token, $session_id ); } return true; } /** * Whether the token looks like a stored cart UUID. * * @param string $token Token from the recovery URL. * @return bool */ private function isValidCartToken( string $token ): bool { return (bool) preg_match( '/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $token ); } }