settings = $settings; $this->recorder = $recorder; $this->visitor = $visitor; } /** * Register hooks. * * @return void */ public function boot(): void { if ( ! $this->settings->load()->flag( 'track_commerce' ) || ! class_exists( 'WooCommerce' ) ) { return; } // Capture, while the customer is still here. Both checkout implementations are covered: the // classic form, and the cart/checkout blocks, which never fire the classic hook. add_action( 'woocommerce_checkout_create_order', array( $this, 'capture_visitor' ), 10, 1 ); add_action( 'woocommerce_store_api_checkout_update_order_from_request', array( $this, 'capture_visitor_blocks' ), 10, 1 ); // Report. Payment completion is the usual trigger; the status hook catches orders paid by // methods that never call payment_complete, such as a bank transfer marked paid by hand. add_action( 'woocommerce_payment_complete', array( $this, 'on_payment_complete' ), 20, 1 ); add_action( 'woocommerce_order_status_changed', array( $this, 'on_status_changed' ), 20, 4 ); add_action( 'woocommerce_order_refunded', array( $this, 'on_refunded' ), 20, 2 ); } /** * Store the visitor context on a classic-checkout order. * * @param mixed $order The order being created. * @return void */ public function capture_visitor( $order ): void { if ( ! $order instanceof WC_Order ) { return; } // No save() here: this runs while WooCommerce is building the order and will save it itself a // moment later. Saving now would write the order twice on every checkout. $order->update_meta_data( self::META_VISITOR, $this->capture() ); } /** * Store the visitor context on a block-checkout order. * * @param mixed $order The order being updated. * @return void */ public function capture_visitor_blocks( $order ): void { if ( ! $order instanceof WC_Order ) { return; } $order->update_meta_data( self::META_VISITOR, $this->capture() ); $order->save(); } /** * Payment completed. * * @param int $order_id Order id. * @return void */ public function on_payment_complete( $order_id ): void { $this->report_purchase( (int) $order_id ); } /** * The order moved to a new status. * * @param int $order_id Order id. * @param string $from Previous status. * @param string $to New status. * @param mixed $order The order. * @return void */ public function on_status_changed( $order_id, $from, $to, $order = null ): void { unset( $from ); if ( ! $order instanceof WC_Order ) { $order = wc_get_order( (int) $order_id ); } if ( ! $order instanceof WC_Order ) { return; } // An order that has not been reported yet becomes a purchase the moment it is paid. One that // already has becomes an update, so a later cancellation can correct the revenue rather than // leaving it silently counted. if ( '' === (string) $order->get_meta( self::META_SENT ) ) { $this->report_purchase( $order->get_id(), $order ); return; } $this->recorder->record( Event::create( 'order_updated', Event::stable_id( 'order_updated', (string) $order->get_id(), (string) $to ) ) ->with_identity( $this->identity_for( $order ) ) ->with_order( $this->order_number( $order ), (float) $order->get_total(), (string) $order->get_currency(), (string) $to ) ->at( time() ), ConsentGate::CATEGORY_STATISTICS, $this->visitor_for( $order ) ); } /** * The order was refunded, in whole or in part. * * @param int $order_id Order id. * @param int $refund_id Refund id. * @return void */ public function on_refunded( $order_id, $refund_id ): void { $order = wc_get_order( (int) $order_id ); if ( ! $order instanceof WC_Order ) { return; } $refund = wc_get_order( (int) $refund_id ); $amount = $refund && method_exists( $refund, 'get_amount' ) ? abs( (float) $refund->get_amount() ) : (float) $order->get_total_refunded(); $this->recorder->record( Event::create( 'order_refunded', Event::stable_id( 'order_refunded', (string) $order->get_id(), (string) $refund_id ) ) ->with_identity( $this->identity_for( $order ) ) ->with_order( $this->order_number( $order ), $amount, (string) $order->get_currency(), 'refunded' ) ->at( time() ), ConsentGate::CATEGORY_STATISTICS, $this->visitor_for( $order ) ); } /** * Report the sale, once. * * @param int $order_id Order id. * @param WC_Order|null $order The order, when the caller already has it. * @return void */ private function report_purchase( int $order_id, ?WC_Order $order = null ): void { $order = $order instanceof WC_Order ? $order : wc_get_order( $order_id ); if ( ! $order instanceof WC_Order ) { return; } // The guard. Several hooks fire for the same order, and a shop manager can move an order back // and forth between statuses all day; the sale is still one sale. if ( '' !== (string) $order->get_meta( self::META_SENT ) ) { return; } if ( ! in_array( $order->get_status(), wc_get_is_paid_statuses(), true ) ) { return; } $identity = $this->identity_for( $order ); $visitor = $this->visitor_for( $order ); $event = Event::create( 'purchase', Event::stable_id( 'purchase', (string) $order->get_id() ) ) ->with_identity( $identity ) ->with_order( $this->order_number( $order ), (float) $order->get_total(), (string) $order->get_currency(), (string) $order->get_status() ) ->with_items( $this->line_items( $order ) ) ->with_properties( array( 'payment_method' => $order->get_payment_method_title(), 'shipping_total' => (float) $order->get_shipping_total(), 'tax_total' => (float) $order->get_total_tax(), 'item_count' => $order->get_item_count(), ) ) ->at( $this->order_time( $order ) ); $this->recorder->identify( $identity, ConsentGate::CATEGORY_STATISTICS, $visitor ); if ( ! $this->recorder->record( $event, ConsentGate::CATEGORY_STATISTICS, $visitor ) ) { // Deliberately not marked as sent. Either consent forbade it, in which case the next status // change will be refused too at no cost, or the queue was full — and then the next status // change is a free second chance at reporting the sale. return; } $order->update_meta_data( self::META_SENT, (string) time() ); $order->save(); } /** * Freeze the current request's visitor for storage. * * @return array */ private function capture(): array { return StoredVisitorContext::capture( $this->visitor, array( ConsentGate::CATEGORY_STATISTICS, ConsentGate::CATEGORY_MARKETING ) ); } /** * The visitor an order should be attributed to. * * @param WC_Order $order The order. * @return VisitorContextInterface|null Null when nothing was captured, so the caller falls back to * the current request. */ private function visitor_for( WC_Order $order ): ?VisitorContextInterface { return StoredVisitorContext::from_array( $order->get_meta( self::META_VISITOR ) ); } /** * Who placed the order. * * @param WC_Order $order The order. * @return Identity */ private function identity_for( WC_Order $order ): Identity { $customer_id = (int) $order->get_customer_id(); return Identity::from( array( // A guest has no account, so the order number becomes the stable id: it is the only thing // about them that will still exist tomorrow. 'external_id' => $customer_id > 0 ? (string) $customer_id : 'guest-' . $order->get_order_number(), 'email' => $order->get_billing_email(), 'phone' => $order->get_billing_phone(), 'traits' => array_filter( array( 'name' => trim( $order->get_billing_first_name() . ' ' . $order->get_billing_last_name() ), 'country' => $order->get_billing_country(), 'city' => $order->get_billing_city(), ) ), ) ); } /** * The order's line items. * * @param WC_Order $order The order. * @return LineItem[] */ private function line_items( WC_Order $order ): array { $items = array(); foreach ( $order->get_items() as $item ) { if ( ! $item instanceof WC_Order_Item_Product ) { continue; } $product = $item->get_product(); $quantity = (float) $item->get_quantity(); // get_total() is the line total after discount, so dividing gives the price actually paid per // unit rather than a list price the customer never saw. $unit_price = $quantity > 0 ? (float) $item->get_total() / $quantity : (float) $item->get_total(); $sku = $product && method_exists( $product, 'get_sku' ) ? (string) $product->get_sku() : ''; $items[] = LineItem::create( '' !== $sku ? $sku : (string) $item->get_product_id(), (string) $item->get_name(), $unit_price, $quantity, array( 'category' => $this->category_for( (int) $item->get_product_id() ), 'variant' => $item->get_variation_id() > 0 ? (string) $item->get_variation_id() : '', ) ); } return $items; } /** * The product's primary category name. * * @param int $product_id Product id. * @return string */ private function category_for( int $product_id ): string { if ( $product_id <= 0 ) { return ''; } $terms = get_the_terms( $product_id, 'product_cat' ); if ( ! is_array( $terms ) || array() === $terms ) { return ''; } $first = reset( $terms ); return isset( $first->name ) ? (string) $first->name : ''; } /** * The order identifier the shop shows its customers. * * @param WC_Order $order The order. * @return string */ private function order_number( WC_Order $order ): string { // get_order_number() rather than the id, so a shop using sequential order numbers reports the // number its own invoices and support tickets already use. return (string) $order->get_order_number(); } /** * When the order was placed. * * @param WC_Order $order The order. * @return int Unix timestamp. */ private function order_time( WC_Order $order ): int { $created = $order->get_date_created(); // The creation time, not now: an order paid by bank transfer and confirmed three days later was // still converted on the day the customer decided to buy. return $created ? (int) $created->getTimestamp() : time(); } }