Plugin Directory

Changeset 3777357


Ignore:
Timestamp:
10/31/2026 08:27:10 PM (6 hours ago)
Author:
akkedli
Message:

Release Akkedli Connector 1.2.0: code-free WooCommerce connection and reliable callbacks

Location:
akkedli-connector
Files:
6 added
28 edited
1 copied

Legend:

Unmodified
Added
Removed
  • akkedli-connector/tags/1.2.0/akkedli-connector.php

    r3624443 r3777357  
    44 * Plugin URI: https://akkedli.com
    55 * Description: Connect your WooCommerce store to Akkedli for automated order verification via WhatsApp.
    6  * Version: 1.1.4
     6 * Version: 1.2.0
    77 * Author: Akkedli
    88 * Author URI: https://akkedli.com
    … …  
    2525// Define plugin constants
    2626if (!defined('AKKEDLI_VERSION')) {
    27     define('AKKEDLI_VERSION', '1.1.4');
     27    define('AKKEDLI_VERSION', '1.2.0');
    2828}
    2929if (!defined('AKKEDLI_PLUGIN_URL')) {
  • akkedli-connector/tags/1.2.0/includes/class-akkedli-api-handler.php

    r3624443 r3777357  
    1313class Akkedli_API_Handler
    1414{
     15    private static $callback_orders = array();
     16
     17    public static function is_callback_order($order_id)
     18    {
     19        return isset(self::$callback_orders[(string) $order_id]);
     20    }
    1521
    1622    public function __construct()
    … …  
    2733            'methods' => 'POST',
    2834            'callback' => array($this, 'update_order_status'),
     35            'permission_callback' => array($this, 'verify_api_key'),
     36        ));
     37
     38        register_rest_route('akkedli/v1', '/legacy-order-operation', array(
     39            'methods' => 'POST',
     40            'callback' => array($this, 'legacy_order_operation'),
    2941            'permission_callback' => array($this, 'verify_api_key'),
    3042        ));
    … …  
    191203            }
    192204            $wc_status = $old_status;
     205        } elseif ('cancelled' === $verification_result && 'cancelled' === $wc_status) {
     206            $lock = Akkedli_V2_Callback_Claims::acquire_order((string) $order_id, time());
     207            if (!is_array($lock)) return $this->legacy_error('callback_busy', 409);
     208            try {
     209                $order = wc_get_order($order_id);
     210                $old_status = $order->get_status();
     211                if ('cancelled' === $old_status) {
     212                    return array('success' => true, 'order_id' => $order_id,
     213                        'old_status' => $old_status, 'new_status' => $old_status,
     214                        'verification_result' => $verification_result);
     215                }
     216                if (!in_array($old_status, array('pending', 'processing', 'on-hold'), true)) {
     217                    return $this->legacy_error('transition_protected', 409);
     218                }
     219                self::$callback_orders[(string) $order_id] = true;
     220                $order->set_status('cancelled');
     221                $order->add_order_note(sprintf(
     222                    /* translators: %1$s: verification result, %2$s: verification timestamp */
     223                    __('Order %1$s via Akkedli verification at %2$s', 'akkedli-connector'),
     224                    'cancelled',
     225                    $verified_at ? $verified_at : current_time('c')
     226                ));
     227                if ('yes' !== $order->get_meta(Akkedli_Order_Confirmation::META_KEY, true)) {
     228                    $order->update_meta_data(Akkedli_Order_Confirmation::META_KEY, 'no');
     229                }
     230                $order->update_meta_data('_akkedli_verified_at', $verified_at);
     231                $order->update_meta_data('_akkedli_verification_result', $verification_result);
     232                if (!$order->save()) return $this->legacy_error('storage_failed', 503);
     233                $persisted = $this->reload_legacy_order($order_id);
     234                if (!$persisted || 'cancelled' !== $persisted->get_status()
     235                    || 'cancelled' !== $persisted->get_meta('_akkedli_verification_result', true)) {
     236                    return $this->legacy_error('storage_failed', 503);
     237                }
     238            } catch (Throwable $error) {
     239                return $this->legacy_error('storage_failed', 503);
     240            } finally {
     241                unset(self::$callback_orders[(string) $order_id]);
     242                Akkedli_V2_Callback_Claims::release_order($lock);
     243            }
    193244        } else {
    194             $order->set_status($wc_status);
    195             $order->add_order_note(sprintf(
    196                 /* translators: %1$s: verification result, %2$s: verification timestamp */
    197                 __('Order %1$s via Akkedli verification at %2$s', 'akkedli-connector'),
    198                 'cancelled',
    199                 $verified_at ? $verified_at : current_time('c')
    200             ));
    201             if ('yes' !== $order->get_meta(Akkedli_Order_Confirmation::META_KEY, true)) {
    202                 $order->update_meta_data(Akkedli_Order_Confirmation::META_KEY, 'no');
    203             }
    204             $order->update_meta_data('_akkedli_verified_at', $verified_at);
    205             $order->update_meta_data('_akkedli_verification_result', $verification_result);
    206             $order->save();
     245            return $this->legacy_error('invalid_operation', 400);
    207246        }
    208247
    … …  
    215254            'message' => __('Order status updated successfully', 'akkedli-connector')
    216255        );
     256    }
     257
     258    /** Authenticated, versioned receipts distinguish application from HTTP acceptance. */
     259    public function legacy_order_operation(WP_REST_Request $request)
     260    {
     261        $operation = $request->get_param('operation');
     262        if ('capabilities' === $operation) return array('success' => true, 'protocol' => 1,
     263            'operations' => array('verified', 'cancelled', 'request_help'));
     264        $id = $request->get_param('order_id');
     265        $callback_id = $request->get_param('callback_id');
     266        $revision = $request->get_param('revision');
     267        $number = $request->get_param('order_number');
     268        if (!is_string($id) || !preg_match('/^[1-9][0-9]{0,15}$/D', $id)
     269            || (float) $id > PHP_INT_MAX || !is_int($revision) || $revision < 1
     270            || !is_string($number) || '' === $number
     271            || !is_string($callback_id) || !preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/Di', $callback_id)
     272            || !in_array($operation, array('verified', 'cancelled', 'request_help'), true)) {
     273            return $this->legacy_error('invalid_operation', 400);
     274        }
     275        global $wpdb;
     276        $mutex = 'akkedli_legacy_' . hash('sha256', (defined('DB_NAME') ? DB_NAME : '') . ':' . $wpdb->prefix . ':' . $id);
     277        $mutex = substr($mutex, 0, 64);
     278        if ('1' !== (string) $wpdb->get_var($wpdb->prepare('SELECT GET_LOCK(%s,0)', $mutex))) {
     279            return $this->legacy_error('callback_busy', 409);
     280        }
     281        $lock = Akkedli_V2_Callback_Claims::acquire_order($id, time());
     282        if (!is_array($lock)) {
     283            $wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $mutex));
     284            return $this->legacy_error('callback_busy', 409);
     285        }
     286        $transaction = false;
     287        try {
     288            $hpos = class_exists('Automattic\\WooCommerce\\Utilities\\OrderUtil')
     289                && \Automattic\WooCommerce\Utilities\OrderUtil::custom_orders_table_usage_is_enabled();
     290            $table = $hpos ? $wpdb->prefix . 'wc_orders' : $wpdb->posts;
     291            foreach (array($table, $hpos ? $wpdb->prefix . 'wc_orders_meta' : $wpdb->prefix . 'postmeta',
     292                $wpdb->prefix . 'comments') as $required_table) {
     293                $engine = $wpdb->get_var($wpdb->prepare(
     294                    'SELECT ENGINE FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=%s', $required_table));
     295                if ('innodb' !== strtolower((string) $engine)) return $this->legacy_error('transactional_storage_required', 503);
     296            }
     297            if (false === $wpdb->query('START TRANSACTION')) return $this->legacy_error('storage_failed', 503);
     298            $transaction = true;
     299            $column = $hpos ? 'id' : 'ID';
     300            $status_column = $hpos ? 'status' : 'post_status';
     301            $stored_status = $wpdb->get_var($wpdb->prepare("SELECT {$status_column} FROM {$table} WHERE {$column}=%d FOR UPDATE", (int) $id));
     302            if (null === $stored_status) {
     303                return $this->legacy_error('order_not_found', 404);
     304            }
     305            $order = $this->reload_legacy_order((int) $id);
     306            if (!$order || (string) $order->get_id() !== $id
     307                || (string) $order->get_order_number() !== $number) return $this->legacy_error('order_identity_mismatch', 409);
     308            if ($order->get_status() !== preg_replace('/^wc-/', '', $stored_status)) return $this->legacy_error('storage_failed', 503);
     309            if (method_exists($order, 'read_meta_data')) $order->read_meta_data(true);
     310            if ('absent' !== Akkedli_V2_Outbox::order_provenance((int) $id)) {
     311                return $this->legacy_error('transition_protected', 409);
     312            }
     313            $previous = $this->legacy_meta_from_database((int) $id, '_akkedli_legacy_callback', $hpos);
     314            if (is_array($previous) && isset($previous['revision']) && $previous['revision'] >= $revision) {
     315                $same = $previous['revision'] === $revision && isset($previous['id'], $previous['operation'])
     316                    && $previous['id'] === $callback_id && $previous['operation'] === $operation;
     317                if (false === $wpdb->query('COMMIT')) return $this->legacy_error('storage_failed', 503);
     318                $transaction = false;
     319                return $this->legacy_receipt($callback_id, $revision, $operation,
     320                    $same ? ('protected' === $previous['outcome'] ? 'protected' : 'duplicate') : 'stale');
     321            }
     322            $current = $order->get_status();
     323            $eligible = in_array($current, array('pending', 'processing', 'on-hold'), true);
     324            $outcome = $eligible ? 'applied' : 'protected';
     325            self::$callback_orders[$id] = true;
     326            if ($eligible) {
     327                if ('verified' === $operation) {
     328                    if (!Akkedli_Order_Confirmation::mark_confirmed($order)) return $this->legacy_error('storage_failed', 503);
     329                } elseif ('request_help' === $operation) {
     330                    $order->set_status('on-hold');
     331                } else {
     332                    $order->set_status('cancelled');
     333                    if ('yes' !== $order->get_meta(Akkedli_Order_Confirmation::META_KEY, true))
     334                        $order->update_meta_data(Akkedli_Order_Confirmation::META_KEY, 'no');
     335                    $order->update_meta_data('_akkedli_verification_result', 'cancelled');
     336                }
     337            }
     338            $record = array('id' => $callback_id, 'revision' => $revision, 'operation' => $operation, 'outcome' => $outcome);
     339            $order->update_meta_data('_akkedli_legacy_callback', $record);
     340            if (!$order->save()) return $this->legacy_error('storage_failed', 503);
     341            $expected = !$eligible || 'verified' === $operation ? $current
     342                : ('request_help' === $operation ? 'on-hold' : 'cancelled');
     343            $saved_status = $wpdb->get_var($wpdb->prepare("SELECT {$status_column} FROM {$table} WHERE {$column}=%d", (int) $id));
     344            if ('wc-' . $expected !== $saved_status
     345                || $this->legacy_meta_from_database((int) $id, '_akkedli_legacy_callback', $hpos) !== $record
     346                || ($eligible && 'verified' === $operation
     347                    && 'yes' !== $this->legacy_meta_from_database((int) $id, Akkedli_Order_Confirmation::META_KEY, $hpos))) {
     348                return $this->legacy_error('storage_failed', 503);
     349            }
     350            if (false === $wpdb->query('COMMIT')) return $this->legacy_error('storage_failed', 503);
     351            $transaction = false;
     352            return $this->legacy_receipt($callback_id, $revision, $operation, $outcome);
     353        } catch (Throwable $error) {
     354            return $this->legacy_error('storage_failed', 503);
     355        } finally {
     356            if ($transaction) $wpdb->query('ROLLBACK');
     357            unset(self::$callback_orders[$id]);
     358            Akkedli_V2_Callback_Claims::release_order($lock);
     359            $wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $mutex));
     360        }
     361    }
     362
     363    private function legacy_error($code, $status)
     364    {
     365        return new WP_Error('akkedli_legacy_' . $code, __('Order operation could not be applied.', 'akkedli-connector'), array('status' => $status));
     366    }
     367
     368    private function legacy_meta_from_database($id, $key, $hpos)
     369    {
     370        global $wpdb;
     371        $table = $wpdb->prefix . ($hpos ? 'wc_orders_meta' : 'postmeta');
     372        $owner = $hpos ? 'order_id' : 'post_id';
     373        $primary = $hpos ? 'id' : 'meta_id';
     374        return maybe_unserialize($wpdb->get_var($wpdb->prepare(
     375            "SELECT meta_value FROM {$table} WHERE {$owner}=%d AND meta_key=%s ORDER BY {$primary} DESC LIMIT 1", $id, $key)));
     376    }
     377
     378    private function legacy_receipt($id, $revision, $operation, $outcome)
     379    {
     380        return array('success' => true, 'protocol' => 1, 'callback_id' => $id,
     381            'revision' => $revision, 'operation' => $operation, 'outcome' => $outcome);
     382    }
     383
     384    private function reload_legacy_order($id)
     385    {
     386        $cache_class = 'Automattic\\WooCommerce\\Caches\\OrderCache';
     387        if (class_exists($cache_class) && function_exists('wc_get_container')) {
     388            wc_get_container()->get($cache_class)->remove($id);
     389        }
     390        clean_post_cache($id);
     391        return wc_get_order($id);
    217392    }
    218393
  • akkedli-connector/tags/1.2.0/includes/class-akkedli-legacy-connection-admin.php

    r3624443 r3777357  
    8484            exit;
    8585        }
    86         update_option('akkedli_api_key', $api_key);
    87         update_option('akkedli_webhook_url', $webhook_url);
    88         update_option('akkedli_api_key_validated', 1);
    89         update_option('akkedli_connected_at', current_time('mysql'));
    90         update_option('akkedli_store_domain', home_url());
     86        $values = array('akkedli_api_key' => $api_key, 'akkedli_webhook_url' => $webhook_url,
     87            'akkedli_connected_at' => current_time('mysql'), 'akkedli_store_domain' => home_url());
     88        if (!self::persist($values)) {
     89            unset($_POST['api_key'], $api_key);
     90            wp_safe_redirect(admin_url('admin.php?page=akkedli-connector&error=persistence_failed'));
     91            exit;
     92        }
    9193        unset($_POST['api_key']);
    9294        $api_key = '';
    … …  
    104106    }
    105107
     108    public static function persist(array $values)
     109    {
     110        global $wpdb;
     111        $suppressed = $wpdb->suppress_errors(true);
     112        try {
     113            update_option('akkedli_api_key_validated', 0);
     114            foreach ($values as $name => $value) update_option($name, $value);
     115            foreach ($values as $name => $value) {
     116                $stored = $wpdb->get_var($wpdb->prepare("SELECT option_value FROM {$wpdb->options} WHERE option_name=%s", $name));
     117                if (!is_string($stored) || !hash_equals((string) $value, $stored)) return false;
     118            }
     119            $legacy = Akkedli_Legacy_Connection_State::load();
     120            if (null !== $legacy) {
     121                if (!is_array($legacy) || 'disconnected' !== $legacy['status']) return false;
     122                $raw = Akkedli_Legacy_Connection_State::raw();
     123                if (1 !== $wpdb->query($wpdb->prepare("DELETE FROM {$wpdb->options} WHERE option_name=%s AND option_value=%s",
     124                    Akkedli_Legacy_Connection_State::OPTION, $raw))) return false;
     125                wp_cache_delete(Akkedli_Legacy_Connection_State::OPTION, 'options');
     126            }
     127            update_option('akkedli_api_key_validated', 1);
     128            return '1' === (string) $wpdb->get_var($wpdb->prepare(
     129                "SELECT option_value FROM {$wpdb->options} WHERE option_name=%s", 'akkedli_api_key_validated'));
     130        } finally {
     131            $wpdb->suppress_errors($suppressed);
     132        }
     133    }
     134
    106135    private static function is_request()
    107136    {
  • akkedli-connector/tags/1.2.0/includes/class-akkedli-order-handler.php

    r3624443 r3777357  
    4545    private function dispatch_new_order($order_id, $order, $store_api)
    4646    {
     47        if (Akkedli_API_Handler::is_callback_order($order_id)) return;
    4748        $selection = Akkedli_Outbound_Selector::order($order_id);
    4849        if (Akkedli_Outbound_Selector::is_v2($selection)) {
    … …  
    106107    public function handle_order_processing($order_id, $order = null)
    107108    {
     109        if (Akkedli_API_Handler::is_callback_order($order_id)) return;
    108110        $legacy = Akkedli_Outbound_Selector::legacy(
    109111            Akkedli_Outbound_Selector::processing($order_id)
    … …  
    153155    public function handle_status_change($order_id, $old_status, $new_status, $order)
    154156    {
     157        if (Akkedli_API_Handler::is_callback_order($order_id)) return;
    155158        $selection = Akkedli_Outbound_Selector::status($order_id, $old_status, $new_status);
    156159        if (Akkedli_Outbound_Selector::is_v2($selection)) {
  • akkedli-connector/tags/1.2.0/includes/class-akkedli-outbound-selector.php

    r3624443 r3777357  
    3333    {
    3434        $state = Akkedli_Connector_State::load();
     35        $session = Akkedli_V2_Session_State::load();
     36        $legacy_session = is_array($session) && isset($session['mode']) && 'legacy' === $session['mode'];
    3537        if (null !== $state || Akkedli_Connector_State::exists()
    36             || Akkedli_V2_Session_State::exists()) {
     38            || (Akkedli_V2_Session_State::exists() && !$legacy_session)) {
    3739            unset($state);
    3840            return array('route' => 'v2');
    … …  
    4042        if ('absent' !== Akkedli_V2_Outbox::order_provenance($order_id))
    4143            return array('route' => 'v2');
     44        $legacy = Akkedli_Legacy_Connection_State::load();
     45        if (null !== $legacy) {
     46            $active = is_array($legacy) && in_array($legacy['status'], array('pending','connected'), true);
     47            return array('route' => 'legacy',
     48                'api_key' => $active && isset($legacy['credential']) ? $legacy['credential'] : '',
     49                'webhook_url' => $active && isset($legacy['webhook_url']) ? $legacy['webhook_url'] : '');
     50        }
    4251        return array(
    4352            'route' => 'legacy',
  • akkedli-connector/tags/1.2.0/includes/class-akkedli-settings.php

    r3624443 r3777357  
    9090            return '';
    9191        }
     92        if (null !== Akkedli_Legacy_Connection_State::load()) return Akkedli_Legacy_Connection_State::credential();
    9293        return get_option('akkedli_api_key', '');
    9394    }
    … …  
    101102            return '';
    102103        }
     104        $legacy = Akkedli_Legacy_Connection_State::load();
     105        if (null !== $legacy) return is_array($legacy) && isset($legacy['webhook_url']) ? $legacy['webhook_url'] : '';
    103106        return get_option('akkedli_webhook_url', '');
    104107    }
    … …  
    117120    public static function is_validated()
    118121    {
     122        $legacy = Akkedli_Legacy_Connection_State::load();
     123        if (null !== $legacy) return is_array($legacy) && 'connected' === $legacy['status'];
    119124        return (bool) get_option('akkedli_api_key_validated', false);
    120125    }
  • akkedli-connector/tags/1.2.0/includes/class-akkedli-v2-bootstrap.php

    r3624443 r3777357  
    1919            'class-akkedli-v2-session-admin.php', 'class-akkedli-v2-session-proof-auth.php',
    2020            'class-akkedli-v2-session-proof.php',
     21            'class-akkedli-legacy-connection-state.php', 'class-akkedli-legacy-session.php',
    2122            'class-akkedli-v2-callback-auth.php', 'class-akkedli-v2-host-binding-auth.php',
    2223            'class-akkedli-v2-host-binding.php', 'class-akkedli-v2-callback-claims.php',
    … …  
    4243        new Akkedli_V2_Enrollment();
    4344        new Akkedli_V2_Session_Admin();
     45        new Akkedli_Legacy_Session();
    4446        new Akkedli_V2_Callback();
    4547        $poller = new Akkedli_V2_Binding_Poller();
  • akkedli-connector/tags/1.2.0/includes/class-akkedli-v2-session-admin.php

    r3624443 r3777357  
    2121    {
    2222        $stored = Akkedli_V2_Session_State::load();
     23        if (is_array($stored) && isset($stored['mode']) && 'legacy' === $stored['mode']) return false;
    2324        if (is_array($stored)) return array('session' => $stored['session'],
    2425            'expected_store_url' => $stored['expected_store_url'], 'resuming' => true);
    … …  
    5657    {
    5758        $stored = Akkedli_V2_Session_State::load();
     59        if (is_array($stored) && isset($stored['mode']) && 'legacy' === $stored['mode']) return 'session_error';
    5860        if (is_array($stored)) {
    5961            if (strtotime($stored['expires_at']) <= time()) {
  • akkedli-connector/tags/1.2.0/includes/class-akkedli-v2-session-proof-auth.php

    r3624443 r3777357  
    1616        $state = Akkedli_V2_Session_State::load();
    1717        if (!is_array($state)) return false;
     18        if (!empty($state['revoke_requested'])) return $this->denied();
    1819        $now = call_user_func($this->clock);
    1920        if (!is_int($now) || strtotime($state['expires_at']) <= $now) return $this->denied();
  • akkedli-connector/tags/1.2.0/includes/class-akkedli-v2-session-state.php

    r3624443 r3777357  
    5959        $keys = array('session', 'bootstrap_secret', 'callback_url',
    6060            'expected_store_url', 'expires_at');
     61        if (isset($state['mode']) && 'legacy' === $state['mode']) {
     62            $keys[] = 'mode';
     63            if (isset($state['revoke_requested'])) {
     64                if (true !== $state['revoke_requested']) return false;
     65                $keys[] = 'revoke_requested';
     66            }
     67        }
    6168        if (!is_array($state) || !Akkedli_V2_Strict_JSON::exact_keys($state, $keys)
    6269            || !self::valid_uuid($state['session'])
  • akkedli-connector/tags/1.2.0/includes/class-akkedli-v2-session-transport.php

    r3624443 r3777357  
    77{
    88    const LIMIT = 4096;
     9    private $legacy;
     10    public function __construct($legacy = false) { $this->legacy = true === $legacy; }
    911    private static $paths = array(
     12        'revoke' => '/connection-session/revoke',
     13        'confirm' => '/connection-session/confirm',
     14        'cleanup' => '/connection-session/cleanup',
    1015        'claim' => '/connection-session/claim',
    1116        'status' => '/connection-session/status',
    … …  
    2934            'callback_url' => $state['callback_url']), $state['callback_url']);
    3035    }
     36    public function revoke(array $state) { return $this->operation('revoke', $state); }
     37    public function confirm(array $state) { return $this->operation('confirm', $state); }
     38    public function cleanup(array $state) { return $this->operation('cleanup', $state); }
     39    private function operation($operation, array $state) {
     40        return $this->request($operation, array('session' => $state['session'],
     41            'bootstrap_secret' => $state['bootstrap_secret'], 'callback_url' => $state['callback_url']), $state['callback_url']);
     42    }
    3143    private function request($operation, array $payload, $callback_url)
    3244    {
    33         $url = self::endpoint($operation);
     45        $url = self::endpoint($operation, $this->legacy);
    3446        $session = $payload['session']; $body = wp_json_encode($payload, JSON_UNESCAPED_SLASHES);
    3547        unset($payload);
    … …  
    5971        if (!is_string($type) || !preg_match('/^application\/json(?:;\s*charset=utf-8)?$/Di', $type)
    6072            || !is_string($raw) || strlen($raw) >= self::LIMIT) return array('kind' => 'ambiguous');
     73        if ($this->legacy) return Akkedli_Legacy_Connection_State::parse_response($raw, $operation);
    6174        if ('exchange' === $operation) {
    6275            $value = Akkedli_V2_Credential_Response::parse($raw, $callback_url, self::LIMIT);
    … …  
    8396        return array('kind' => 'retry', 'retry_after' => $delay);
    8497    }
    85     private static function endpoint($operation)
     98    private static function endpoint($operation, $legacy = false)
    8699    {
    87100        if (!isset(self::$paths[$operation])) return false;
    88101        $suffix = '/api/connectors/woocommerce/enroll';
    89102        if (substr(Akkedli_V2_Enrollment::URL, -strlen($suffix)) !== $suffix) return false;
    90         return substr(Akkedli_V2_Enrollment::URL, 0, -strlen('/enroll')) . self::$paths[$operation];
     103        $path = $legacy ? str_replace('/connection-session/', '/legacy-session/', self::$paths[$operation]) : self::$paths[$operation];
     104        return substr(Akkedli_V2_Enrollment::URL, 0, -strlen('/enroll')) . $path;
    91105    }
    92106}
  • akkedli-connector/tags/1.2.0/languages/akkedli-connector.pot

    r3624443 r3777357  
    55msgid ""
    66msgstr ""
    7 "Project-Id-Version: Akkedli Connector 1.1.4\n"
     7"Project-Id-Version: Akkedli Connector 1.2.0\n"
    88"Report-Msgid-Bugs-To: https://akkedli.com/support\n"
    99"POT-Creation-Date: 2026-02-01 23:25+0000\n"
  • akkedli-connector/tags/1.2.0/readme.txt

    r3624443 r3777357  
    55Tested up to: 7.0
    66Requires PHP: 7.4
    7 Stable tag: 1.1.4
     7Stable tag: 1.2.0
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    135135== Changelog ==
    136136
     137= 1.2.0 =
     138* Add explicit Legacy code-free WordPress approval, durable verification, and generation-scoped disconnect/reconnection.
     139* Preserve Legacy callback reliability and Classic/HPOS behavior.
     140* Verify manual connection persistence before reporting success.
     141
    137142= 1.1.4 - 2026-07-26 =
    138143* Security: Keep callback replay tombstones beyond the complete Backend retry horizon
  • akkedli-connector/tags/1.2.0/templates/admin-settings.php

    r3624443 r3777357  
    99
    1010$akkedli_is_configured = Akkedli_Settings::is_configured();
     11$akkedli_is_connected = $akkedli_is_configured && Akkedli_Settings::is_validated();
    1112$akkedli_v2_status = Akkedli_Connector_State::status();
    1213// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Display-only URL param from our own redirect.
    … …  
    2223    </h1>
    2324
    24     <?php if (false !== Akkedli_V2_Session_Admin::view()): ?>
     25    <?php if (false !== Akkedli_Legacy_Session::view()): ?>
     26        <?php include AKKEDLI_PLUGIN_PATH . 'templates/admin-legacy-session.php'; ?>
     27    <?php elseif (false !== Akkedli_V2_Session_Admin::view()): ?>
    2528        <?php include AKKEDLI_PLUGIN_PATH . 'templates/admin-v2-session.php'; ?>
    2629    <?php elseif ('absent' !== $akkedli_v2_status || !$akkedli_is_configured): ?>
    … …  
    2932
    3033    <?php if ('absent' === $akkedli_v2_status): ?>
    31     <?php if ($akkedli_just_connected): ?>
     34    <?php if ($akkedli_just_connected && $akkedli_is_connected): ?>
    3235        <div class="notice notice-success inline" style="border-left-color: #10b981;">
    3336            <p>
    … …  
    4346            </p>
    4447        </div>
    45     <?php elseif ($akkedli_is_configured): ?>
     48    <?php elseif ($akkedli_is_connected): ?>
    4649        <div class="notice notice-success inline">
    4750            <p>
    4851                <strong><?php esc_html_e('Connected', 'akkedli-connector'); ?></strong> —
    4952                <?php esc_html_e('Orders will be sent to Akkedli automatically.', 'akkedli-connector'); ?>
     53            </p>
     54        </div>
     55    <?php elseif ($akkedli_is_configured): ?>
     56        <div class="notice notice-info inline" role="status">
     57            <p>
     58                <strong><?php esc_html_e('Verifying connection', 'akkedli-connector'); ?></strong> —
     59                <?php esc_html_e('Connection settings are saved. Waiting for Akkedli to confirm the connection.', 'akkedli-connector'); ?>
    5060            </p>
    5161        </div>
  • akkedli-connector/trunk/akkedli-connector.php

    r3624443 r3777357  
    44 * Plugin URI: https://akkedli.com
    55 * Description: Connect your WooCommerce store to Akkedli for automated order verification via WhatsApp.
    6  * Version: 1.1.4
     6 * Version: 1.2.0
    77 * Author: Akkedli
    88 * Author URI: https://akkedli.com
    … …  
    2525// Define plugin constants
    2626if (!defined('AKKEDLI_VERSION')) {
    27     define('AKKEDLI_VERSION', '1.1.4');
     27    define('AKKEDLI_VERSION', '1.2.0');
    2828}
    2929if (!defined('AKKEDLI_PLUGIN_URL')) {
  • akkedli-connector/trunk/includes/class-akkedli-api-handler.php

    r3624443 r3777357  
    1313class Akkedli_API_Handler
    1414{
     15    private static $callback_orders = array();
     16
     17    public static function is_callback_order($order_id)
     18    {
     19        return isset(self::$callback_orders[(string) $order_id]);
     20    }
    1521
    1622    public function __construct()
    … …  
    2733            'methods' => 'POST',
    2834            'callback' => array($this, 'update_order_status'),
     35            'permission_callback' => array($this, 'verify_api_key'),
     36        ));
     37
     38        register_rest_route('akkedli/v1', '/legacy-order-operation', array(
     39            'methods' => 'POST',
     40            'callback' => array($this, 'legacy_order_operation'),
    2941            'permission_callback' => array($this, 'verify_api_key'),
    3042        ));
    … …  
    191203            }
    192204            $wc_status = $old_status;
     205        } elseif ('cancelled' === $verification_result && 'cancelled' === $wc_status) {
     206            $lock = Akkedli_V2_Callback_Claims::acquire_order((string) $order_id, time());
     207            if (!is_array($lock)) return $this->legacy_error('callback_busy', 409);
     208            try {
     209                $order = wc_get_order($order_id);
     210                $old_status = $order->get_status();
     211                if ('cancelled' === $old_status) {
     212                    return array('success' => true, 'order_id' => $order_id,
     213                        'old_status' => $old_status, 'new_status' => $old_status,
     214                        'verification_result' => $verification_result);
     215                }
     216                if (!in_array($old_status, array('pending', 'processing', 'on-hold'), true)) {
     217                    return $this->legacy_error('transition_protected', 409);
     218                }
     219                self::$callback_orders[(string) $order_id] = true;
     220                $order->set_status('cancelled');
     221                $order->add_order_note(sprintf(
     222                    /* translators: %1$s: verification result, %2$s: verification timestamp */
     223                    __('Order %1$s via Akkedli verification at %2$s', 'akkedli-connector'),
     224                    'cancelled',
     225                    $verified_at ? $verified_at : current_time('c')
     226                ));
     227                if ('yes' !== $order->get_meta(Akkedli_Order_Confirmation::META_KEY, true)) {
     228                    $order->update_meta_data(Akkedli_Order_Confirmation::META_KEY, 'no');
     229                }
     230                $order->update_meta_data('_akkedli_verified_at', $verified_at);
     231                $order->update_meta_data('_akkedli_verification_result', $verification_result);
     232                if (!$order->save()) return $this->legacy_error('storage_failed', 503);
     233                $persisted = $this->reload_legacy_order($order_id);
     234                if (!$persisted || 'cancelled' !== $persisted->get_status()
     235                    || 'cancelled' !== $persisted->get_meta('_akkedli_verification_result', true)) {
     236                    return $this->legacy_error('storage_failed', 503);
     237                }
     238            } catch (Throwable $error) {
     239                return $this->legacy_error('storage_failed', 503);
     240            } finally {
     241                unset(self::$callback_orders[(string) $order_id]);
     242                Akkedli_V2_Callback_Claims::release_order($lock);
     243            }
    193244        } else {
    194             $order->set_status($wc_status);
    195             $order->add_order_note(sprintf(
    196                 /* translators: %1$s: verification result, %2$s: verification timestamp */
    197                 __('Order %1$s via Akkedli verification at %2$s', 'akkedli-connector'),
    198                 'cancelled',
    199                 $verified_at ? $verified_at : current_time('c')
    200             ));
    201             if ('yes' !== $order->get_meta(Akkedli_Order_Confirmation::META_KEY, true)) {
    202                 $order->update_meta_data(Akkedli_Order_Confirmation::META_KEY, 'no');
    203             }
    204             $order->update_meta_data('_akkedli_verified_at', $verified_at);
    205             $order->update_meta_data('_akkedli_verification_result', $verification_result);
    206             $order->save();
     245            return $this->legacy_error('invalid_operation', 400);
    207246        }
    208247
    … …  
    215254            'message' => __('Order status updated successfully', 'akkedli-connector')
    216255        );
     256    }
     257
     258    /** Authenticated, versioned receipts distinguish application from HTTP acceptance. */
     259    public function legacy_order_operation(WP_REST_Request $request)
     260    {
     261        $operation = $request->get_param('operation');
     262        if ('capabilities' === $operation) return array('success' => true, 'protocol' => 1,
     263            'operations' => array('verified', 'cancelled', 'request_help'));
     264        $id = $request->get_param('order_id');
     265        $callback_id = $request->get_param('callback_id');
     266        $revision = $request->get_param('revision');
     267        $number = $request->get_param('order_number');
     268        if (!is_string($id) || !preg_match('/^[1-9][0-9]{0,15}$/D', $id)
     269            || (float) $id > PHP_INT_MAX || !is_int($revision) || $revision < 1
     270            || !is_string($number) || '' === $number
     271            || !is_string($callback_id) || !preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/Di', $callback_id)
     272            || !in_array($operation, array('verified', 'cancelled', 'request_help'), true)) {
     273            return $this->legacy_error('invalid_operation', 400);
     274        }
     275        global $wpdb;
     276        $mutex = 'akkedli_legacy_' . hash('sha256', (defined('DB_NAME') ? DB_NAME : '') . ':' . $wpdb->prefix . ':' . $id);
     277        $mutex = substr($mutex, 0, 64);
     278        if ('1' !== (string) $wpdb->get_var($wpdb->prepare('SELECT GET_LOCK(%s,0)', $mutex))) {
     279            return $this->legacy_error('callback_busy', 409);
     280        }
     281        $lock = Akkedli_V2_Callback_Claims::acquire_order($id, time());
     282        if (!is_array($lock)) {
     283            $wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $mutex));
     284            return $this->legacy_error('callback_busy', 409);
     285        }
     286        $transaction = false;
     287        try {
     288            $hpos = class_exists('Automattic\\WooCommerce\\Utilities\\OrderUtil')
     289                && \Automattic\WooCommerce\Utilities\OrderUtil::custom_orders_table_usage_is_enabled();
     290            $table = $hpos ? $wpdb->prefix . 'wc_orders' : $wpdb->posts;
     291            foreach (array($table, $hpos ? $wpdb->prefix . 'wc_orders_meta' : $wpdb->prefix . 'postmeta',
     292                $wpdb->prefix . 'comments') as $required_table) {
     293                $engine = $wpdb->get_var($wpdb->prepare(
     294                    'SELECT ENGINE FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=%s', $required_table));
     295                if ('innodb' !== strtolower((string) $engine)) return $this->legacy_error('transactional_storage_required', 503);
     296            }
     297            if (false === $wpdb->query('START TRANSACTION')) return $this->legacy_error('storage_failed', 503);
     298            $transaction = true;
     299            $column = $hpos ? 'id' : 'ID';
     300            $status_column = $hpos ? 'status' : 'post_status';
     301            $stored_status = $wpdb->get_var($wpdb->prepare("SELECT {$status_column} FROM {$table} WHERE {$column}=%d FOR UPDATE", (int) $id));
     302            if (null === $stored_status) {
     303                return $this->legacy_error('order_not_found', 404);
     304            }
     305            $order = $this->reload_legacy_order((int) $id);
     306            if (!$order || (string) $order->get_id() !== $id
     307                || (string) $order->get_order_number() !== $number) return $this->legacy_error('order_identity_mismatch', 409);
     308            if ($order->get_status() !== preg_replace('/^wc-/', '', $stored_status)) return $this->legacy_error('storage_failed', 503);
     309            if (method_exists($order, 'read_meta_data')) $order->read_meta_data(true);
     310            if ('absent' !== Akkedli_V2_Outbox::order_provenance((int) $id)) {
     311                return $this->legacy_error('transition_protected', 409);
     312            }
     313            $previous = $this->legacy_meta_from_database((int) $id, '_akkedli_legacy_callback', $hpos);
     314            if (is_array($previous) && isset($previous['revision']) && $previous['revision'] >= $revision) {
     315                $same = $previous['revision'] === $revision && isset($previous['id'], $previous['operation'])
     316                    && $previous['id'] === $callback_id && $previous['operation'] === $operation;
     317                if (false === $wpdb->query('COMMIT')) return $this->legacy_error('storage_failed', 503);
     318                $transaction = false;
     319                return $this->legacy_receipt($callback_id, $revision, $operation,
     320                    $same ? ('protected' === $previous['outcome'] ? 'protected' : 'duplicate') : 'stale');
     321            }
     322            $current = $order->get_status();
     323            $eligible = in_array($current, array('pending', 'processing', 'on-hold'), true);
     324            $outcome = $eligible ? 'applied' : 'protected';
     325            self::$callback_orders[$id] = true;
     326            if ($eligible) {
     327                if ('verified' === $operation) {
     328                    if (!Akkedli_Order_Confirmation::mark_confirmed($order)) return $this->legacy_error('storage_failed', 503);
     329                } elseif ('request_help' === $operation) {
     330                    $order->set_status('on-hold');
     331                } else {
     332                    $order->set_status('cancelled');
     333                    if ('yes' !== $order->get_meta(Akkedli_Order_Confirmation::META_KEY, true))
     334                        $order->update_meta_data(Akkedli_Order_Confirmation::META_KEY, 'no');
     335                    $order->update_meta_data('_akkedli_verification_result', 'cancelled');
     336                }
     337            }
     338            $record = array('id' => $callback_id, 'revision' => $revision, 'operation' => $operation, 'outcome' => $outcome);
     339            $order->update_meta_data('_akkedli_legacy_callback', $record);
     340            if (!$order->save()) return $this->legacy_error('storage_failed', 503);
     341            $expected = !$eligible || 'verified' === $operation ? $current
     342                : ('request_help' === $operation ? 'on-hold' : 'cancelled');
     343            $saved_status = $wpdb->get_var($wpdb->prepare("SELECT {$status_column} FROM {$table} WHERE {$column}=%d", (int) $id));
     344            if ('wc-' . $expected !== $saved_status
     345                || $this->legacy_meta_from_database((int) $id, '_akkedli_legacy_callback', $hpos) !== $record
     346                || ($eligible && 'verified' === $operation
     347                    && 'yes' !== $this->legacy_meta_from_database((int) $id, Akkedli_Order_Confirmation::META_KEY, $hpos))) {
     348                return $this->legacy_error('storage_failed', 503);
     349            }
     350            if (false === $wpdb->query('COMMIT')) return $this->legacy_error('storage_failed', 503);
     351            $transaction = false;
     352            return $this->legacy_receipt($callback_id, $revision, $operation, $outcome);
     353        } catch (Throwable $error) {
     354            return $this->legacy_error('storage_failed', 503);
     355        } finally {
     356            if ($transaction) $wpdb->query('ROLLBACK');
     357            unset(self::$callback_orders[$id]);
     358            Akkedli_V2_Callback_Claims::release_order($lock);
     359            $wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $mutex));
     360        }
     361    }
     362
     363    private function legacy_error($code, $status)
     364    {
     365        return new WP_Error('akkedli_legacy_' . $code, __('Order operation could not be applied.', 'akkedli-connector'), array('status' => $status));
     366    }
     367
     368    private function legacy_meta_from_database($id, $key, $hpos)
     369    {
     370        global $wpdb;
     371        $table = $wpdb->prefix . ($hpos ? 'wc_orders_meta' : 'postmeta');
     372        $owner = $hpos ? 'order_id' : 'post_id';
     373        $primary = $hpos ? 'id' : 'meta_id';
     374        return maybe_unserialize($wpdb->get_var($wpdb->prepare(
     375            "SELECT meta_value FROM {$table} WHERE {$owner}=%d AND meta_key=%s ORDER BY {$primary} DESC LIMIT 1", $id, $key)));
     376    }
     377
     378    private function legacy_receipt($id, $revision, $operation, $outcome)
     379    {
     380        return array('success' => true, 'protocol' => 1, 'callback_id' => $id,
     381            'revision' => $revision, 'operation' => $operation, 'outcome' => $outcome);
     382    }
     383
     384    private function reload_legacy_order($id)
     385    {
     386        $cache_class = 'Automattic\\WooCommerce\\Caches\\OrderCache';
     387        if (class_exists($cache_class) && function_exists('wc_get_container')) {
     388            wc_get_container()->get($cache_class)->remove($id);
     389        }
     390        clean_post_cache($id);
     391        return wc_get_order($id);
    217392    }
    218393
  • akkedli-connector/trunk/includes/class-akkedli-legacy-connection-admin.php

    r3624443 r3777357  
    8484            exit;
    8585        }
    86         update_option('akkedli_api_key', $api_key);
    87         update_option('akkedli_webhook_url', $webhook_url);
    88         update_option('akkedli_api_key_validated', 1);
    89         update_option('akkedli_connected_at', current_time('mysql'));
    90         update_option('akkedli_store_domain', home_url());
     86        $values = array('akkedli_api_key' => $api_key, 'akkedli_webhook_url' => $webhook_url,
     87            'akkedli_connected_at' => current_time('mysql'), 'akkedli_store_domain' => home_url());
     88        if (!self::persist($values)) {
     89            unset($_POST['api_key'], $api_key);
     90            wp_safe_redirect(admin_url('admin.php?page=akkedli-connector&error=persistence_failed'));
     91            exit;
     92        }
    9193        unset($_POST['api_key']);
    9294        $api_key = '';
    … …  
    104106    }
    105107
     108    public static function persist(array $values)
     109    {
     110        global $wpdb;
     111        $suppressed = $wpdb->suppress_errors(true);
     112        try {
     113            update_option('akkedli_api_key_validated', 0);
     114            foreach ($values as $name => $value) update_option($name, $value);
     115            foreach ($values as $name => $value) {
     116                $stored = $wpdb->get_var($wpdb->prepare("SELECT option_value FROM {$wpdb->options} WHERE option_name=%s", $name));
     117                if (!is_string($stored) || !hash_equals((string) $value, $stored)) return false;
     118            }
     119            $legacy = Akkedli_Legacy_Connection_State::load();
     120            if (null !== $legacy) {
     121                if (!is_array($legacy) || 'disconnected' !== $legacy['status']) return false;
     122                $raw = Akkedli_Legacy_Connection_State::raw();
     123                if (1 !== $wpdb->query($wpdb->prepare("DELETE FROM {$wpdb->options} WHERE option_name=%s AND option_value=%s",
     124                    Akkedli_Legacy_Connection_State::OPTION, $raw))) return false;
     125                wp_cache_delete(Akkedli_Legacy_Connection_State::OPTION, 'options');
     126            }
     127            update_option('akkedli_api_key_validated', 1);
     128            return '1' === (string) $wpdb->get_var($wpdb->prepare(
     129                "SELECT option_value FROM {$wpdb->options} WHERE option_name=%s", 'akkedli_api_key_validated'));
     130        } finally {
     131            $wpdb->suppress_errors($suppressed);
     132        }
     133    }
     134
    106135    private static function is_request()
    107136    {
  • akkedli-connector/trunk/includes/class-akkedli-order-handler.php

    r3624443 r3777357  
    4545    private function dispatch_new_order($order_id, $order, $store_api)
    4646    {
     47        if (Akkedli_API_Handler::is_callback_order($order_id)) return;
    4748        $selection = Akkedli_Outbound_Selector::order($order_id);
    4849        if (Akkedli_Outbound_Selector::is_v2($selection)) {
    … …  
    106107    public function handle_order_processing($order_id, $order = null)
    107108    {
     109        if (Akkedli_API_Handler::is_callback_order($order_id)) return;
    108110        $legacy = Akkedli_Outbound_Selector::legacy(
    109111            Akkedli_Outbound_Selector::processing($order_id)
    … …  
    153155    public function handle_status_change($order_id, $old_status, $new_status, $order)
    154156    {
     157        if (Akkedli_API_Handler::is_callback_order($order_id)) return;
    155158        $selection = Akkedli_Outbound_Selector::status($order_id, $old_status, $new_status);
    156159        if (Akkedli_Outbound_Selector::is_v2($selection)) {
  • akkedli-connector/trunk/includes/class-akkedli-outbound-selector.php

    r3624443 r3777357  
    3333    {
    3434        $state = Akkedli_Connector_State::load();
     35        $session = Akkedli_V2_Session_State::load();
     36        $legacy_session = is_array($session) && isset($session['mode']) && 'legacy' === $session['mode'];
    3537        if (null !== $state || Akkedli_Connector_State::exists()
    36             || Akkedli_V2_Session_State::exists()) {
     38            || (Akkedli_V2_Session_State::exists() && !$legacy_session)) {
    3739            unset($state);
    3840            return array('route' => 'v2');
    … …  
    4042        if ('absent' !== Akkedli_V2_Outbox::order_provenance($order_id))
    4143            return array('route' => 'v2');
     44        $legacy = Akkedli_Legacy_Connection_State::load();
     45        if (null !== $legacy) {
     46            $active = is_array($legacy) && in_array($legacy['status'], array('pending','connected'), true);
     47            return array('route' => 'legacy',
     48                'api_key' => $active && isset($legacy['credential']) ? $legacy['credential'] : '',
     49                'webhook_url' => $active && isset($legacy['webhook_url']) ? $legacy['webhook_url'] : '');
     50        }
    4251        return array(
    4352            'route' => 'legacy',
  • akkedli-connector/trunk/includes/class-akkedli-settings.php

    r3624443 r3777357  
    9090            return '';
    9191        }
     92        if (null !== Akkedli_Legacy_Connection_State::load()) return Akkedli_Legacy_Connection_State::credential();
    9293        return get_option('akkedli_api_key', '');
    9394    }
    … …  
    101102            return '';
    102103        }
     104        $legacy = Akkedli_Legacy_Connection_State::load();
     105        if (null !== $legacy) return is_array($legacy) && isset($legacy['webhook_url']) ? $legacy['webhook_url'] : '';
    103106        return get_option('akkedli_webhook_url', '');
    104107    }
    … …  
    117120    public static function is_validated()
    118121    {
     122        $legacy = Akkedli_Legacy_Connection_State::load();
     123        if (null !== $legacy) return is_array($legacy) && 'connected' === $legacy['status'];
    119124        return (bool) get_option('akkedli_api_key_validated', false);
    120125    }
  • akkedli-connector/trunk/includes/class-akkedli-v2-bootstrap.php

    r3624443 r3777357  
    1919            'class-akkedli-v2-session-admin.php', 'class-akkedli-v2-session-proof-auth.php',
    2020            'class-akkedli-v2-session-proof.php',
     21            'class-akkedli-legacy-connection-state.php', 'class-akkedli-legacy-session.php',
    2122            'class-akkedli-v2-callback-auth.php', 'class-akkedli-v2-host-binding-auth.php',
    2223            'class-akkedli-v2-host-binding.php', 'class-akkedli-v2-callback-claims.php',
    … …  
    4243        new Akkedli_V2_Enrollment();
    4344        new Akkedli_V2_Session_Admin();
     45        new Akkedli_Legacy_Session();
    4446        new Akkedli_V2_Callback();
    4547        $poller = new Akkedli_V2_Binding_Poller();
  • akkedli-connector/trunk/includes/class-akkedli-v2-session-admin.php

    r3624443 r3777357  
    2121    {
    2222        $stored = Akkedli_V2_Session_State::load();
     23        if (is_array($stored) && isset($stored['mode']) && 'legacy' === $stored['mode']) return false;
    2324        if (is_array($stored)) return array('session' => $stored['session'],
    2425            'expected_store_url' => $stored['expected_store_url'], 'resuming' => true);
    … …  
    5657    {
    5758        $stored = Akkedli_V2_Session_State::load();
     59        if (is_array($stored) && isset($stored['mode']) && 'legacy' === $stored['mode']) return 'session_error';
    5860        if (is_array($stored)) {
    5961            if (strtotime($stored['expires_at']) <= time()) {
  • akkedli-connector/trunk/includes/class-akkedli-v2-session-proof-auth.php

    r3624443 r3777357  
    1616        $state = Akkedli_V2_Session_State::load();
    1717        if (!is_array($state)) return false;
     18        if (!empty($state['revoke_requested'])) return $this->denied();
    1819        $now = call_user_func($this->clock);
    1920        if (!is_int($now) || strtotime($state['expires_at']) <= $now) return $this->denied();
  • akkedli-connector/trunk/includes/class-akkedli-v2-session-state.php

    r3624443 r3777357  
    5959        $keys = array('session', 'bootstrap_secret', 'callback_url',
    6060            'expected_store_url', 'expires_at');
     61        if (isset($state['mode']) && 'legacy' === $state['mode']) {
     62            $keys[] = 'mode';
     63            if (isset($state['revoke_requested'])) {
     64                if (true !== $state['revoke_requested']) return false;
     65                $keys[] = 'revoke_requested';
     66            }
     67        }
    6168        if (!is_array($state) || !Akkedli_V2_Strict_JSON::exact_keys($state, $keys)
    6269            || !self::valid_uuid($state['session'])
  • akkedli-connector/trunk/includes/class-akkedli-v2-session-transport.php

    r3624443 r3777357  
    77{
    88    const LIMIT = 4096;
     9    private $legacy;
     10    public function __construct($legacy = false) { $this->legacy = true === $legacy; }
    911    private static $paths = array(
     12        'revoke' => '/connection-session/revoke',
     13        'confirm' => '/connection-session/confirm',
     14        'cleanup' => '/connection-session/cleanup',
    1015        'claim' => '/connection-session/claim',
    1116        'status' => '/connection-session/status',
    … …  
    2934            'callback_url' => $state['callback_url']), $state['callback_url']);
    3035    }
     36    public function revoke(array $state) { return $this->operation('revoke', $state); }
     37    public function confirm(array $state) { return $this->operation('confirm', $state); }
     38    public function cleanup(array $state) { return $this->operation('cleanup', $state); }
     39    private function operation($operation, array $state) {
     40        return $this->request($operation, array('session' => $state['session'],
     41            'bootstrap_secret' => $state['bootstrap_secret'], 'callback_url' => $state['callback_url']), $state['callback_url']);
     42    }
    3143    private function request($operation, array $payload, $callback_url)
    3244    {
    33         $url = self::endpoint($operation);
     45        $url = self::endpoint($operation, $this->legacy);
    3446        $session = $payload['session']; $body = wp_json_encode($payload, JSON_UNESCAPED_SLASHES);
    3547        unset($payload);
    … …  
    5971        if (!is_string($type) || !preg_match('/^application\/json(?:;\s*charset=utf-8)?$/Di', $type)
    6072            || !is_string($raw) || strlen($raw) >= self::LIMIT) return array('kind' => 'ambiguous');
     73        if ($this->legacy) return Akkedli_Legacy_Connection_State::parse_response($raw, $operation);
    6174        if ('exchange' === $operation) {
    6275            $value = Akkedli_V2_Credential_Response::parse($raw, $callback_url, self::LIMIT);
    … …  
    8396        return array('kind' => 'retry', 'retry_after' => $delay);
    8497    }
    85     private static function endpoint($operation)
     98    private static function endpoint($operation, $legacy = false)
    8699    {
    87100        if (!isset(self::$paths[$operation])) return false;
    88101        $suffix = '/api/connectors/woocommerce/enroll';
    89102        if (substr(Akkedli_V2_Enrollment::URL, -strlen($suffix)) !== $suffix) return false;
    90         return substr(Akkedli_V2_Enrollment::URL, 0, -strlen('/enroll')) . self::$paths[$operation];
     103        $path = $legacy ? str_replace('/connection-session/', '/legacy-session/', self::$paths[$operation]) : self::$paths[$operation];
     104        return substr(Akkedli_V2_Enrollment::URL, 0, -strlen('/enroll')) . $path;
    91105    }
    92106}
  • akkedli-connector/trunk/languages/akkedli-connector.pot

    r3624443 r3777357  
    55msgid ""
    66msgstr ""
    7 "Project-Id-Version: Akkedli Connector 1.1.4\n"
     7"Project-Id-Version: Akkedli Connector 1.2.0\n"
    88"Report-Msgid-Bugs-To: https://akkedli.com/support\n"
    99"POT-Creation-Date: 2026-02-01 23:25+0000\n"
  • akkedli-connector/trunk/readme.txt

    r3624443 r3777357  
    55Tested up to: 7.0
    66Requires PHP: 7.4
    7 Stable tag: 1.1.4
     7Stable tag: 1.2.0
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    135135== Changelog ==
    136136
     137= 1.2.0 =
     138* Add explicit Legacy code-free WordPress approval, durable verification, and generation-scoped disconnect/reconnection.
     139* Preserve Legacy callback reliability and Classic/HPOS behavior.
     140* Verify manual connection persistence before reporting success.
     141
    137142= 1.1.4 - 2026-07-26 =
    138143* Security: Keep callback replay tombstones beyond the complete Backend retry horizon
  • akkedli-connector/trunk/templates/admin-settings.php

    r3624443 r3777357  
    99
    1010$akkedli_is_configured = Akkedli_Settings::is_configured();
     11$akkedli_is_connected = $akkedli_is_configured && Akkedli_Settings::is_validated();
    1112$akkedli_v2_status = Akkedli_Connector_State::status();
    1213// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Display-only URL param from our own redirect.
    … …  
    2223    </h1>
    2324
    24     <?php if (false !== Akkedli_V2_Session_Admin::view()): ?>
     25    <?php if (false !== Akkedli_Legacy_Session::view()): ?>
     26        <?php include AKKEDLI_PLUGIN_PATH . 'templates/admin-legacy-session.php'; ?>
     27    <?php elseif (false !== Akkedli_V2_Session_Admin::view()): ?>
    2528        <?php include AKKEDLI_PLUGIN_PATH . 'templates/admin-v2-session.php'; ?>
    2629    <?php elseif ('absent' !== $akkedli_v2_status || !$akkedli_is_configured): ?>
    … …  
    2932
    3033    <?php if ('absent' === $akkedli_v2_status): ?>
    31     <?php if ($akkedli_just_connected): ?>
     34    <?php if ($akkedli_just_connected && $akkedli_is_connected): ?>
    3235        <div class="notice notice-success inline" style="border-left-color: #10b981;">
    3336            <p>
    … …  
    4346            </p>
    4447        </div>
    45     <?php elseif ($akkedli_is_configured): ?>
     48    <?php elseif ($akkedli_is_connected): ?>
    4649        <div class="notice notice-success inline">
    4750            <p>
    4851                <strong><?php esc_html_e('Connected', 'akkedli-connector'); ?></strong> —
    4952                <?php esc_html_e('Orders will be sent to Akkedli automatically.', 'akkedli-connector'); ?>
     53            </p>
     54        </div>
     55    <?php elseif ($akkedli_is_configured): ?>
     56        <div class="notice notice-info inline" role="status">
     57            <p>
     58                <strong><?php esc_html_e('Verifying connection', 'akkedli-connector'); ?></strong> —
     59                <?php esc_html_e('Connection settings are saved. Waiting for Akkedli to confirm the connection.', 'akkedli-connector'); ?>
    5060            </p>
    5161        </div>
Note: See TracChangeset for help on using the changeset viewer.