settings = $settings;
$this->repository = $repository;
}
public function register() {
if ( $this->settings->get( 'paid_customer' ) || $this->settings->get( 'paid_admin' ) ) {
add_action( 'woocommerce_payment_complete', array( $this, 'payment_complete' ) ); }
if ( ! $this->settings->get( 'recovery' ) ) {
return; }
add_action( 'woocommerce_order_status_changed', array( $this, 'status_changed' ), 10, 3 );
add_action( 'woocommerce_checkout_order_created', array( $this, 'order_created' ) );
add_action( 'woocommerce_store_api_checkout_order_processed', array( $this, 'order_created' ) );
add_action( 'woocommerce_after_cart_table', array( $this, 'display_form' ) );
add_action( 'woocommerce_before_checkout_form', array( $this, 'display_form' ), 15 );
add_shortcode( 'anar_cart_reminders', array( $this, 'form' ) );
add_shortcode( 'anar_recover_cart', array( $this, 'recovery_form' ) );
add_action( 'woocommerce_before_cart', array( $this, 'display_recovery_form' ) );
add_action( 'woocommerce_cart_is_empty', array( $this, 'display_recovery_form' ) );
add_action( 'woocommerce_before_checkout_form', array( $this, 'display_recovery_form' ), 5 );
add_action( 'wc_ajax_anar_toolkit_consent', array( $this, 'capture' ) );
add_action( 'wc_ajax_anar_toolkit_recover', array( $this, 'recover' ) );
add_action( 'wc_ajax_anar_toolkit_recovery_code', array( $this, 'request_recovery_code' ) );
add_action( 'woocommerce_cart_updated', array( $this, 'cart_updated' ) );
add_action( 'woocommerce_cart_emptied', array( $this, 'cart_emptied' ) );
add_action( 'template_redirect', array( $this, 'prepare_recovery_page' ), 0 );
add_action( 'template_redirect', array( $this, 'link_page' ), 1 );
}
public function prepare_recovery_page() {
if ( ! $this->settings->get( 'recovery' ) || ! WC()->session ) {
return;
}
$page = get_queried_object();
$shortcode = $page instanceof \WP_Post && ( has_shortcode( $page->post_content, 'anar_recover_cart' ) || has_shortcode( $page->post_content, 'anar_cart_reminders' ) );
if ( $shortcode || is_cart() || is_checkout() || ( $this->settings->get( 'recovery_page_id' ) && is_page( (int) $this->settings->get( 'recovery_page_id' ) ) ) ) {
if ( ! defined( 'DONOTCACHEPAGE' ) ) {
// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound -- Standard WordPress/page-cache integration flag.
define( 'DONOTCACHEPAGE', true );
}
nocache_headers();
WC()->session->set_customer_session_cookie( true );
}
}
public static function consent_text() {
return __( 'Send me SMS reminders about this cart and its unpaid order. I can stop reminders using the link in each message. My phone number and reminder details will be sent to the store\'s SMS provider.', 'anar-toolkit' );
}
private function session_key() {
return WC()->session ? hash_hmac( 'sha256', (string) WC()->session->get_customer_id(), wp_salt( 'nonce' ) ) : '';
}
private function items() {
$items = array();
foreach ( WC()->cart->get_cart() as $item ) {
$items[] = array(
'product_id' => (int) $item['product_id'],
'variation_id' => (int) $item['variation_id'],
'variation' => (array) $item['variation'],
'quantity' => wc_stock_amount( $item['quantity'] ),
);
}
return $items;
}
public function display_form() {
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- form() escapes each dynamic value.
echo $this->form();
}
public function display_recovery_form() {
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- recovery_form() escapes each dynamic value.
echo $this->recovery_form();
}
public function recovery_form() {
if ( ! $this->settings->get( 'recovery' ) || ! WC()->session ) {
return '';
}
WC()->session->set_customer_session_cookie( true );
wp_enqueue_script( 'anar-toolkit-recovery', ANAR_LOGIN_URL . 'assets/js/recovery.js', array(), ANAR_LOGIN_VERSION, true );
ob_start();
?>
repository->contact( $cart->contact_id );
if ( ! $contact || $contact->suppressed || ! $this->valid_cart( $cart ) ) {
return new WP_Error( 'anar_recovery_unavailable' );
}
$codes = new OtpRepository();
$code = sprintf( '%06d', random_int( 0, 999999 ) );
if ( ! $codes->create( $contact->phone, 'recovery', $code, (int) $this->settings->get( 'code_minutes' ) * MINUTE_IN_SECONDS ) ) {
return new WP_Error( 'anar_recovery_storage' );
}
$challenge = $codes->latest( $contact->phone, 'recovery' );
if ( ! $challenge || ! $this->repository->bind_challenge( $contact->id, $cart->id, $challenge->id ) ) {
return new WP_Error( 'anar_recovery_storage' );
}
return $code;
}
public function recovery_page() {
$id = (int) $this->settings->get( 'recovery_page_id' );
return $id && 'publish' === get_post_status( $id ) ? get_permalink( $id ) : wc_get_cart_url();
}
/** Atomically consume a phone-bound recovery challenge with a five-guess budget. */
public function verify_recovery( $phone, $code ) {
$error = new WP_Error( 'anar_recovery_invalid', __( 'The code is invalid, expired or unavailable. Check the number and latest code, or request a new code.', 'anar-toolkit' ) );
$phone = Identity::normalize( $phone );
$code = trim( Identity::latin_digits( $code ) );
if ( ! $this->settings->get( 'recovery' ) || is_wp_error( $phone ) || 'sms' !== $phone['channel'] ) {
return $error;
}
$limit = ( new RateLimiter( new LoginSettings() ) )->guard_recovery( $phone['identity'] );
if ( is_wp_error( $limit ) ) {
return $error;
}
$contact = $this->repository->contact_by_phone( $phone['identity'] );
$codes = new OtpRepository();
$challenge = $codes->latest( $phone['identity'], 'recovery' );
if ( ! $contact || $contact->suppressed || ! $challenge || (int) $contact->challenge_id !== (int) $challenge->id || strtotime( $challenge->expires_at . ' UTC' ) <= time() || ! $codes->claim_attempt( $challenge->id, 5 ) ) {
return $error;
}
$cart = $this->repository->cart( $contact->recovery_cart_id );
if ( ! preg_match( '/^\d{6}$/D', $code ) || ! $codes->matches( $challenge, $phone['identity'], $code ) || ! $this->valid_cart( $cart ) || ! $codes->consume( $challenge->id ) ) {
return $error;
}
return $cart;
}
public function request_recovery_code() {
if ( ! WC()->session ) {
wp_send_json_error( array(), 400 );
}
check_ajax_referer( 'anar_cart_' . $this->session_key(), 'nonce' );
$raw = isset( $_POST['phone'] ) && is_string( $_POST['phone'] ) ? sanitize_text_field( wp_unslash( $_POST['phone'] ) ) : '';
$phone = Identity::normalize( $raw );
if ( $this->settings->get( 'recovery' ) && ! is_wp_error( $phone ) && 'sms' === $phone['channel'] ) {
$allowed = ( new RateLimiter( new LoginSettings() ) )->guard_request( 'purchase-recovery:' . $phone['identity'] );
$contact = $this->repository->contact_by_phone( $phone['identity'] );
$cart = $contact ? $this->repository->latest_cart_for_contact( $contact->id ) : null;
if ( ! is_wp_error( $allowed ) && $contact && ! $contact->suppressed && $this->valid_cart( $cart ) ) {
$this->repository->enqueue( 'recovery_request', $cart->id, $phone['identity'], time(), (string) floor( time() / 300 ) );
}
}
// Identical response for unknown, opted-out, exhausted and eligible numbers.
wp_send_json_success();
}
public function recover() {
if ( ! WC()->session || ! WC()->cart ) {
wp_send_json_error( array(), 400 );
}
check_ajax_referer( 'anar_cart_' . $this->session_key(), 'nonce' );
$phone = isset( $_POST['phone'] ) && is_string( $_POST['phone'] ) ? sanitize_text_field( wp_unslash( $_POST['phone'] ) ) : '';
$code = isset( $_POST['code'] ) && is_string( $_POST['code'] ) ? sanitize_text_field( wp_unslash( $_POST['code'] ) ) : '';
$cart = $this->verify_recovery( $phone, $code );
if ( is_wp_error( $cart ) ) {
wp_send_json_error( array(), 400 );
}
global $wpdb;
$visit = array( 'clicked_at' => time() );
if ( $cart->order_id ) {
$visit['recovered_order'] = $cart->order_id;
}
$wpdb->update( $this->repository->table( 'carts' ), $visit, array( 'id' => $cart->id ) );
if ( $cart->order_id ) {
( new Coupons( $this->settings, $this->repository ) )->apply( $cart, wc_get_order( $cart->order_id ) ); }
$url = $cart->order_id ? wc_get_order( $cart->order_id )->get_checkout_payment_url() : $this->restore( $cart );
if ( is_wp_error( $url ) ) {
wp_send_json_error( array(), 409 );
}
wp_send_json_success( array( 'redirect' => wp_validate_redirect( $url, wc_get_cart_url() ) ) );
}
/** Use the shortcode beside Cart/Checkout blocks; never inject into their React tree. */
public function form() {
if ( 'automatic' === $this->settings->get( 'capture_mode' ) ) {
return ''; }
if ( ! $this->settings->get( 'recovery' ) || ! WC()->cart || WC()->cart->is_empty() || ! WC()->session ) {
return '';
}
WC()->session->set_customer_session_cookie( true );
wp_enqueue_script( 'anar-toolkit-recovery', ANAR_LOGIN_URL . 'assets/js/recovery.js', array(), ANAR_LOGIN_VERSION, true );
$phone = WC()->customer ? WC()->customer->get_billing_phone() : '';
$email = is_user_logged_in() ? wp_get_current_user()->user_email : '';
$cart = $this->repository->current_cart( $this->session_key() );
$contact = $cart ? $this->repository->contact( $cart->contact_id ) : null;
ob_start();
?>
settings->get( 'recovery' ) || ! WC()->session || ! WC()->cart ) {
wp_send_json_error( array(), 400 );
}
check_ajax_referer( 'anar_cart_' . $this->session_key(), 'nonce' );
$cart = $this->repository->current_cart( $this->session_key() );
if ( empty( $_POST['consent'] ) ) {
if ( $cart ) {
$this->repository->suppress( $cart->contact_id );
}
wp_send_json_success();
}
$phone = isset( $_POST['phone'] ) && is_string( $_POST['phone'] ) ? sanitize_text_field( wp_unslash( $_POST['phone'] ) ) : '';
$email = isset( $_POST['email'] ) && is_string( $_POST['email'] ) ? sanitize_email( wp_unslash( $_POST['email'] ) ) : '';
$phone = Identity::normalize( $phone );
if ( is_wp_error( $phone ) || 'sms' !== $phone['channel'] || WC()->cart->is_empty() ) {
wp_send_json_error( array(), 400 );
}
// Bound consent changes per real WooCommerce session; no public arbitrary-number send API.
$changes = (array) WC()->session->get( 'anar_consent_changes', array( 'time' => time(), 'count' => 0 ) );
if ( $changes['time'] < time() - HOUR_IN_SECONDS ) {
$changes = array( 'time' => time(), 'count' => 0 );
}
if ( $changes['count'] >= 5 ) {
wp_send_json_error( array(), 429 );
}
++$changes['count'];
WC()->session->set( 'anar_consent_changes', $changes );
$contact_id = $this->repository->consent( $phone['identity'], $email, self::consent_text() );
$id = $contact_id ? $this->repository->save_cart( $this->session_key(), $contact_id, $this->items(), WC()->cart->get_total( 'edit' ), get_woocommerce_currency() ) : 0;
if ( ! $id ) {
wp_send_json_error( array(), 500 );
}
wp_send_json_success();
}
public function remember( $raw_phone, $raw_email = '', $source = 'checkout' ) {
if ( $this->restoring || ! $this->settings->get( 'recovery' ) || 'automatic' !== $this->settings->get( 'capture_mode' ) || ! WC()->session || ! WC()->cart || WC()->cart->is_empty() ) {
return 0; }
$phone = Identity::normalize( $raw_phone );
if ( is_wp_error( $phone ) || 'sms' !== $phone['channel'] ) {
return 0; }
$current = $this->repository->current_cart( $this->session_key() );
$contact = $current ? $this->repository->contact( $current->contact_id ) : null;
if ( ! $contact || $contact->phone !== $phone['identity'] ) {
$limit = ( new RateLimiter( new LoginSettings() ) )->guard_request( 'capture|' . $this->session_key() );
if ( is_wp_error( $limit ) ) {
return 0; }
}
$id = $this->repository->capture_contact( $phone['identity'], sanitize_email( $raw_email ), 'account' === $source ? 'account' : 'checkout' );
return $id ? $this->repository->save_cart( $this->session_key(), $id, $this->items(), WC()->cart->get_total( 'edit' ), get_woocommerce_currency() ) : 0;
}
public function cart_updated() {
if ( $this->restoring || ! $this->settings->get( 'recovery' ) || ! WC()->session || ! WC()->cart || WC()->cart->is_empty() ) {
return;
}
$cart = $this->repository->current_cart( $this->session_key() );
if ( ! $cart || $cart->order_id ) {
return;
}
$contact = $this->repository->contact( $cart->contact_id );
if ( $contact && ! $contact->suppressed ) {
$this->repository->save_cart( $this->session_key(), $contact->id, $this->items(), WC()->cart->get_total( 'edit' ), get_woocommerce_currency() );
}
}
public function cart_emptied() {
if ( $this->restoring || ! WC()->session ) {
return;
}
$cart = $this->repository->current_cart( $this->session_key() );
// WooCommerce empties the cart after creating an order: keep unpaid-order reminders.
if ( $cart && ! $cart->order_id ) {
$this->repository->set_cart_status( $cart->id, 'cancelled' );
}
}
public function order_created( $order ) {
if ( ! $this->settings->get( 'recovery' ) || ! WC()->session ) {
return;
}
if ( 'automatic' === $this->settings->get( 'capture_mode' ) ) {
$this->remember( $order->get_billing_phone(), $order->get_billing_email(), 'checkout' ); }
$cart = $this->repository->current_cart( $this->session_key() );
if ( ! $cart ) {
return;
}
$contact = $this->repository->contact( $cart->contact_id );
$phone = Identity::normalize( $order->get_billing_phone() );
// Consent for one recipient cannot be transferred to a different billing number.
if ( ! $contact || $contact->suppressed || is_wp_error( $phone ) || $phone['identity'] !== $contact->phone ) {
$this->repository->set_cart_status( $cart->id, 'suppressed' );
return;
}
$restored = (int) WC()->session->get( 'anar_recovered_cart' ) === (int) $cart->id;
$this->repository->attach_order( $cart->id, $order->get_id(), $restored );
$order->update_meta_data( '_anar_toolkit_cart_id', $cart->id );
$order->save_meta_data();
WC()->session->__unset( 'anar_recovered_cart' );
}
public function payment_complete( $order_id ) {
$order = wc_get_order( $order_id );
if ( ! $order || ! $order->is_paid() || ! $order->get_date_paid() || (float) $order->get_total() <= 0 ) {
return;
}
$this->repository->paid( $order_id );
if ( $order->get_meta( '_anar_toolkit_paid_queued' ) ) {
return;
}
$queued = false;
$ok = true;
if ( $this->settings->get( 'paid_customer' ) ) {
$phone = Identity::normalize( $order->get_billing_phone() );
if ( ! is_wp_error( $phone ) && 'sms' === $phone['channel'] ) {
$ok = $this->repository->enqueue( 'paid_customer', $order_id, $phone['identity'], time() ) && $ok;
$queued = true;
}
}
if ( $this->settings->get( 'paid_admin' ) ) {
foreach ( array_filter( explode( "\n", (string) $this->settings->get( 'admin_phones' ) ) ) as $phone ) {
$ok = $this->repository->enqueue( 'paid_admin', $order_id, $phone, time() ) && $ok;
$queued = true;
}
}
if ( $ok && $queued ) {
$order->update_meta_data( '_anar_toolkit_paid_queued', 1 );
$order->save_meta_data();
}
}
public function status_changed( $order_id, $from, $to ) {
$order = wc_get_order( $order_id );
if ( $order && ( $order->is_paid() || in_array( $to, array( 'cancelled', 'refunded', 'trash' ), true ) ) ) {
$this->repository->paid( $order_id, $order->is_paid() ? 'converted' : 'cancelled' );
}
}
public static function signature( $cart, $action ) {
// 160-bit MAC keeps provider tokens short without introducing an external URL shortener.
return substr( hash_hmac( 'sha256', $action . '|' . $cart->id . '|' . $cart->seed, wp_salt( 'auth' ) ), 0, 40 );
}
public static function link( $cart, $action ) {
if ( 'unsubscribe' !== $action ) {
return '';
}
$code = base_convert( (string) $cart->id, 10, 36 ) . '.' . ( 'recover' === $action ? 'r' : 'u' ) . '.' . self::signature( $cart, $action );
return add_query_arg( 'ar', $code, home_url( '/' ) );
}
public function valid_cart( $cart ) {
if ( ! $cart || 'active' !== $cart->status || $cart->updated_at + $this->settings->get( 'link_days' ) * DAY_IN_SECONDS < time() ) {
return false;
}
$contact = $this->repository->contact( $cart->contact_id );
if ( ! $contact || $contact->suppressed ) {
return false;
}
if ( $cart->order_id ) {
$order = wc_get_order( $cart->order_id );
return $order && $order->needs_payment() && in_array( $order->get_status(), array( 'pending', 'failed' ), true );
}
return true;
}
/** GET displays confirmation only: link previews must not mutate carts or opt shoppers out. */
public function link_page() {
// phpcs:disable WordPress.Security.NonceVerification.Recommended -- Signed bearer links are validated below; mutations additionally require a POST nonce.
$code = isset( $_GET['ar'] ) && is_string( $_GET['ar'] ) ? sanitize_text_field( wp_unslash( $_GET['ar'] ) ) : '';
$parts = explode( '.', $code );
$valid = 3 === count( $parts ) && preg_match( '/^[0-9a-z]{1,13}$/', $parts[0] ) && 'u' === $parts[1] && preg_match( '/^[0-9a-f]{40}$/', $parts[2] );
$id = $valid ? (int) base_convert( $parts[0], 36, 10 ) : 0;
$action = 'unsubscribe';
$token = $valid ? $parts[2] : '';
// phpcs:enable WordPress.Security.NonceVerification.Recommended
if ( '' === $code ) {
return;
}
nocache_headers();
header( 'Referrer-Policy: no-referrer' );
header( 'X-Robots-Tag: noindex, nofollow' );
$cart = $this->repository->cart( $id );
if ( ! $cart || ! in_array( $action, array( 'recover', 'unsubscribe' ), true ) || ! hash_equals( self::signature( $cart, $action ), $token ) ) {
wp_die( esc_html__( 'This reminder link is invalid or has expired.', 'anar-toolkit' ), '', array( 'response' => 404 ) );
}
$nonce_action = 'anar_link_' . $id . '_' . $action . '_' . $token;
if ( 'POST' === sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ?? '' ) ) ) {
check_admin_referer( $nonce_action );
if ( 'unsubscribe' === $action ) {
$this->repository->suppress( $cart->contact_id );
wp_die( esc_html__( 'Purchase reminders have been stopped for this mobile number.', 'anar-toolkit' ), esc_html__( 'Reminders stopped', 'anar-toolkit' ), array( 'response' => 200 ) );
}
}
$title = __( 'Stop purchase reminders', 'anar-toolkit' );
$text = __( 'Stop all cart and unpaid-order reminders for this mobile number?', 'anar-toolkit' );
$html = '' . esc_html( $text ) . '
';
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- All text is escaped above; wp_nonce_field produces trusted markup.
wp_die( $html, esc_html( $title ), array( 'response' => 200 ) );
}
private function restore( $cart ) {
$items = json_decode( $cart->items, true );
if ( ! is_array( $items ) || empty( $items ) ) {
return new WP_Error( 'anar_cart_unavailable', __( 'The saved cart is empty.', 'anar-toolkit' ) );
}
foreach ( $items as $item ) {
$product = wc_get_product( $item['variation_id'] ?: $item['product_id'] );
if ( ! $product || ! $product->is_purchasable() || ! $product->is_in_stock() || ! $product->has_enough_stock( $item['quantity'] ) ) {
return new WP_Error( 'anar_cart_unavailable', __( 'A saved product is no longer available. Your current cart has been kept.', 'anar-toolkit' ) );
}
}
$this->restoring = true;
$previous = WC()->cart->get_cart();
$previous_coupons = WC()->cart->get_applied_coupons();
WC()->cart->empty_cart( false );
$ok = true;
foreach ( $items as $item ) {
if ( ! WC()->cart->add_to_cart( $item['product_id'], $item['quantity'], $item['variation_id'], $item['variation'] ) ) {
$ok = false;
break;
}
}
if ( ! $ok ) {
WC()->cart->empty_cart( false );
WC()->cart->set_cart_contents( $previous );
WC()->cart->set_applied_coupons( $previous_coupons );
WC()->cart->calculate_totals();
WC()->cart->set_session();
$this->restoring = false;
return new WP_Error( 'anar_cart_unavailable', __( 'The saved cart could not be restored. Your previous cart has been kept.', 'anar-toolkit' ) );
}
( new Coupons( $this->settings, $this->repository ) )->apply( $cart );
WC()->cart->calculate_totals();
WC()->session->set( 'anar_recovered_cart', (int) $cart->id );
global $wpdb;
$wpdb->update( $this->repository->table( 'carts' ), array( 'session_key' => $this->session_key(), 'updated_at' => time() ), array( 'id' => $cart->id ) );
WC()->cart->set_session();
$this->restoring = false;
return wc_get_checkout_url();
}
public function variables( $order ) {
return array(
'site' => get_bloginfo( 'name' ),
'name' => $order ? $order->get_billing_first_name() : '',
'order_id' => $order ? $order->get_order_number() : '',
'total' => $order ? wc_format_decimal( $order->get_total(), wc_get_price_decimals() ) : '',
'currency' => $order ? $order->get_currency() : get_woocommerce_currency(),
'status' => $order ? wc_get_order_status_name( $order->get_status() ) : '',
'payment_method' => $order ? $order->get_payment_method_title() : '',
);
}
}