repository = $repository ?: new Akkedli_V2_Outbox_Repository(); $this->client = $client ?: new Akkedli_V2_Signed_Client(); add_action(self::CAPTURE_HOOK, array($this, 'handle_capture'), 10, 1); add_action(self::DELIVERY_HOOK, array($this, 'handle_delivery'), 10, 1); add_action(self::RECOVERY_HOOK, array($this, 'recover'), 10, 0); add_action('action_scheduler_init', array(__CLASS__, 'initialize'), 10, 0); self::initialize(); } public static function initialize() { if (!self::scheduler_ready() || !function_exists('as_next_scheduled_action') || !function_exists('as_schedule_recurring_action')) { return false; } $locked = false; try { if (false !== as_next_scheduled_action( self::RECOVERY_HOOK, array(), self::GROUP )) return true; $locked = self::recovery_lock(true); if (!$locked) return false; return false !== as_next_scheduled_action(self::RECOVERY_HOOK, array(), self::GROUP) || 0 < (int) as_schedule_recurring_action(time() + self::RECOVERY_INTERVAL, self::RECOVERY_INTERVAL, self::RECOVERY_HOOK, array(), self::GROUP, true); } catch (Throwable $error) { unset($error); return false; } finally { if ($locked) self::recovery_lock(false); } } public static function schedule_capture($row_id) { return self::schedule(self::CAPTURE_HOOK, $row_id, time()); } public static function schedule_delivery($row_id) { return self::schedule(self::DELIVERY_HOOK, $row_id, time()); } public function handle_capture($row_id) { $row_id = is_int($row_id) ? $row_id : (is_string($row_id) && ctype_digit($row_id) ? (int) $row_id : 0); if ($row_id < 1) return; try { $owner = bin2hex(random_bytes(32)); $now = time(); $row = $this->repository->claim_capture($row_id, $owner, $now); if (is_array($row)) $this->capture($row, $owner, $now); } catch (Throwable $error) { unset($error, $owner, $now, $row); } } public function handle_delivery($row_id) { $row_id = is_int($row_id) ? $row_id : (is_string($row_id) && ctype_digit($row_id) ? (int) $row_id : 0); if ($row_id < 1) return; try { $owner = bin2hex(random_bytes(32)); $now = time(); $row = $this->repository->claim_delivery($row_id, $owner, $now); if (is_array($row)) $this->deliver($row, $owner, $now); } catch (Throwable $error) { unset($error, $owner, $now, $row); } } public function recover() { try { $now = time(); $state = Akkedli_Connector_State::load(); if (is_array($state) && 'v2' === $state['mode']) Akkedli_V2_Outbox_Recovery::recover_current($state, $this->repository, $now); $this->repository->block_orphans($now, self::BATCH); $rows = $this->repository->recoverable($now, self::BATCH); if (is_array($rows)) { foreach ($rows as $row) { if (isset($row['id'], $row['state'])) { $hook = in_array($row['state'], array('capture_pending', 'capture_processing'), true) ? self::CAPTURE_HOOK : self::DELIVERY_HOOK; self::schedule($hook, (int) $row['id'], $now); } } } $this->repository->cleanup($now - 604800, self::BATCH); } catch (Throwable $error) { unset($error, $now, $rows); } } private function capture(array $row, $owner, $now) { if (self::expired($row, $now)) { $this->fail_capture($row, $owner, 'capture_exhausted', $now); return; } try { $value = Akkedli_V2_Outbox_Crypto::open($row['envelope'], $row['event_id'], 'order'); if (array_keys($value) !== array('native_order_id') || !self::native_id($value['native_order_id'])) { throw new RuntimeException('Invalid capture authority.'); } } catch (Throwable $error) { unset($error, $value); $this->fail_capture($row, $owner, 'capture_invalid', $now); return; } if ((int) $row['capture_attempts'] > self::MAX_CAPTURE_ATTEMPTS) { unset($value); $this->fail_capture($row, $owner, 'capture_exhausted', $now); return; } try { $order = wc_get_order((int) $value['native_order_id']); if (!is_object($order) || !is_a($order, 'WC_Order') || is_a($order, 'WC_Order_Refund') || (string) $order->get_id() !== $value['native_order_id']) { unset($value, $order); $this->fail_capture($row, $owner, 'capture_invalid', $now); return; } $body = $this->client->build_order_body($order); unset($order, $value); if (!is_string($body)) { $this->retry_capture($row, $owner, $now); return; } $idempotency = 'order.' . Akkedli_V2_Crypto::base64url_encode(random_bytes(32)); $envelope = Akkedli_V2_Outbox_Crypto::seal(array( 'body' => $body, 'idempotency_key' => $idempotency, ), $row['event_id'], 'order'); unset($body, $idempotency); if ($this->repository->freeze((int) $row['id'], $owner, $envelope, $now)) { self::schedule_delivery((int) $row['id']); } unset($envelope); } catch (Throwable $error) { unset($error, $value, $order, $body, $idempotency, $envelope); $this->retry_capture($row, $owner, $now); } } private function deliver(array $row, $owner, $now) { if (self::expired($row, $now)) { $this->finish_delivery($row, $owner, 'exhausted', 'retry_exhausted', $now); return; } try { $value = Akkedli_V2_Outbox_Crypto::open( $row['envelope'], $row['event_id'], $row['operation'] ); if (array_keys($value) !== array('body', 'idempotency_key') || !is_string($value['body']) || !is_string($value['idempotency_key'])) { throw new RuntimeException('Invalid delivery envelope.'); } } catch (Throwable $error) { unset($error, $value); $this->repository->quarantine_delivery($row, $owner, 'delivery_invalid', $now); return; } try { $result = 'order' === $row['operation'] ? $this->client->send_order($value['body'], $value['idempotency_key']) : $this->client->send_status($value['body'], $value['idempotency_key']); unset($value); $this->apply_result($row, $owner, is_array($result) ? $result : array(), $now); } catch (Throwable $error) { unset($error, $value, $result); $this->retry_delivery($row, $owner, array('outcome' => 'retryable'), $now); } } private function apply_result(array $row, $owner, array $result, $now) { $outcome = isset($result['outcome']) ? $result['outcome'] : ''; if ('confirmed' === $outcome) { $code = isset($result['code']) ? $result['code'] : ''; if ('order' === $row['operation'] && 'ORDER_SKIPPED' === $code) { $this->finish_delivery($row, $owner, 'skipped', 'order_skipped', $now); return; } $expected = 'order' === $row['operation'] ? 'ORDER_ACCEPTED' : 'STATUS_ACCEPTED'; $this->finish_delivery($row, $owner, $code === $expected ? 'confirmed' : 'rejected', $code === $expected ? 'accepted' : 'delivery_invalid', $now); return; } if ('retryable' === $outcome) { $this->retry_delivery($row, $owner, $result, $now); return; } if ('paused' === $outcome) { $this->repository->pause_delivery((int) $row['id'], $owner, isset($result['code']) ? $result['code'] : 'paused', $now); return; } if ('authority_lost' === $outcome) { Akkedli_V2_Binding_State::authority_lost(isset($result['credential_id']) ? $result['credential_id'] : ''); $this->repository->terminal_delivery((int) $row['id'], $owner, 'rejected', 'authority_lost', $now); return; } $code = isset($result['code']) ? $result['code'] : ''; if ('quarantined' === $outcome && $this->repository->quarantine_delivery($row, $owner, $code, $now)) return; $code = 'quarantined' === $outcome && isset($result['code']) ? $result['code'] : 'rejected'; $this->finish_delivery($row, $owner, 'rejected', $code, $now); } private function retry_capture(array $row, $owner, $now) { $attempt = (int) $row['capture_attempts']; if ($attempt >= self::MAX_CAPTURE_ATTEMPTS) { $this->fail_capture($row, $owner, 'capture_exhausted', $now); return; } $delay = self::$capture_delays[$attempt - 1]; if ($this->repository->retry_capture((int) $row['id'], $owner, $delay, $now)) { self::schedule(self::CAPTURE_HOOK, (int) $row['id'], $now + $delay); } } private function fail_capture(array $row, $owner, $outcome, $now) { if ($this->repository->terminal_capture((int) $row['id'], $owner, $outcome, $now)) { $this->repository->block_dependents($row['order_key'], $now); } } private function retry_delivery(array $row, $owner, array $result, $now) { $attempt = (int) $row['attempts']; if ($attempt >= self::MAX_DELIVERY_ATTEMPTS) { $this->finish_delivery($row, $owner, 'exhausted', 'retry_exhausted', $now); return; } $delay = self::$delivery_delays[$attempt - 1]; if (isset($result['retry_after']) && is_int($result['retry_after']) && $result['retry_after'] >= 1 && $result['retry_after'] <= 43200) { $delay = max($delay, $result['retry_after']); } if ($this->repository->retry_delivery((int) $row['id'], $owner, $delay, $now)) { self::schedule(self::DELIVERY_HOOK, (int) $row['id'], $now + $delay); } } private function finish_delivery(array $row, $owner, $state, $outcome, $now) { if ($this->repository->terminal_delivery((int) $row['id'], $owner, $state, $outcome, $now) && 'order' === $row['operation'] && 'confirmed' !== $state) { $this->repository->block_dependents($row['order_key'], $now); } } private static function schedule($hook, $row_id, $timestamp) { if ((int) $row_id < 1 || !self::scheduler_ready() || !function_exists('as_schedule_single_action')) { return false; } try { return 0 < (int) as_schedule_single_action((int) $timestamp, $hook, array((int) $row_id), self::GROUP); } catch (Throwable $error) { unset($error); return false; } } private static function scheduler_ready() { return class_exists('ActionScheduler') && method_exists('ActionScheduler', 'is_initialized') && ActionScheduler::is_initialized(); } private static function recovery_lock($acquire) { global $wpdb; $name = 'akkedli_v2_outbox_recovery_' . (string) get_current_blog_id(); $sql = $acquire ? 'SELECT GET_LOCK(%s,0)' : 'SELECT RELEASE_LOCK(%s)'; return 1 === (int) $wpdb->get_var($wpdb->prepare($sql, $name)); } private static function expired(array $row, $now) { if (isset($row['last_outcome']) && in_array( $row['last_outcome'], array('local_not_ready', 'connector_disabled'), true )) return false; if (!isset($row['created_gmt']) || !is_string($row['created_gmt'])) { return true; } $created = DateTimeImmutable::createFromFormat('!Y-m-d H:i:s', $row['created_gmt'], new DateTimeZone('UTC')); return false === $created || $created->format('Y-m-d H:i:s') !== $row['created_gmt'] || $created->getTimestamp() > $now || $now - $created->getTimestamp() >= self::MAX_EVENT_AGE; } private static function native_id($value) { return is_string($value) && 1 === preg_match('/^[1-9]\d{0,15}$/D', $value) && (strlen($value) < 16 || strcmp($value, '9007199254740991') <= 0); } }