'', 'done' => 0, 'since' => 0, 'page' => 1, 'tries' => 0, ) ); } /** * @param array $state Order send state. */ function sevenbe_orders_save_state( $state ) { update_option( SEVENBE_ORDERS_OPTION, $state, false ); } /** * What 7BE needs to know about one order: the order's money, where the visit came from and a one-way hash of the buyer's email. * * @param WC_Order $order The order. * @return array|null Null when this is not an order. */ function sevenbe_attribution_payload( $order ) { if ( ! $order instanceof WC_Order ) { return null; } $created = $order->get_date_created(); // Net of tax and shipping (shipping tax is part of the total tax) and of the goods refunded: a refund's own tax and // shipping are already left out above, so only the rest of it comes off. $refunded = (float) $order->get_total_refunded() - (float) $order->get_total_tax_refunded() - (float) $order->get_total_shipping_refunded(); $net = max( 0, round( (float) $order->get_total() - (float) $order->get_total_tax() - (float) $order->get_shipping_total() - $refunded, 2 ) ); // WooCommerce's order attribution keeps these on the order; the plugin reads them through the order API (HPOS-safe). $referrer = trim( (string) $order->get_meta( '_wc_order_attribution_referrer' ) ); if ( '' !== $referrer && false === strpos( $referrer, '//' ) ) { $referrer = '//' . $referrer; } $host = '' !== $referrer ? wp_parse_url( $referrer, PHP_URL_HOST ) : ''; // The buyer, only as a one-way hash of the email: 7BE never receives the email itself. $email = strtolower( trim( (string) $order->get_billing_email() ) ); return array( 'id' => (string) $order->get_id(), 'at' => gmdate( 'c', $created ? $created->getTimestamp() : time() ), 'net' => $net, 'status' => preg_replace( '/^wc-/', '', (string) $order->get_status() ), 'source_type' => mb_substr( sanitize_text_field( (string) $order->get_meta( '_wc_order_attribution_source_type' ) ), 0, 40 ), 'utm_source' => mb_substr( sanitize_text_field( (string) $order->get_meta( '_wc_order_attribution_utm_source' ) ), 0, 120 ), 'referrer' => is_string( $host ) ? mb_substr( strtolower( $host ), 0, 120 ) : '', 'customer' => '' !== $email ? hash( 'sha256', $email ) : '', ); } /** * Send orders to 7BE (POST /api/apps/orders, at most 250 at a time). * * @param WC_Order[] $orders The orders. * @param bool $backfill Whether this is a page of the 60-day history. * @param bool $blocking Wait for the answer; false sends without slowing the request down. * @param bool $done The history's last page: 7BE then has all 60 days, even when this page is empty. * @return bool Whether 7BE took them (always true for a send that does not wait). */ function sevenbe_orders_send( $orders, $backfill = false, $blocking = true, $done = false ) { $rows = array_values( array_filter( array_map( 'sevenbe_attribution_payload', $orders ) ) ); if ( ! $rows && ! $done ) { return true; } $body = array( 'plugin' => SEVENBE_VERSION, 'currency' => get_woocommerce_currency(), 'backfill' => (bool) $backfill, 'orders' => $rows, ); if ( $done ) { $body['done'] = true; } $result = sevenbe_signed_api( $body, $blocking, '/api/apps/orders' ); if ( ! $blocking ) { return true; } return ! empty( $result['ok'] ) && ! isset( $result['error'] ); } /** * Send the orders a query matches, 250 a page, until they are all sent or the time budget is used. * * @param array $query wc_get_orders() arguments (without limit and paged). * @param bool $backfill Whether these pages are the 60-day history. * @param int $page First page to send. * @param int $deadline Time after which no new page starts (0 for no limit). * @return array { ok: bool, page: int } ok is false when 7BE did not take a page (page is that page); otherwise * page is the next page still to send, or 0 when everything was sent. */ function sevenbe_orders_send_pages( $query, $backfill, $page = 1, $deadline = 0 ) { $query['limit'] = SEVENBE_ORDERS_PAGE; $query['return'] = 'objects'; do { $query['paged'] = $page; $orders = wc_get_orders( $query ); $count = count( $orders ); $last = SEVENBE_ORDERS_PAGE !== $count; // The history's last page says it is the last, even when it is empty (a store without orders in the 60 days), // so 7BE knows the history is in and shows the report. $done = $backfill && $last; if ( ( $count || $done ) && ! sevenbe_orders_send( $orders, $backfill, true, $done ) ) { return array( 'ok' => false, 'page' => $page, ); } ++$page; if ( ! $last && $deadline && time() >= $deadline ) { return array( 'ok' => true, 'page' => $page, ); } } while ( ! $last ); return array( 'ok' => true, 'page' => 0, ); } // ---------- The 60-day history ---------- /** * Schedule the history send in the background. Does nothing when one is already scheduled. * * @param int $delay Seconds from now. */ function sevenbe_orders_schedule_backfill( $delay = 15 ) { if ( ! wp_next_scheduled( 'sevenbe_orders_backfill' ) ) { wp_schedule_single_event( time() + $delay, 'sevenbe_orders_backfill' ); } } /** * Make sure the daily resend is scheduled while the store is connected. */ function sevenbe_orders_schedule_daily() { if ( ! wp_next_scheduled( 'sevenbe_orders_daily' ) ) { wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', 'sevenbe_orders_daily' ); } } /** * Start the 60-day history send from the beginning (after connecting, and after updating to 0.9.3). */ function sevenbe_orders_start() { sevenbe_orders_save_state( array( 'version' => SEVENBE_VERSION, 'done' => 0, 'since' => 0, 'page' => 1, 'tries' => 0, ) ); sevenbe_orders_schedule_backfill(); sevenbe_orders_schedule_daily(); } /** * Forget the order send: the store disconnects, or the plugin is switched off. */ function sevenbe_orders_stop() { wp_clear_scheduled_hook( 'sevenbe_orders_backfill' ); wp_clear_scheduled_hook( 'sevenbe_orders_daily' ); } /** * WP-Cron: send the last 60 days of orders, a page of 250 at a time. A long history carries on in a new event; * a failure is tried again in an hour, up to 5 attempts. */ function sevenbe_orders_backfill_run() { if ( ! sevenbe_orders_connected() || ! function_exists( 'wc_get_orders' ) ) { return; } $state = sevenbe_orders_state(); if ( $state['done'] ) { return; } if ( ! $state['since'] ) { $state['since'] = time() - SEVENBE_ORDERS_DAYS * DAY_IN_SECONDS; } $run = sevenbe_orders_send_pages( array( 'status' => sevenbe_orders_statuses(), 'date_created' => '>' . $state['since'], 'orderby' => 'date', 'order' => 'ASC', ), true, max( 1, (int) $state['page'] ), time() + 25 ); $state['page'] = $run['page'] ? $run['page'] : 1; if ( ! $run['ok'] ) { ++$state['tries']; sevenbe_orders_save_state( $state ); if ( $state['tries'] < SEVENBE_ORDERS_TRIES ) { sevenbe_orders_schedule_backfill( HOUR_IN_SECONDS ); } return; } if ( $run['page'] ) { // More pages than fit in one run: carry on in a minute. sevenbe_orders_save_state( $state ); sevenbe_orders_schedule_backfill( MINUTE_IN_SECONDS ); return; } $state['done'] = time(); $state['tries'] = 0; sevenbe_orders_save_state( $state ); } add_action( 'sevenbe_orders_backfill', 'sevenbe_orders_backfill_run' ); // In the admin: after the plugin is updated to 0.9.3 (or later; 0.9.3 adds the buyer's hash) send the history once, pick up one that did not finish, // and keep the daily resend scheduled. add_action( 'admin_init', function () { if ( wp_doing_ajax() || ! sevenbe_orders_connected() ) { return; } $state = sevenbe_orders_state(); if ( version_compare( $state['version'], '0.9.3', '<' ) ) { sevenbe_orders_start(); return; } if ( SEVENBE_VERSION !== $state['version'] ) { $state['version'] = SEVENBE_VERSION; sevenbe_orders_save_state( $state ); } // A history send that was cleared before it finished (the plugin was switched off and on) is picked up again. if ( ! $state['done'] && $state['tries'] < SEVENBE_ORDERS_TRIES ) { sevenbe_orders_schedule_backfill(); } sevenbe_orders_schedule_daily(); } ); // ---------- Live: an order is sent when it changes ---------- /** * Orders to send when the request ends: one send per order even if it changed several times, with its final totals. * * @param int $order_id An order to add; 0 returns the queue and empties it. * @return int[] The queued order IDs (when called with 0). */ function sevenbe_orders_queue( $order_id = 0 ) { static $ids = array(); if ( $order_id ) { $ids[ (int) $order_id ] = (int) $order_id; return array(); } $queued = array_values( $ids ); $ids = array(); return $queued; } /** * @param int $order_id The order that changed. */ function sevenbe_orders_changed( $order_id ) { if ( sevenbe_orders_connected() ) { sevenbe_orders_queue( $order_id ); } } add_action( 'woocommerce_order_status_changed', 'sevenbe_orders_changed', 20 ); add_action( 'woocommerce_order_refunded', 'sevenbe_orders_changed', 20 ); /** * At the end of the request, send the orders that changed. The request does not wait for 7BE's answer. */ function sevenbe_orders_flush() { $ids = sevenbe_orders_queue(); if ( ! $ids || ! function_exists( 'wc_get_order' ) ) { return; } $orders = array(); foreach ( $ids as $id ) { $order = wc_get_order( $id ); // Carts that are not orders yet are not sent. if ( $order && ! in_array( $order->get_status(), array( 'checkout-draft', 'auto-draft', 'draft' ), true ) ) { $orders[] = $order; } } foreach ( array_chunk( $orders, SEVENBE_ORDERS_PAGE ) as $chunk ) { sevenbe_orders_send( $chunk, false, false ); } } add_action( 'shutdown', 'sevenbe_orders_flush' ); // ---------- Daily resend ---------- /** * WP-Cron, daily: send the orders changed in the last 3 days again, and pick up a history send that never finished. */ function sevenbe_orders_daily_run() { if ( ! sevenbe_orders_connected() || ! function_exists( 'wc_get_orders' ) ) { return; } $state = sevenbe_orders_state(); if ( ! $state['done'] ) { // A history send that used up its attempts (7BE down for hours) gets a new round each day. if ( $state['tries'] >= SEVENBE_ORDERS_TRIES ) { $state['tries'] = 0; sevenbe_orders_save_state( $state ); } sevenbe_orders_schedule_backfill(); } sevenbe_orders_send_pages( array( 'status' => sevenbe_orders_statuses(), 'date_modified' => '>' . ( time() - 3 * DAY_IN_SECONDS ), 'orderby' => 'date', 'order' => 'ASC', ), false, 1, time() + 25 ); } add_action( 'sevenbe_orders_daily', 'sevenbe_orders_daily_run' );