'BZ-2026-000088', * 'value' => 66.00, * 'currency' => 'EUR', * 'items' => array( 'DOPAMIN', 'Grußkarte' ), * 'name' => 'Jakob Forini', * 'email' => 'jakob@example.org', * 'phone' => '0151 1234567', * ) ); * * The order of the browser and the one of the server are one lead: the browser brings where the * buyer came from, the server who it is and the exact amount, also when the payment took the * buyer away to PayPal and the purchase event of the browser never came. */ class Orders { /** * Days an order number is remembered with its lead. */ const KEEP_DAYS = 90; /** * Hooks. */ public function register() { add_action( 'dealroot_order_paid', array( __CLASS__, 'paid' ) ); } /** * Highest amount accepted from the browser. */ const MAX_VALUE = 100000; /** * How long a form submission can be matched with the order that follows it. */ const MATCH_WINDOW = DAY_IN_SECONDS; /** * Remembers the lead of a form submission for the order that may follow. * * @param string $key Random key the tracker created for the submission. * @param int $lead_id Lead ID. */ public static function remember( $key, $lead_id ) { if ( $lead_id && self::valid_key( $key ) ) { set_transient( 'dealroot_lk_' . md5( $key ), (int) $lead_id, self::MATCH_WINDOW ); } } /** * Whether the tracker should pass purchase events on: WooCommerce orders are saved on the * server by their own integration. * * @return bool */ public static function enabled() { return (bool) Settings::get( 'purchase_orders' ) && ! ( class_exists( '\\WooCommerce' ) && Settings::get( 'woocommerce_orders' ) ); } /** * Saves an order. * * @param string $number Order number. * @param float $value Amount. * @param string $currency Currency code. * @param array $items Names of the ordered products. * @param string $key Key of the form submission before, if any. * @param array $buyer name, email and phone, when known. * @return int Lead ID, 0 when the order is not saved. */ public static function save( $number, $value, $currency, array $items, $key = '', array $buyer = array() ) { $number = (string) $number; $value = round( (float) $value, 2 ); if ( ! preg_match( '/^[\w.\-#\/:]{1,64}$/', $number ) || $value <= 0 || $value > self::MAX_VALUE ) { return 0; } $seen = 'dealroot_ord_' . md5( $number ); // The server told about the order first: the browser brings where the buyer came from. $known = self::lead_of( $number ); if ( $known && get_transient( 'dealroot_ordsrv_' . md5( $number ) ) ) { delete_transient( 'dealroot_ordsrv_' . md5( $number ) ); self::attribute( $known ); return $known; } if ( get_transient( $seen ) || self::find( $number ) ) { return 0; } set_transient( $seen, 1, 90 * DAY_IN_SECONDS ); $currency = Settings::sanitize_currency( $currency ); $items = array_slice( array_filter( array_map( 'sanitize_text_field', array_map( 'strval', $items ) ), 'strlen' ), 0, 20 ); /* translators: %s: order number */ $note = sprintf( __( 'Order %s, reported by the purchase event of the shop.', 'dealroot' ), $number ); $lead_id = self::valid_key( $key ) ? (int) get_transient( 'dealroot_lk_' . md5( $key ) ) : 0; $lead = $lead_id ? Leads::get( $lead_id ) : null; // Who bought: told with the order, or the checkout left for the payment. $buyer = array_filter( array_map( 'strval', $buyer ), 'strlen' ); if ( ! $buyer && self::valid_key( $key ) ) { $buyer = array_filter( (array) get_transient( 'dealroot_ct_' . md5( $key ) ), 'strlen' ); } if ( self::valid_key( $key ) ) { delete_transient( 'dealroot_ct_' . md5( $key ) ); } // The open enquiry of the buyer, e.g. their checkout form, is the order. if ( ! $lead && $buyer ) { $lead = self::open_of( (string) ( $buyer['email'] ?? '' ), (string) ( $buyer['phone'] ?? '' ) ); $lead_id = $lead ? (int) $lead->id : 0; } if ( $lead && in_array( $lead->status, array( 'new', 'contacted', 'qualified' ), true ) ) { delete_transient( 'dealroot_lk_' . md5( $key ) ); $changes = array( 'status' => 'won', 'value' => $value, 'currency' => $currency, 'note' => trim( $lead->note . "\n" . $note ), ); // What the enquiry left open, e.g. the name, from the checkout. foreach ( array( 'name', 'email', 'phone' ) as $column ) { if ( '' === (string) $lead->$column && '' !== (string) ( $buyer[ $column ] ?? '' ) ) { $changes[ $column ] = (string) $buyer[ $column ]; } } Leads::update( $lead_id, $changes ); self::add_order( $lead_id, $number, $items ); self::merge_attempts( $lead_id ); self::remember_lead( $number, $lead_id ); return $lead_id; } $fields = array( array( 'label' => __( 'Order number', 'dealroot' ), 'name' => 'order', 'type' => 'text', 'value' => $number, ), ); $fields = array_merge( $fields, self::buyer_fields( $buyer ) ); if ( $items ) { $fields[] = array( 'label' => __( 'Products', 'dealroot' ), 'name' => 'items', 'type' => 'textarea', 'value' => implode( "\n", $items ), ); } $lead_id = Capture::submission( 'order', $number, __( 'Order', 'dealroot' ), $fields, array( 'status' => 'won', 'value' => $value, 'currency' => $currency, 'won_at' => current_time( 'mysql', true ), 'note' => $note, ) ); self::remember_lead( $number, $lead_id ); return $lead_id; } /** * A paid order told by the shop from the server, with the buyer: fills in the lead of the * order, or wins the open enquiry of the same person, or saves the order. * * @param array $order number, value, currency, items, name, email, phone. * @return int Lead ID, 0 when the order is not saved. */ public static function paid( $order ) { $order = is_array( $order ) ? $order : array(); $number = (string) ( $order['number'] ?? '' ); $value = round( (float) ( $order['value'] ?? 0 ), 2 ); $currency = Settings::sanitize_currency( (string) ( $order['currency'] ?? '' ) ); $items = array_slice( array_filter( array_map( 'sanitize_text_field', array_map( 'strval', (array) ( $order['items'] ?? array() ) ) ), 'strlen' ), 0, 20 ); $name = mb_substr( sanitize_text_field( (string) ( $order['name'] ?? '' ) ), 0, 191 ); $email = sanitize_email( (string) ( $order['email'] ?? '' ) ); $phone = mb_substr( sanitize_text_field( (string) ( $order['phone'] ?? '' ) ), 0, 64 ); if ( ! preg_match( '/^[\w.\-#\/:]{1,64}$/', $number ) || $value <= 0 ) { return 0; } /* translators: %s: order number */ $note = sprintf( __( 'Order %s, paid, reported by the shop.', 'dealroot' ), $number ); $lead_id = self::lead_of( $number ); // The open enquiry of the same person, e.g. the checkout form, is the order. if ( ! $lead_id ) { $open = self::open_of( $email, $phone ); if ( $open ) { Leads::update( (int) $open->id, array( 'status' => 'won', 'value' => $value, 'currency' => $currency, 'note' => trim( $open->note . "\n" . $note ), ) ); self::add_order( (int) $open->id, $number, $items ); self::remember_lead( $number, (int) $open->id ); set_transient( 'dealroot_ord_' . md5( $number ), 1, self::KEEP_DAYS * DAY_IN_SECONDS ); self::merge_attempts( (int) $open->id ); return (int) $open->id; } } if ( $lead_id ) { $lead = Leads::get( $lead_id ); $changes = array( 'value' => $value, 'currency' => $currency, ); foreach ( array( 'name' => $name, 'email' => $email, 'phone' => $phone, ) as $key => $given ) { if ( $lead && '' === (string) $lead->$key && '' !== $given ) { $changes[ $key ] = $given; } } if ( $lead && 'won' !== $lead->status ) { $changes['status'] = 'won'; } Leads::update( $lead_id, $changes ); self::add_order( $lead_id, $number, $items ); self::merge_attempts( $lead_id ); return $lead_id; } // Before the browser, or without it: saved now, its source added when the browser tells. $fields = array( array( 'label' => __( 'Order number', 'dealroot' ), 'name' => 'order', 'type' => 'text', 'value' => $number, ), ); $fields = array_merge( $fields, self::buyer_fields( array( 'name' => $name, 'email' => $email, 'phone' => $phone, ) ) ); if ( $items ) { $fields[] = array( 'label' => __( 'Products', 'dealroot' ), 'name' => 'items', 'type' => 'textarea', 'value' => implode( "\n", $items ), ); } $data = array( 'form_plugin' => 'order', 'form_id' => $number, 'form_name' => __( 'Order', 'dealroot' ), 'fields' => wp_json_encode( $fields ), 'name' => $name, 'email' => $email, 'phone' => $phone, 'status' => 'won', 'value' => $value, 'currency' => $currency, 'won_at' => current_time( 'mysql', true ), 'note' => $note, ); $lead_id = Leads::insert( $data ); if ( $lead_id ) { set_transient( 'dealroot_ord_' . md5( $number ), 1, self::KEEP_DAYS * DAY_IN_SECONDS ); set_transient( 'dealroot_ordsrv_' . md5( $number ), 1, DAY_IN_SECONDS ); self::remember_lead( $number, $lead_id ); /** This action is documented in includes/class-capture.php */ do_action( 'dealroot_lead_created', $lead_id, $data ); } return $lead_id; } /** * Adds the order number and the products to the lead an order won, e.g. a checkout form * whose fields hold the address but not the cart. A checkout is then read as the order: its * summary and topics are the products, not a guess from the address. * * @param int $lead_id Lead. * @param string $number Order number. * @param string[] $items Products. */ private static function add_order( $lead_id, $number, array $items ) { global $wpdb; $lead = Leads::get( $lead_id ); if ( ! $lead ) { return; } $fields = json_decode( (string) $lead->fields, true ); $fields = is_array( $fields ) ? $fields : array(); $names = array_column( $fields, 'name' ); if ( ! in_array( 'order', $names, true ) ) { $fields[] = array( 'label' => __( 'Order number', 'dealroot' ), 'name' => 'order', 'type' => 'text', 'value' => (string) $number, ); } if ( $items && ! array_intersect( array( 'items', 'products' ), $names ) ) { $fields[] = array( 'label' => __( 'Products', 'dealroot' ), 'name' => 'items', 'type' => 'textarea', 'value' => implode( "\n", $items ), ); } $row = array( 'fields' => wp_json_encode( $fields ) ); if ( $items && self::is_checkout( (string) $lead->form_name ) ) { $lead->fields = $row['fields']; $row = array_merge( $row, array_intersect_key( AI::order_reading( $lead ), array_flip( array( 'ai_summary', 'ai_topics' ) ) ) ); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->update( Installer::table(), $row, array( 'id' => (int) $lead_id ) ); } /** * The name, email address and phone number of a buyer as fields of the order. * * @param array $buyer name, email, phone. * @return array */ private static function buyer_fields( array $buyer ) { $fields = array(); foreach ( array( 'name' => array( __( 'Name', 'dealroot' ), 'text' ), 'email' => array( __( 'Email', 'dealroot' ), 'email' ), 'phone' => array( __( 'Phone', 'dealroot' ), 'tel' ), ) as $key => list( $label, $type ) ) { if ( '' !== (string) ( $buyer[ $key ] ?? '' ) ) { $fields[] = array( 'label' => $label, 'name' => $key, 'type' => $type, 'value' => (string) $buyer[ $key ], ); } } return $fields; } /** * Keeps the contact of a checkout left for the payment, for the order that may follow. * * @param string $key Key the tracker made for it. * @param array $contact name, email, phone. */ public static function checkout_contact( $key, array $contact ) { $contact = array_filter( array_intersect_key( $contact, array_flip( array( 'name', 'email', 'phone' ) ) ), 'strlen' ); if ( self::valid_key( $key ) && ( ! empty( $contact['email'] ) || ! empty( $contact['phone'] ) ) ) { set_transient( 'dealroot_ct_' . md5( $key ), $contact, DAY_IN_SECONDS ); } } /** * Remembers which lead an order number belongs to. * * @param string $number Order number. * @param int $lead_id Lead ID. */ private static function remember_lead( $number, $lead_id ) { if ( $lead_id ) { set_transient( 'dealroot_ordlead_' . md5( (string) $number ), (int) $lead_id, self::KEEP_DAYS * DAY_IN_SECONDS ); } } /** * The lead of an order number. * * @param string $number Order number. * @return int Lead ID, 0 when none. */ private static function lead_of( $number ) { global $wpdb; $id = (int) get_transient( 'dealroot_ordlead_' . md5( (string) $number ) ); if ( $id && Leads::get( $id ) ) { return $id; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching return (int) $wpdb->get_var( $wpdb->prepare( "SELECT id FROM %i WHERE form_plugin = 'order' AND form_id = %s ORDER BY id DESC LIMIT 1", Installer::table(), (string) $number ) ); } /** * The open enquiry of a buyer from the last three days, e.g. the checkout form. * * @param string $email Email address. * @param string $phone Phone number. * @return object|null Lead. */ private static function open_of( $email, $phone ) { global $wpdb; $digits = substr( (string) preg_replace( '/\D/', '', $phone ), -9 ); if ( '' === $email && strlen( $digits ) < 6 ) { return null; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $rows = (array) $wpdb->get_results( $wpdb->prepare( "SELECT * FROM %i WHERE status IN ('new', 'contacted', 'qualified') AND form_plugin NOT IN ('order', 'woocommerce', 'manual') AND created_at >= %s ORDER BY id DESC LIMIT 30", Installer::table(), gmdate( 'Y-m-d H:i:s', time() - 3 * DAY_IN_SECONDS ) ) ); foreach ( $rows as $row ) { if ( ( '' !== $email && 0 === strcasecmp( (string) $row->email, $email ) ) || ( strlen( $digits ) >= 6 && substr( (string) preg_replace( '/\D/', '', (string) $row->phone ), -9 ) === $digits ) ) { return $row; } } return null; } /** * Adds where the buyer came from to an order the server told about first. * * @param int $lead_id Lead ID. */ private static function attribute( $lead_id ) { global $wpdb; $attribution = Attribution::from_request(); if ( ! $attribution ) { return; } $columns = array_merge( Attribution::to_columns( $attribution ), Visitor::for_lead( $attribution ), array( 'clarity' => Clarity::from_request() ) ); $wpdb->update( Installer::table(), $columns, array( 'id' => (int) $lead_id ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching /** * Fires after the source of a lead was added later, e.g. the ad click of an order the * shop told before the browser did, so the sale can be reported to the ad platform now. * * @param int $lead_id Lead ID. */ do_action( 'dealroot_lead_attributed', (int) $lead_id ); } /** * Whether a form looks like a checkout, by its name. * * @param string $name Name of the form. * @return bool */ public static function is_checkout( $name ) { return (bool) preg_match( '/(checkout|kasse|bestell|warenkorb|zahlung|order|commande|caisse|pedido|pago|ordine|cassa|afrekenen|bestelling|zamówienie|kasa|оформлен|замовлен|заказ)/iu', (string) $name ); } /** * Whether a lead is a checkout that no order followed, half an hour on. * * @param object $lead Lead. * @return bool */ public static function unfinished( $lead ) { return 'new' === $lead->status && ! in_array( (string) $lead->form_plugin, array( 'woocommerce', 'order', 'manual' ), true ) && self::is_checkout( (string) $lead->form_name ) && strtotime( $lead->created_at . ' UTC' ) < time() - 30 * MINUTE_IN_SECONDS; } /** * Earlier tries of the same checkout by the same person, e.g. when a payment did not go * through the first time, are one order with the lead that was won: they are merged into it, * so the order is not counted twice and the person not shown as waiting for an answer. * * @param int $lead_id Lead of the order. * @return int Leads merged. */ public static function merge_attempts( $lead_id ) { global $wpdb; $lead = Leads::get( (int) $lead_id ); if ( ! $lead || ( '' === (string) $lead->email && '' === (string) $lead->phone ) ) { return 0; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $rows = (array) $wpdb->get_results( $wpdb->prepare( "SELECT id, form_name FROM %i WHERE id <> %d AND status = 'new' AND form_plugin NOT IN ('woocommerce', 'order', 'manual') AND ( email = NULLIF(%s, '') OR phone = NULLIF(%s, '') ) AND created_at >= %s AND created_at <= %s", Installer::table(), (int) $lead->id, (string) $lead->email, (string) $lead->phone, gmdate( 'Y-m-d H:i:s', strtotime( $lead->created_at . ' UTC' ) - 3 * HOUR_IN_SECONDS ), (string) $lead->created_at ) ); $ids = array(); foreach ( $rows as $row ) { if ( $row->form_name === $lead->form_name || self::is_checkout( (string) $row->form_name ) ) { $ids[] = (int) $row->id; } } if ( ! $ids ) { return 0; } Leads::delete( $ids ); Leads::update( (int) $lead->id, array( 'note' => trim( $lead->note . "\n" . sprintf( /* translators: %s: number of earlier tries */ _n( 'Merged %s earlier try of the same checkout.', 'Merged %s earlier tries of the same checkout.', count( $ids ), 'dealroot' ), number_format_i18n( count( $ids ) ) ) ), ) ); return count( $ids ); } /** * A lead saved for an order number. * * @param string $number Order number. * @return bool */ private static function find( $number ) { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching return (bool) $wpdb->get_var( $wpdb->prepare( "SELECT 1 FROM %i WHERE form_plugin = 'order' AND form_id = %s LIMIT 1", Installer::table(), $number ) ); } /** * Keys are 32 hexadecimal characters. * * @param string $key Key. * @return bool */ private static function valid_key( $key ) { return (bool) preg_match( '/^[a-f0-9]{32}$/', (string) $key ); } }