.... * * @param string $raw Cookie value. * @return array{last:int,products:string[]}|null */ public static function parse_marker( $raw ) { $parts = explode( '.', (string) $raw ); if ( count( $parts ) < 4 || '1' !== $parts[0] || ! preg_match( '/^[a-z0-9]{1,12}$/', $parts[3] ) ) { return null; } $last = (int) floor( intval( $parts[3], 36 ) / 1000 ); $products = isset( $parts[4] ) ? array_values( array_filter( explode( '~', $parts[4] ), 'ctype_digit' ) ) : array(); return array( 'last' => $last, 'products' => array_slice( $products, 0, 20 ), ); } /** * Checkout (classic or block): label the order now; the marker is read once. * * @param \WC_Order $order Order. * @return void */ public function capture( $order ) { if ( ! self::enabled() || ! $order instanceof \WC_Order ) { return; } $raw = isset( $_COOKIE[ self::COOKIE ] ) ? sanitize_text_field( wp_unslash( $_COOKIE[ self::COOKIE ] ) ) : ''; $marker = self::parse_marker( $raw ); $ordered = array(); $from_cfg = false; foreach ( $order->get_items() as $item ) { if ( ! $item instanceof \WC_Order_Item_Product ) { continue; } $ordered[] = (string) $item->get_product_id(); if ( '' !== (string) $item->get_meta( Cart::META_CONFIG ) ) { $from_cfg = true; } } $engaged = array(); if ( $marker && time() - $marker['last'] <= self::WINDOW ) { $engaged = array_values( array_intersect( $ordered, $marker['products'] ) ); } if ( $from_cfg || $engaged ) { $label = '3d'; } elseif ( function_exists( 'wp_has_consent' ) && ! wp_has_consent( 'statistics' ) ) { $label = 'unknown'; } else { $label = 'none'; } $order->update_meta_data( self::META_LABEL, $label ); $order->update_meta_data( self::META_COUNT, count( array_unique( $engaged ) ) ); if ( $marker ) { $order->update_meta_data( self::META_MARKER, $raw ); } $order->save_meta_data(); } /** * Paid: queue one report (deduplicated by the order meta flag). * * @param int $order_id Order ID. * @return void */ public function paid( $order_id ) { if ( ! self::enabled() || null === Connection::token() || ! function_exists( 'as_enqueue_async_action' ) ) { return; } $order = wc_get_order( $order_id ); if ( ! $order || '' === (string) $order->get_meta( self::META_LABEL ) || $order->get_meta( self::META_SENT ) ) { return; } $order->update_meta_data( self::META_SENT, 'queued' ); $order->save_meta_data(); as_enqueue_async_action( self::ACTION, array( (int) $order_id, 1 ), self::GROUP ); } /** * Refund: queue a refund report for attributed orders. * * @param int $order_id Order ID. * @param int $refund_id Refund ID. * @return void */ public function refunded( $order_id, $refund_id ) { if ( ! self::enabled() || null === Connection::token() || ! function_exists( 'as_enqueue_async_action' ) ) { return; } $order = wc_get_order( $order_id ); if ( ! $order || '' === (string) $order->get_meta( self::META_LABEL ) ) { return; } as_enqueue_async_action( self::ACTION_REFND, array( (int) $order_id, (int) $refund_id, 1 ), self::GROUP ); } /** * Action: send a paid order. * * @param int $order_id Order ID. * @param int $attempt Attempt number. * @return void */ public function send_order( $order_id, $attempt = 1 ) { $order = wc_get_order( $order_id ); if ( ! $order ) { return; } $quantity = 0; foreach ( $order->get_items() as $item ) { $quantity += (int) $item->get_quantity(); } $report = array( 'kind' => 'purchase', 'order_id' => (string) $order->get_id(), 'value_minor' => self::minor( (float) $order->get_total(), $order->get_currency() ), 'currency' => $order->get_currency(), 'quantity' => $quantity, 'attribution' => (string) $order->get_meta( self::META_LABEL ), 'products_3d' => (int) $order->get_meta( self::META_COUNT ), 'at' => self::paid_at( $order ), ); if ( $this->send( $report, self::ACTION, array( (int) $order_id ), $attempt ) ) { $order->update_meta_data( self::META_SENT, 'sent' ); $order->save_meta_data(); } } /** * Action: send a refund. * * @param int $order_id Order ID. * @param int $refund_id Refund ID. * @param int $attempt Attempt number. * @return void */ public function send_refund( $order_id, $refund_id, $attempt = 1 ) { $order = wc_get_order( $order_id ); $refund = wc_get_order( $refund_id ); if ( ! $order || ! $refund instanceof \WC_Order_Refund ) { return; } $quantity = 0; foreach ( $refund->get_items() as $item ) { $quantity += abs( (int) $item->get_quantity() ); } $created = $refund->get_date_created(); $report = array( 'kind' => 'refund', 'id' => (string) $refund->get_id(), 'order_id' => (string) $order->get_id(), 'value_minor' => self::minor( abs( (float) $refund->get_amount() ), $order->get_currency() ), 'currency' => $order->get_currency(), 'quantity' => $quantity, 'attribution' => (string) $order->get_meta( self::META_LABEL ), 'at' => ( $created ? $created->getTimestamp() : time() ) * 1000, ); $this->send( $report, self::ACTION_REFND, array( (int) $order_id, (int) $refund_id ), $attempt ); } /** * POST one report; retry with backoff on transient failures. * * @param array $report Report. * @param string $action Action to reschedule. * @param array $args Action args without the attempt. * @param int $attempt Attempt number. * @return bool Delivered (or permanently rejected). */ private function send( array $report, $action, array $args, $attempt ) { $result = Api::request( 'POST', '/orders', array( 'orders' => array( $report ) ), null, 15 ); if ( Api::ok( $result ) ) { return true; } $status = is_wp_error( $result ) ? 0 : (int) $result['status']; $retry = 0 === $status || 409 === $status || $status >= 500; if ( $retry && $attempt < self::MAX_ATTEMPTS ) { $args[] = $attempt + 1; as_schedule_single_action( time() + 15 * MINUTE_IN_SECONDS * $attempt, $action, $args, self::GROUP ); } return ! $retry; } /** * Minor units using the store's decimal setting for the currency. * * @param float $amount Amount. * @param string $currency ISO code. * @return int */ private static function minor( $amount, $currency ) { $zero_decimal = array( 'BIF', 'CLP', 'DJF', 'GNF', 'ISK', 'JPY', 'KMF', 'KRW', 'PYG', 'RWF', 'UGX', 'VND', 'VUV', 'XAF', 'XOF', 'XPF' ); $three = array( 'BHD', 'IQD', 'JOD', 'KWD', 'LYD', 'OMR', 'TND' ); $digits = in_array( $currency, $zero_decimal, true ) ? 0 : ( in_array( $currency, $three, true ) ? 3 : 2 ); return (int) round( $amount * ( 10 ** $digits ) ); } /** * When the order was paid, in epoch ms. * * @param \WC_Order $order Order. * @return int */ private static function paid_at( $order ) { $date = $order->get_date_paid(); if ( ! $date ) { $date = $order->get_date_created(); } return ( $date ? $date->getTimestamp() : time() ) * 1000; } /** * Local conversion summary for the Insights tab (cached 10 minutes). * * @param int $days Look-back window. * @return array */ public static function summary( $days = 30 ) { $key = 'thridify_insights_' . (int) $days; $cached = get_transient( $key ); if ( is_array( $cached ) ) { return $cached; } $summary = array( '3d' => array( 'orders' => 0, 'revenue' => 0.0, ), 'none' => array( 'orders' => 0, 'revenue' => 0.0, ), 'unknown' => array( 'orders' => 0, 'revenue' => 0.0, ), ); $orders = wc_get_orders( array( 'limit' => 2000, 'status' => array( 'wc-processing', 'wc-completed' ), 'date_created' => '>' . ( time() - $days * DAY_IN_SECONDS ), 'meta_key' => self::META_LABEL, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- bounded admin report, cached. 'return' => 'objects', ) ); foreach ( $orders as $order ) { $label = (string) $order->get_meta( self::META_LABEL ); if ( isset( $summary[ $label ] ) ) { ++$summary[ $label ]['orders']; $summary[ $label ]['revenue'] += (float) $order->get_total() - (float) $order->get_total_refunded(); } } set_transient( $key, $summary, 10 * MINUTE_IN_SECONDS ); return $summary; } }