session || !RefRef_Storefront::consent()) { return; } $handle = RefRef_Storefront::handle_id(); if ($handle !== '') { $order->update_meta_data('_refref_handle', $handle); } if (!$order->get_customer_id()) { $guest = WC()->session->get('refref_guest'); if (!$guest) { $guest = wp_generate_uuid4(); WC()->session->set('refref_guest', $guest); } $order->update_meta_data('_refref_guest', $guest); } $order->save(); } /** Read history without retaining every newly loaded order in Woo's request caches. */ public static function history_orders(array $ids, array $preserve_ids = []): iterable { $store = WC_Data_Store::load('order'); $legacy = !\Automattic\WooCommerce\Utilities\OrderUtil::custom_orders_table_usage_is_enabled(); $cache = wc_get_container()->get(\Automattic\WooCommerce\Caches\OrderCache::class); foreach (array_chunk($ids, 250) as $batch) { $release = []; $loaded = []; foreach ($batch as $id) { // A caller may already hold an unsaved order. Never evict its cached object. if (in_array((int) $id, $preserve_ids, true) || (!$legacy && $cache->is_cached($id))) { continue; } $keys = ['posts' => $id, 'post_meta' => $id, 'orders' => WC_Order::generate_meta_cache_key($id, 'orders')]; $missing = []; foreach ($keys as $group => $key) { wp_cache_get($key, $group, false, $found); if (!$found) { $missing[$group] = $key; } } $release[$id] = $missing; } try { // Legacy storage needs raw metadata primed separately; item/refund data is not read here. if ($legacy) { $store->prime_caches_for_orders($batch, ['fields' => ['meta_data']]); } $orders = WC_Order_Factory::get_orders($batch); foreach ($orders as $position => $order) { if (!$order instanceof WC_Abstract_Order) { throw new RuntimeException('Could not load order ' . $batch[$position]); } $loaded[$order->get_id()] = [$order, self::history_meta_state($order)]; } yield from $orders; } finally { foreach ($release as $id => $missing) { $state = $loaded[$id] ?? null; $order = $state[0] ?? null; if (!$state && !$legacy) { $order = $cache->get($id); } // A getter/filter or consumer may mutate an order during the scan. if ($order && ($order->get_changes() || self::history_meta_dirty($order) || ($state && self::history_meta_state($order) !== $state[1]))) { continue; } if (!$legacy) { $cache->remove($id); // Woo 11.1.2 exposes this @internal invalidator. It clears persisted raw data, // not unsaved objects; another worker may incur a cache miss, never a data write. $store->clear_cached_data([$id]); } foreach ($missing as $group => $key) { wp_cache_delete($key, $group); } } unset($orders, $loaded, $order); } } } private static function history_meta_state($order): array { return array_map(static fn($meta) => [$meta->id, $meta->key, $meta->value], $order->get_meta_data()); } private static function history_meta_dirty($order): bool { foreach ($order->get_meta_data() as $meta) { if (!$meta->id || $meta->get_changes()) { return true; } } return false; } public static function paid($id): void { global $wpdb; $lock = substr('refref-paid:' . hash('sha256', $wpdb->prefix . $id), 0, 64); // Connection-scoped database mutex: a cached result cannot acquire or release a lock. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- No WordPress locking API provides this cross-worker guarantee. if ((int) $wpdb->get_var($wpdb->prepare('SELECT GET_LOCK(%s, 5)', $lock)) !== 1) { throw new RuntimeException(esc_html__('Paid order snapshot is busy.', 'refref')); } try { $order = wc_get_order($id); if (!$order || !$order->get_date_paid() || !RefRef_Config::valid_site()) { return; } if (!$order->get_meta('_refref_purchase')) { $order->update_meta_data('_refref_store', RefRef_Config::identity()['id']); $order->update_meta_data('_refref_paid_customer', $order->get_customer_id()); $order->update_meta_data('_refref_paid_guest', $order->get_meta('_refref_guest')); $base = 0; foreach ($order->get_items() as $item) { $base += (int) self::minor($item->get_total()); } $order->update_meta_data('_refref_purchase', [ 'resourceType' => 'order', 'externalResourceId' => (string) $order->get_id(), 'paidAt' => gmdate('Y-m-d\TH:i:s\Z', $order->get_date_paid()->getTimestamp()), 'currency' => $order->get_currency(), 'currencyExponent' => wc_get_price_decimals(), 'paidAmountMinor' => self::minor($order->get_total()), 'taxMinor' => self::minor($order->get_total_tax()), 'commissionableAmountMinor' => (string) $base, ]); $purchase = $order->get_meta('_refref_purchase'); $customer = $order->get_customer_id(); if ($customer) { $ids = wc_get_orders(['customer_id' => $customer, 'limit' => 10001, 'return' => 'ids']); if (count($ids) <= 10000) { $index = 0; foreach (self::history_orders($ids, [(int) $id]) as $previous) { // Count observed payments, not timestamp/ID rank: another order may complete earlier in the same second. if ($previous->get_date_paid()) { $index++; } } if ($index > 0) { $purchase['sourceOrderIndex'] = $index; } } } $order->update_meta_data('_refref_purchase', $purchase); $order->save(); } } finally { // Connection-scoped database mutex: a cached result cannot release the lock. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- The lock must be released on the same database connection. $wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lock)); } self::schedule($id); } public static function schedule($id): void { if (!RefRef_Config::connection()) { return; } if (!as_has_scheduled_action('refref_send_order', [(int) $id, 0], 'refref')) { as_enqueue_async_action('refref_send_order', [(int) $id, 0], 'refref'); } } public static function send($id, $attempt = 0): void { $connection = RefRef_Config::connection(); if (!$connection) { return; } try { $snapshot = self::read((int) $id); $event = RefRef_Transport::proof('event', ['id' => hash('sha256', wp_json_encode($snapshot)), 'orderId' => (string) $id, 'test' => RefRef_Config::mode() === 'test']); RefRef_Transport::request('/integrations/v1/woocommerce/webhooks/' . rawurlencode($connection['id']), $event); update_option('refref_sync_status', 'Last accepted: ' . gmdate('Y-m-d H:i:s') . ' UTC', false); } catch (Throwable $error) { update_option('refref_sync_status', 'Waiting to retry order ' . (int) $id, false); as_schedule_single_action(time() + min(3600, 30 * (2 ** min(7, $attempt))), 'refref_send_order', [(int) $id, $attempt + 1], 'refref'); } } public static function read(int $id): array { $order = wc_get_order($id); if (!$order instanceof WC_Order) { throw new RuntimeException(esc_html__('Order not found.', 'refref')); } $purchase = $order->get_meta('_refref_store') === RefRef_Config::identity()['id'] ? ($order->get_meta('_refref_purchase') ?: null) : null; $customer = $purchase ? (int) $order->get_meta('_refref_paid_customer') : $order->get_customer_id(); $participant = $customer ? self::participant($customer) : null; $guest = $purchase ? $order->get_meta('_refref_paid_guest') : $order->get_meta('_refref_guest'); if (!$participant && $guest) { $participant = 'woocommerce:' . RefRef_Config::identity()['id'] . ':guest:' . $guest; } $refunds = []; foreach ($order->get_refunds() as $refund) { $base = 0; foreach ($refund->get_items() as $item) { $base += (int) self::minor($item->get_total(), $purchase['currencyExponent'] ?? null); } $refunds[] = ['externalOperationId' => (string) $refund->get_id(), 'occurredAt' => gmdate('Y-m-d\TH:i:s\Z', $refund->get_date_created()->getTimestamp()), 'eligibleBaseDeltaMinor' => (string) $base, 'unallocated' => count($refund->get_items(['line_item', 'shipping', 'fee'])) === 0 && (float) $refund->get_amount() > 0]; } return ['id' => (string) $id, 'account' => RefRef_Config::identity()['id'], 'test' => RefRef_Config::mode() === 'test', 'createdAt' => gmdate('Y-m-d\TH:i:s\Z', $order->get_date_created()->getTimestamp()), 'participant' => $participant, 'purchase' => $purchase, 'refunds' => $refunds, 'fullyRefunded' => $purchase && (float) $order->get_total_refunded() >= (float) $order->get_total(), 'handle' => $order->get_meta('_refref_handle') ?: null, 'coupons' => $order->get_coupon_codes()]; } }