Plugin Directory

Changeset 3407166


Ignore:
Timestamp:
12/01/2025 07:11:22 PM (4 months ago)
Author:
IQComputing
Message:

Update to version 1.1.0 from GitHub

Location:
live-rates-for-shipstation
Files:
44 added
12 deleted
20 edited
1 copied

Legend:

Unmodified
Added
Removed
  • live-rates-for-shipstation/tags/1.1.0/README.md

    r3366009 r3407166  
    66[![Plugin Version](https://img.shields.io/wordpress/plugin/v/live-rates-for-shipstation.svg?style=flat-square)](https://wordpress.org/plugins/live-rates-for-shipstation/)
    77
    8 Live Rates for ShipStation is a free Open Source plugin that works with [ShipStation](https://www.dpbolvw.net/click-101532691-11646582) and [WooCommerce](https://woocommerce.com/) to pull in shipping estimates from the most common shipping providers.
     8Live Rates for ShipStation is a free Open Source plugin that works with [ShipStation](https://www.kqzyfj.com/click-101532691-15733876) and [WooCommerce](https://woocommerce.com/) to pull in shipping estimates from the most common shipping providers.
    99
    1010**ShipStation** is a 3rd party provider helping WooCommerce store owners compare shipping carrier rates, automate shipping processes, print labels, sync order data, and group tracking information, among other features.
     
    1212This plugin connects to the ShipStation API using an authentication key to display shipping rates from various common carriers supported by ShipStation. This allows store owners to group all their shipping carriers under one umbrella which makes management easier and allows customers to choose the best shipping method for them which leads to happier customers.
    1313
    14 In order to use the Live Rates for ShipStation plugin, you must have a [premium ShipStation account](https://www.dpbolvw.net/click-101532691-11646582), and purchased the [ShipStation for WooCommerce](https://woocommerce.com/products/shipstation-integration/) plugin. This plugin **will not work** without access to the ShipStation API which is tied to your premium ShipStation account.
     14In order to use the Live Rates for ShipStation plugin, you must have a [premium ShipStation account](https://www.kqzyfj.com/click-101532691-15733876), and purchased the [ShipStation for WooCommerce](https://woocommerce.com/products/shipstation-integration/) plugin. This plugin **will not work** without access to the ShipStation API which is tied to your premium ShipStation account.
    1515
    1616Please review [ShipStations Terms of Service](https://www.shipstation.com/terms-of-service/) and [ShipStations Privacy Policy](https://auctane.com/legal/privacy-policy/) for more information about how your data is managed.
    1717
    18 Don't have a ShipStation account? [Open a ShipStation account today!](https://www.dpbolvw.net/click-101532691-11646582)
     18Don't have a ShipStation account? [Open a ShipStation account today!](https://www.kqzyfj.com/click-101532691-15733876)
    1919
    2020## Requirements
     
    2222Live Rates for ShipStation is free to use, but it does require a premium ShipStation account to access their REST API. In addition, there are plugin requirements as well. Here's a list of requirements in order to use this plugin properly:
    2323
    24 1. [A Premium ShipStation Account](https://www.dpbolvw.net/click-101532691-11646582) (Gold+)
     241. [A Premium ShipStation Account](https://www.kqzyfj.com/click-101532691-15733876) (Gold+)
    25251. [WooCommerce Plugin](https://wordpress.org/plugins/woocommerce/)
    26261. [ShipStation for WooCommerce Plugin](https://woocommerce.com/products/shipstation-integration/)
  • live-rates-for-shipstation/tags/1.1.0/_stallation.php

    r3375346 r3407166  
    1515     */
    1616    public static function deactivate() {
    17 
    18         $settings = new Core\Settings_Shipstation();
    19         $settings->clear_cache();
    20 
     17        \IQLRSS\Driver::clear_cache();
    2118    }
    2219
     
    2724    public static function uninstall() {
    2825
    29         $settings = new Core\Settings_Shipstation();
    30         $settings->clear_cache();
    31 
     26        // Normalize ShipStation Settings by removing our keys.
    3227        $settings = get_option( 'woocommerce_shipstation_settings' );
    3328        foreach( $settings as $key => $val ) {
     
    3934        update_option( 'woocommerce_shipstation_settings', $settings );
    4035
     36        // Clear Cache
     37        \IQLRSS\Driver::clear_cache();
     38
     39    }
     40
     41
     42    /**
     43     * Transition the old plugin version to the current plugin verison.
     44     * This may trigger additional actions.
     45     *
     46     * @param String $version
     47     *
     48     * @return void
     49     */
     50    public static function transversion( $version ) {
     51
     52        $found_version = \IQLRSS\Driver::get_opt( 'version', '1.0.0' );
     53        if( 0 == version_compare( $version, $found_version ) ) {
     54            return;
     55        }
     56
     57        \IQLRSS\Driver::set_opt( 'version', $version );
     58        flush_rewrite_rules();
     59
    4160    }
    4261
  • live-rates-for-shipstation/tags/1.1.0/changelog.txt

    r3376459 r3407166  
    22
    33This is a brief text document keeping track of changes to the plugin. For a full history, see the Github Repository.
     4
     5= 1.1.0 =
     6
     7Relase Date: December 01, 2025
     8
     9* Overview
     10    * Custom Packages really needed to be redone to better support label creation.
     11        * Having modal support will make creating custom options / screens easier in future updates.
     12        * Having named custom boxes and a modal of options will allow users to better manage product and boxes when requesting a shipping label in a future update.
     13            * For example, if products need to be repackaged into different boxes before label creation.
     14    * Redo of the Custom Packages options.
     15        * New options for Weight Only
     16        * New options for Stacked Vertically
     17        * New Box Price field.
     18        * New Package Presets.
     19            * These are pulled from static JSON files + known values.
     20            * Support: UPS, FedEx, USPS.
     21    * New default product weight field.
     22
     23* Code Updates
     24    * Filter hook `iqlrss/zone/settings`
     25        * Expects array of setting fields.
     26        * This hook is useful to manage custom Product Packing options.
     27        * core\shipping-method-shipstation.php LN 543
     28    * Filter hook `iqlrss/zone/package_presets`
     29        * Expects an array of specific key value pairs.
     30        * This hook is useful to manage the Custom Package Options when a zone uses this setting.
     31        * The carrier_code is important to correctly get One rates from supported carriers.
     32        * core\shipping-method-shipstation.php LN 1569
     33    * Filter hook `iqlrss/shipping/packages`
     34        * Expects an array of ShipStation API v2 /rates/estimate API args.
     35            * https://docs.shipstation.com/openapi/rates/estimate_rates
     36        * Useful for custom shipping / package rules. This gives developers the cart items to repackage and retrieve rates from.
     37        * core\shipping-method-shipstation.php LN 742
     38    * Lots of code rearranging, better comments, and better methods to prepare for future updates, features, and functionalty.
    439
    540= 1.0.8 =
  • live-rates-for-shipstation/tags/1.1.0/core/settings-shipstation.php

    r3375346 r3407166  
    4141        add_action( 'admin_enqueue_scripts',                    array( $this, 'enqueue_admin_assets' ) );
    4242        add_action( 'woocommerce_cart_totals_after_order_total',array( $this, 'display_cart_weight' ) ) ;
    43         add_action( 'rest_api_init',                            array( $this, 'api_actions_endpoint' ) );
    4443        add_action( 'woocommerce_update_option',                array( $this, 'clear_cache_on_update' ) );
    45 
    46         // Track and Update exported ShipStation Orders
    47         add_action( 'added_order_meta', array( $this, 'denote_shipstation_export' ), 15, 4 );
    48         add_action( 'init',             array( $this, 'update_exported_orders' ), 15, 4 );
    4944
    5045    }
     
    6156        wp_register_style(
    6257            \IQLRSS\Driver::plugin_prefix( 'admin', '-' ),
    63             \IQLRSS\Driver::get_asset_url( 'admin.css' ),
     58            \IQLRSS\Driver::get_asset_url( 'css/admin.css' ),
    6459            array(),
    6560            \IQLRSS\Driver::get( 'version', '1.0.0' )
     
    6964        wp_register_script_module(
    7065            \IQLRSS\Driver::plugin_prefix( 'admin', '-' ),
    71             \IQLRSS\Driver::get_asset_url( 'admin.js' ),
     66            \IQLRSS\Driver::get_asset_url( 'js/admin.js' ),
    7267            array( 'jquery' ),
    7368            \IQLRSS\Driver::get( 'version', '1.0.0' )
     
    9388        $data = array(
    9489            'api_verified'  => \IQLRSS\Driver::get_ss_opt( 'api_key_valid', false ),
    95             'apiv1_verified'=> \IQLRSS\Driver::get_ss_opt( 'apiv1_key_valid', false ),
    9690            'global_adjustment_type' => \IQLRSS\Driver::get_ss_opt( 'global_adjustment_type', '' ),
     91            'store' => array(
     92                'currency_symbol' => get_woocommerce_currency_symbol( get_woocommerce_currency() ),
     93            ),
    9794            'rest' => array(
    9895                'nonce'     => wp_create_nonce( 'wp_rest' ),
    99                 'apiactions'=> get_rest_url( null, sprintf( '/%s/v1/apiactions',
     96                'settings'=> get_rest_url( null, sprintf( '/%s/v1/settings',
    10097                    \IQLRSS\Driver::get( 'slug' )
    10198                ) ),
     
    105102                'button_api_clearcache' => esc_html__( 'Clear API Cache', 'live-rates-for-shipstation' ),
    106103                'confirm_box_removal'   => esc_html__( 'Please confirm you would like to completely remove (x) custom boxes.', 'live-rates-for-shipstation' ),
     104                'confirm_modal_closure' => esc_html__( 'Changes you made may not be saved. Close modal window?', 'live-rates-for-shipstation' ),
     105                'error_field_required'  => esc_html__( 'This field is required, please enter a value.', 'live-rates-for-shipstation' ),
     106                'error_custombox_json'  => esc_html__( 'Something went wrong while saving your data. Please try again.', 'live-rates-for-shipstation' ),
    107107                'error_rest_generic'    => esc_html__( 'Something went wrong with the REST Request. Please resave permalinks and try again.', 'live-rates-for-shipstation' ),
    108108                'error_verification_required'       => esc_html__( 'Please click the Verify API button to ensure a connection exists.', 'live-rates-for-shipstation' ),
     109                'success_custombox_added'           => esc_html__( 'The Custom Box has been added to the list successfully!', 'live-rates-for-shipstation' ),
    109110                'desc_global_adjustment_percentage' => esc_html__( 'Example: IF UPS Ground is $7.25 and you input 15% ($1.08), the final shipping rate the customer sees is: $8.33', 'live-rates-for-shipstation' ),
    110111                'desc_global_adjustment_flatrate'   => esc_html__( 'Example: IF UPS Ground is $5.50 and you input $2.37, the final shipping rate the customer sees is: $7.87', 'live-rates-for-shipstation' ),
     
    196197
    197198    /**
    198      * REST Endpoint to validate the users API Key and clear API caches.
    199      *
    200      * @return void
    201      */
    202     public function api_actions_endpoint() {
    203 
    204         $prefix = \IQLRSS\Driver::get( 'slug' );
    205 
    206         // Handle ajax requests
    207         register_rest_route( "{$prefix}/v1", 'apiactions', array(
    208             'methods' => array( 'POST' ),
    209             'permission_callback' => fn() => is_user_logged_in(),
    210             'callback' => function( $request ) {
    211 
    212                 $params = $request->get_params();
    213                 if( ! isset( $params['action'] ) || empty( $params['action'] ) ) {
    214                     wp_send_json_error();
    215                 }
    216 
    217                 switch( $params['action'] ) {
    218 
    219                     // Clear the API Caches
    220                     case 'clearcache':
    221 
    222                         // Success!
    223                         $this->clear_cache();
    224                         wp_send_json_success();
    225 
    226                     break;
    227 
    228 
    229                     // Verify API Key
    230                     case 'verify':
    231 
    232                         // Error - Unknown Type
    233                         if( empty( $params['type'] ) || ! in_array( $params['type'], array( 'v1', 'v2' ) ) ) {
    234                             wp_send_json_error( esc_html__( 'System could not discern API type.', 'live-rates-for-shipstation' ), 401 );
    235 
    236                         // Error - v1 API missing key or secret.
    237                         } else if( 'v1' == $params['type'] && ( empty( $params['key'] ) || empty( $params['secret'] ) ) ) {
    238                             wp_send_json_error( esc_html__( 'The ShipStation [v1] API required both a valid [v1] key and [v1] secret.', 'live-rates-for-shipstation' ), 401 );
    239 
    240                         // Error v2 API missing api key.
    241                         } else if( empty( $params['key'] ) ) {
    242                             wp_send_json_error( esc_html__( 'The ShipStation v2 API requires an API key.', 'live-rates-for-shipstation' ), 401 );
    243                         }
    244 
    245                         $type = sanitize_title( $params['type'] );
    246                         $settings = array(
    247                             'v2'            => \IQLRSS\Driver::get_ss_opt( 'api_key' ),
    248                             'v2valid'       => \IQLRSS\Driver::get_ss_opt( 'api_key_valid' ),
    249                             'v2valid_time'  => \IQLRSS\Driver::get_ss_opt( 'api_key_vt' ),
    250                             'v1'            => \IQLRSS\Driver::get_ss_opt( 'apiv1_key' ),
    251                             'v1secret'      => \IQLRSS\Driver::get_ss_opt( 'apiv1_secret' ),
    252                             'v1valid'       => \IQLRSS\Driver::get_ss_opt( 'apiv1_key_valid' ),
    253                             'v1valid_time'  => \IQLRSS\Driver::get_ss_opt( 'apiv1_key_vt' ),
    254                         );
    255                         $keydata = array(
    256                             'old' => array(
    257                                 'key'    => $settings[ $type ],
    258                                 'secret' => $settings['v1secret'],
    259                             ),
    260                             'new' => array(
    261                                 'key'    => sanitize_text_field( $params['key'] ),
    262                                 'secret' => ( ! empty( $params['secret'] ) ) ? sanitize_text_field( $params['secret'] ) : '',
    263                             )
    264                         );
    265 
    266                         // Only allow verification once a day if the data is the same.
    267                         if( $keydata['old']['key'] == $keydata['new']['key'] ) {
    268 
    269                             $valid_time = $settings["{$type}valid_time"];
    270                             if( 'v1' == $type ) {
    271                                 $valid_time = ( $keydata['old']['secret'] != $keydata['new']['secret'] ) ? 0 : $valid_time;
    272                             }
    273 
    274                             // Return Early - We don't need to make a call, it is still valid.
    275                             if( ! empty( $valid_time ) && $valid_time >= gmdate( 'Ymd', strtotime( 'today' ) ) ) {
    276                                 wp_send_json_success();
    277                             }
    278 
    279                         }
    280 
    281                         // Verify the v1 API
    282                         if( 'v1' == $type ) {
    283 
    284                             // The API requires the keys to exist before being pinged.
    285                             \IQLRSS\Driver::set_ss_opt( 'apiv1_key', $keydata['new']['key'] );
    286                             \IQLRSS\Driver::set_ss_opt( 'apiv1_secret', $keydata['new']['secret'] );
    287 
    288                             // Ping the stores so that it sets the currently connected store ID.
    289                             $shipStationAPI = new Shipstation_Apiv1();
    290                             $request = $shipStationAPI->get_stores();
    291 
    292                             // Error - Something went wrong, the API should let us know.
    293                             if( is_wp_error( $request ) || empty( $request ) ) {
    294 
    295                                 // Revert to old key and secret.
    296                                 \IQLRSS\Driver::set_ss_opt( 'apiv1_key', $keydata['old']['key'] );
    297                                 \IQLRSS\Driver::set_ss_opt( 'apiv1_secret', $keydata['old']['secret'] );
    298 
    299                                 $message = ( is_wp_error( $request ) ) ? $request->get_error_message() : '';
    300                                 $code = ( is_wp_error( $request ) ) ? $request->get_error_code() : 400;
    301                                 wp_send_json_error( $message, $code );
    302 
    303                             }
    304 
    305                             // Success! - Denote v2 validity and valid time.
    306                             \IQLRSS\Driver::set_ss_opt( 'apiv1_key_valid', true );
    307                             \IQLRSS\Driver::set_ss_opt( 'apiv1_key_vt', gmdate( 'Ymd', strtotime( 'today' ) ) );
    308                             wp_send_json_success();
    309 
    310                         // Verify the v2 API
    311                         } else {
    312 
    313                             // The API requires the keys to exist before being pinged.
    314                             \IQLRSS\Driver::set_ss_opt( 'api_key', $keydata['new']['key'] );
    315 
    316                             // Ping the carriers so that they are cached.
    317                             $shipStationAPI = new Shipstation_Api();
    318                             $request = $shipStationAPI->get_carriers();
    319 
    320                             // Error - Something went wrong, the API should let us know.
    321                             if( is_wp_error( $request ) || empty( $request ) ) {
    322 
    323                                 // Revert to old key.
    324                                 \IQLRSS\Driver::get_ss_opt( 'api_key', $keydata['old']['key'] );
    325 
    326                                 $message = ( is_wp_error( $request ) ) ? $request->get_error_message() : '';
    327                                 $code = ( is_wp_error( $request ) ) ? $request->get_error_code() : 400;
    328                                 wp_send_json_error( $message, $code );
    329 
    330                             }
    331 
    332                             // Success! - Denote v2 validity and valid time.
    333                             \IQLRSS\Driver::set_ss_opt( 'api_key_valid', true );
    334                             \IQLRSS\Driver::set_ss_opt( 'api_key_vt', gmdate( 'Ymd', strtotime( 'today' ) ) );
    335                             wp_send_json_success();
    336 
    337                         }
    338 
    339                     break;
    340                 }
    341 
    342                 // Cases should return their own error/success.
    343                 wp_send_json_error();
    344             }
    345         ) );
    346 
    347     }
    348 
    349 
    350     /**
    351199     * Clear the API cache.
    352200     *
     
    363211         * The first WHERE ensures only `_transient_` and the 2nd ensures only our plugins transients.
    364212         */
    365         $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s AND option_name LIKE %s",
     213        $wpdb->query( $wpdb->prepare( "DELETE FROM %i WHERE option_name LIKE %s AND option_name LIKE %s", // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.UnsupportedIdentifierPlaceholder
     214            $wpdb->options,
    366215            $wpdb->esc_like( '_transient_' ) . '%',
    367216            '%' . $wpdb->esc_like( '_' . \IQLRSS\Driver::get( 'slug' ) . '_' ) . '%'
     
    369218
    370219        // Set transient to clear any WC_Session caches if they are found.
    371         $expires = absint( apply_filters( 'wc_session_expiration', DAY_IN_SECONDS * 2 ) );
     220        $expires = absint( apply_filters( 'wc_session_expiration', DAY_IN_SECONDS * 2 ) ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
    372221        set_transient( \IQLRSS\Driver::plugin_prefix( 'wcs_timeout' ), time(), $expires );
    373222
     
    388237        }
    389238
    390         $this->clear_cache();
    391 
    392     }
    393 
    394 
    395     /**
    396      * Denote the exported order as a transient.
    397      * Use the transient later to update the order via the v1 API.
    398      *
    399      * @param Integer $meta_id
    400      * @param Integer $order_id
    401      * @param String $meta_key
    402      * @param String $meta_value
    403      *
    404      * @return void
    405      */
    406     public function denote_shipstation_export( $meta_id, $order_id, $meta_key, $meta_value ) {
    407 
    408         if( '_shipstation_exported' != $meta_key || 'yes' != $meta_value ) {
    409             return;
    410         }
    411 
    412         $trans_key = \IQLRSS\Driver::plugin_prefix( 'exported_orders' );
    413         $order_ids = get_transient( $trans_key );
    414         $order_ids = ( ! empty( $order_ids ) ) ? $order_ids : array();
    415 
    416         // Return Early - Order ID already exists.
    417         if( in_array( $order_id, $order_ids ) ) {
    418             return;
    419         }
    420 
    421         $order_ids[] = $order_id;
    422         set_transient( $trans_key, $order_ids, HOUR_IN_SECONDS );
    423 
    424     }
    425 
    426 
    427     /**
    428      * If an `_exported_orders` transient exists
    429      * Update the order with some better info.
    430      *
    431      * @return void
    432      */
    433     public function update_exported_orders() {
    434 
    435         $trans_key = \IQLRSS\Driver::plugin_prefix( 'exported_orders' );
    436         $order_ids = get_transient( $trans_key );
    437 
    438         // Return Early - Delete transient, it's empty.
    439         if( empty( $order_ids ) || ! is_array( $order_ids ) ) {
    440             return delete_transient( $trans_key );
    441         }
    442 
    443         // Grab the oldest order while also priming the WC_Order cache.
    444         $wc_orders = wc_get_orders( array(
    445             'include'   => array_map( 'absint', $order_ids ),
    446             'orderby'   => 'date',
    447             'order'     => 'ASC',
    448             'limit'     => count( $order_ids ),
    449         ) );
    450 
    451         // Return Early - Could't associate WC_Orders with transient order ids.
    452         if( empty( $wc_orders ) ) {
    453             return delete_transient( $trans_key );
    454         }
    455 
    456         // Prime the cache
    457         // API v1 will always cache it's ShipStation data in the WC_Order as metadata.
    458         $apiv1 = new Shipstation_Apiv1( true );
    459         $apiv1->get_orders( array(
    460             'createDateEnd' => gmdate( 'c', time() ),
    461         ) );
    462 
    463         $api = new Shipstation_Api( true );
    464         $api->create_shipments_from_wc_orders( $wc_orders );
    465 
    466         return delete_transient( $trans_key );
     239        \IQLRSS\Driver::clear_cache();
    467240
    468241    }
     
    514287    public function append_shipstation_integration_settings( $fields ) {
    515288
    516         $carriers = array();
     289        $carriers = array(
     290            '' => esc_html__( 'ShipStation carriers may still be loading...', 'live-rates-for-shipstation' ),
     291        );
    517292        $appended_fields = array();
    518293
     
    520295
    521296            $carrier_desc = esc_html__( 'Select which ShipStation carriers you would like to see live shipping rates from.', 'live-rates-for-shipstation' );
    522             $shipStationAPI = new Shipstation_Api();
    523             $response = $shipStationAPI->get_carriers();
    524 
     297            $response = ( new Api\Shipstation() )->get_carriers();
     298
     299            $carriers = array();
    525300            if( is_a( $response, 'WP_Error' ) ) {
    526301                $carriers[''] = $response->get_error_message();
     
    552327                    'default'       => '',
    553328                );
    554 
    555                 // $appended_fields[ \IQLRSS\Driver::plugin_prefix( 'apiv1_key' ) ] = array(
    556                 //  'title'         => esc_html__( 'ShipStation [v1] API Key', 'live-rates-for-shipstation' ),
    557                 //  'type'          => 'password',
    558                 //  'description'   => esc_html__( 'See "ShipStation REST API Key" description, but instead of selecting [v2], select [v1].', 'live-rates-for-shipstation' ),
    559                 //  'default'       => '',
    560                 // );
    561 
    562                 // $appended_fields[ \IQLRSS\Driver::plugin_prefix( 'apiv1_secret' ) ] = array(
    563                 //  'title'         => esc_html__( 'ShipStation [v1] API Secret', 'live-rates-for-shipstation' ),
    564                 //  'type'          => 'password',
    565                 //  'description'   => esc_html__( 'The v1 API is _required_ to manage orders. The v2 API handles Live Rates.', 'live-rates-for-shipstation' ),
    566                 //  'default'       => '',
    567                 // );
    568329
    569330                $appended_fields[ \IQLRSS\Driver::plugin_prefix( 'carriers' ) ] = array(
     
    643404            }
    644405
    645             $this->clear_cache();
    646         }
    647 
    648         // No [v1] API Key? Invalid!
    649         $apiv1_key_key = \IQLRSS\Driver::plugin_prefix( 'apiv1_key' );
    650         if( ! isset( $settings[ $apiv1_key_key ] ) || empty( $settings[ $apiv1_key_key ] ) ) {
    651 
    652             $settings[ \IQLRSS\Driver::plugin_prefix( 'apiv1_key_valid' ) ] = false;
    653             if( isset( $settings[ \IQLRSS\Driver::plugin_prefix( 'apiv1_key_vt' ) ] ) ) {
    654                 unset( $settings[ \IQLRSS\Driver::plugin_prefix( 'apiv1_key_vt' ) ] );
    655             }
    656 
    657             $this->clear_cache();
     406            \IQLRSS\Driver::clear_cache();
    658407        }
    659408
     
    745494
    746495            // Integration > ShipStation settings page
    747             $enqueue = ( $enqueue || isset( $_GET, $_GET['section'] ) && 'shipstation' == $_GET['section'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
     496            $enqueue = ( $enqueue || ( isset( $_GET, $_GET['section'] ) && 'shipstation' == $_GET['section'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
    748497
    749498            // Overprotective WooCommerce settings page check
  • live-rates-for-shipstation/tags/1.1.0/core/shipping-method-shipstation.php

    r3376459 r3407166  
    22/**
    33 * ShipStation Live Shipping Rates Method
     4 *
     5 * @todo Consider moving Shipping Calculations into it's own class.
     6 *
     7 * @link https://www.fedex.com/en-us/shipping/one-rate.html
     8 * @link https://www.usps.com/ship/priority-mail.htm#flatrate
     9 * @link https://www.ups.com/worldshiphelp/WSA/ENG/AppHelp/mergedProjects/CORE/Codes/Package_Type_Codes.htm
    410 *
    511 * :: Action Hooks
     
    2834
    2935    /**
    30      * Array of expected dimension keys (width, height, length, weight)
    31      *
    32      * @var Array
    33      */
    34     protected $dimension_keys = array(
    35         'width'     => 'width',
    36         'height'    => 'height',
    37         'length'    => 'length',
    38         'weight'    => 'weight',
    39     );
    40 
    41 
    42     /**
    4336     * Array of store specific settings.
    4437     *
     
    8679
    8780        $this->plugin_prefix        = \IQLRSS\Driver::get( 'slug' );
    88         $this->shipStationApi       = new Shipstation_Api();
     81        $this->shipStationApi       = new Api\Shipstation();
    8982        $this->id                   = \IQLRSS\Driver::plugin_prefix( 'shipstation' );
    9083        $this->instance_id          = absint( $instance_id );
     
    133126     */
    134127    private function action_hooks() {
     128
    135129        add_action( 'woocommerce_update_options_shipping_' . $this->id, array( $this, 'process_admin_options' ) );
     130        add_action( 'admin_footer', array( $this, 'hide_zone_setting_fields' ) );
     131
    136132    }
    137133
     
    146142        ( new \IQLRSS\Core\Settings_Shipstation() )->clear_cache();
    147143        return parent::process_admin_options();
     144
     145    }
     146
     147
     148    /**
     149     * Hide Shipping Zone setting fields
     150     * 1). Since they're row options we rarely have markup control over.
     151     * 2). Since modules load JS a bit later.
     152     *
     153     * @return void
     154     */
     155    public function hide_zone_setting_fields() {
     156
     157        ?><script type="text/javascript">
     158
     159            /* Hide onebox when not set */
     160            if( document.getElementById( 'woocommerce_iqlrss_shipstation_packing' ) ) { ( function() {
     161                if( 'onebox' != document.getElementById( 'woocommerce_iqlrss_shipstation_packing' ).value ) {
     162                    document.getElementById( 'woocommerce_iqlrss_shipstation_packing' ).closest( 'tr' ).nextElementSibling.style.display = 'none';
     163                }
     164            } )(); }
     165        </script><?php
    148166
    149167    }
     
    293311                            if( isset( $rate_arr['other_costs'] ) ) {
    294312                                foreach( $rate_arr['other_costs'] as $o_slug => $o_amount ) {
    295                                     $new_display .= sprintf( ' | %s: %s', ucwords( $o_slug ), wc_price( $o_amount ) );
     313                                    $new_display .= sprintf( ' | %s: %s', ucwords( str_replace( array( '-', '_' ), ' ', $o_slug ) ), wc_price( $o_amount ) );
    296314                                }
    297315                            }
     
    314332                    $display_arr = array();
    315333                    foreach( $value as $i => $box_arr ) {
     334
     335                        /* translators: %1$d is box/package count (1,2,3). */
     336                        $box_name = sprintf( esc_html__( 'Package %1$d', 'live-rates-for-shipstation' ), $i + 1 );
     337                        if( ! empty( $box_arr['nickname'] ) ) {
     338                            $box_name = $box_arr['nickname'];
     339                        }
    316340
    317341                        $names = esc_html__( 'Product', 'live-rates-for-shipstation' );
     
    323347                            }, $box_arr['packed'] );
    324348                        }
    325 
    326349                        $display_arr[] = sprintf( '%s ( %s ) [ %s %s ( %s x %s x %s %s ) ]',
    327 
    328                             /* translators: %1$d is box/package count (1,2,3). */
    329                             sprintf( esc_html__( 'Package %1$d', 'live-rates-for-shipstation' ), $i + 1 ),
     350                            $box_name,
    330351                            implode( ', ', (array)$names ),
    331352                            $box_arr['weight']['value'],
     
    388409    protected function init_instance_form_fields() {
    389410
    390         $this->instance_form_fields = array(
     411        $settings = array(
    391412            'title' => array(
    392413                'title'         => esc_html__( 'Title', 'live-rates-for-shipstation' ),
     
    395416                'default'       => esc_html__( 'ShipStation Rates', 'live-rates-for-shipstation' ),
    396417                'desc_tip'      => true,
     418            ),
     419            'minweight' => array(
     420                'title'         => esc_html__( 'Product Weight Fallback', 'live-rates-for-shipstation' ),
     421                'type'          => 'text',
     422                'description'   => esc_html__( 'This value will be used if both weight and dimensions are missing from any given product. ShipStation at minimum needs a product weight to retrieve rates.', 'live-rates-for-shipstation' ),
    397423            ),
    398424            'packing' => array(
     
    403429                    'individual'    => esc_html__( 'Pack items individually', 'live-rates-for-shipstation' ),
    404430                    'wc-box-packer' => esc_html__( 'Pack items using Custom Packing Boxes', 'live-rates-for-shipstation' ),
     431                    'onebox'        => esc_html__( 'Pack items into one package derived from products', 'live-rates-for-shipstation' ),
    405432                ),
    406433                'description'   => esc_html__( 'Individually can be more costly. Custom packing boxes will automatically fit as many products in set dimensions lowering shipping costs.', 'live-rates-for-shipstation' ),
     434            ),
     435            'packing_sub' => array(
     436                'title'         => esc_html__( 'Package Dimensions', 'live-rates-for-shipstation' ),
     437                'type'          => 'select',
     438                'options'       => array(
     439                    'weightonly'    => esc_html__( 'Total weight', 'live-rates-for-shipstation' ),
     440                    'stacked'       => esc_html__( 'Stacked vertically', 'live-rates-for-shipstation' ),
     441                ),
     442                'description'   => esc_html__( 'Stacked vertically - sums product heights and weights, takes largest of other dimensions. Weight only sums product weights and retrieves rates using the total.', 'live-rates-for-shipstation' ),
    407443            ),
    408444            'customboxes' => array(
     
    414450        );
    415451
     452
     453        /**
     454         * Allow filtering the Shipping Zone settings
     455         *
     456         * @hook filter
     457         *
     458         * @param Array $settings
     459         * @param \IQLRSS\Core\Shipping_Method_Shipstation $this
     460         *
     461         * @return Array $settings
     462         */
     463        $settings = apply_filters( 'iqlrss/zone/settings', $settings, $this );
     464        $this->instance_form_fields = $settings;
     465
     466    }
     467
     468
     469    /**
     470     * Automatic dynamic method inherited from parent.
     471     * Generate HTML for custom boxes fields.
     472     *
     473     * @return String - HTML
     474     */
     475    public function generate_customboxes_html() {
     476
     477        $prefix         = $this->plugin_prefix;
     478        $show_custom    = ( 'wc-box-packer' == $this->get_option( 'packing', 'individual' ) );
     479        $saved_boxes    = $this->get_option( 'customboxes', array() );
     480        $packages       = $this->get_package_options();
     481
     482        ob_start();
     483            include 'assets/views/customboxes-table.php';
     484        return ob_get_clean();
     485
     486    }
     487
     488
     489    /**
     490     * Validate customboxes.
     491     *
     492     * @return Array $boxes
     493     */
     494    public function validate_customboxes_field() {
     495
     496        if( ! isset( $_POST['_wpnonce'] ) ) {
     497            return;
     498        }
     499
     500        $nonce = sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) );
     501        if( ! wp_verify_nonce( $nonce, 'woocommerce-settings' ) ) {
     502            return;
     503        } else if( ! isset( $_POST['custombox'] ) || ! is_array( $_POST['custombox'] ) ) {
     504            return;
     505        }
     506
     507        // Input sanitized during processing.
     508        $posted_boxes = wp_unslash( $_POST['custombox'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
     509
     510        $boxes = array();
     511        foreach( $posted_boxes as $box_arr ) {
     512
     513            if( isset( $box_arr['json'] ) ) {
     514
     515                $json = json_decode( $box_arr['json'], true );
     516                if( empty( $json['outer'] ) ) continue;
     517
     518                $boxes[] = array(
     519                    'active' => absint( $json['active'] ),
     520                    'preset' => ( isset( $json['preset'] ) ) ? sanitize_text_field( $json['preset'] ) : '',
     521                    'nickname' => sanitize_text_field( $json['nickname'] ),
     522                    'outer' => array(
     523                        'length'    => floatval( $json['outer']['length'] ),
     524                        'width'     => floatval( $json['outer']['width'] ),
     525                        'height'    => floatval( $json['outer']['height'] ),
     526                    ),
     527                    'inner' => array(
     528                        'length'    => floatval( $json['inner']['length'] ),
     529                        'width'     => floatval( $json['inner']['width'] ),
     530                        'height'    => floatval( $json['inner']['height'] ),
     531                    ),
     532                    'weight'    => floatval( $json['weight'] ),
     533                    'weight_max'=> floatval( $json['weight_max'] ),
     534                    'price'     => floatval( $json['price'] ),
     535                    'carrier_code' => ( isset( $json['carrier_code'] ) ) ? sanitize_text_field( $json['carrier_code'] ) : '',
     536                );
     537
     538            }
     539        }
     540
     541        usort( $boxes, function( $arrA, $arrB ) {
     542            return strcasecmp( $arrA['nickname'], $arrB['nickname'] );
     543        } );
     544
     545        return $boxes;
     546
    416547    }
    417548
     
    459590
    460591        ob_start();
    461             include 'views/services-table.php';
    462         return ob_get_clean();
    463 
    464     }
    465 
    466 
    467     /**
    468      * Automatic dynamic method inherited from parent.
    469      * Generate HTML for custom boxes fields.
    470      *
    471      * @return String - HTML
    472      */
    473     public function generate_customboxes_html() {
    474 
    475         $prefix         = $this->plugin_prefix;
    476         $show_custom    = ( 'wc-box-packer' == $this->get_option( 'packing', 'individual' ) );
    477         $saved_boxes    = $this->get_option( 'customboxes', array() );
    478 
    479         ob_start();
    480             include 'views/customboxes-table.php';
     592            include 'assets/views/services-table.php';
    481593        return ob_get_clean();
    482594
     
    563675
    564676
    565     /**
    566      * Validate customboxes field.
    567      *
    568      * @return Array $boxes
    569      */
    570     public function validate_customboxes_field() {
    571 
    572         if( ! isset( $_POST['_wpnonce'] ) ) {
    573             return;
    574         }
    575 
    576         $nonce = sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) );
    577         if( ! wp_verify_nonce( $nonce, 'woocommerce-settings' ) ) {
    578             return;
    579         } else if( ! isset( $_POST['custombox'] ) || ! is_array( $_POST['custombox'] ) ) {
    580             return;
    581         }
    582 
    583         // Input sanitized during processing.
    584         $posted_boxes = wp_unslash( $_POST['custombox'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
    585 
    586         $boxes = array();
    587         foreach( $posted_boxes as $box_arr ) {
    588 
    589             $vals = array_filter( $box_arr, 'is_numeric' );
    590             if( count( $vals ) < 7 ) continue;
    591 
    592             $boxes[] = array(
    593                 'outer' => array(
    594                     'length'    => floatval( $box_arr['ol'] ),
    595                     'width'     => floatval( $box_arr['ow'] ),
    596                     'height'    => floatval( $box_arr['oh'] ),
    597                 ),
    598                 'inner' => array(
    599                     'length'    => floatval( $box_arr['il'] ),
    600                     'width'     => floatval( $box_arr['iw'] ),
    601                     'height'    => floatval( $box_arr['ih'] ),
    602                 ),
    603                 'weight'    => floatval( $box_arr['w'] ),
    604                 'weight_max'=> floatval( $box_arr['wm'] ),
    605             );
    606 
    607         }
    608 
    609         return $boxes;
    610 
    611     }
    612 
    613 
    614677
    615678    /**------------------------------------------------------------------------------------------------ **/
     
    619682     * Calculate shipping costs
    620683     *
    621      * @param Array $package
     684     * @param Array $packages
    622685     *
    623686     * @return void
     
    631694        // Try to pull from cache. This may set $this->rates
    632695        // Return Early - We have cached rates to work with!
    633         $packages_hash = $this->check_packages_rate_cache( $packages );
     696        $this->check_packages_rate_cache( $packages );
    634697        if( ! empty( $this->rates ) ) {
    635698            return;
     
    642705        $enabled_services = $this->get_enabled_services();
    643706        if( empty( $enabled_services ) ) {
     707            $this->log( esc_html__( 'No enabled carrier services found. Please enable carrier services within the shipping zone.', 'live-rates-for-shipstation' ) );
    644708            return;
    645709        }
     
    669733        );
    670734
    671         // Individual Packaging
    672         if( 'individual' == $packing_type ) {
    673             $item_requests = $this->get_individual_requests( $packages['contents'] );
    674 
    675         // WC Boxed Packaging
    676         } else {
    677             $item_requests = $this->get_custombox_requests( $packages['contents'] );
    678         }
    679 
    680         // Rates groups shipping estimates by service ID.
     735        $item_requests = array();
     736        $callback = sprintf( 'group_requestsby_%s', str_replace( '-', '_', $packing_type ) );
     737        if( method_exists( $this, $callback ) ) {
     738            $item_requests = call_user_func( array( $this, $callback ), $packages['contents'] );
     739        }
     740
     741
     742        /**
     743         * Allow filtering the packages before requesting estimates.
     744         *
     745         * The returned array should follow this format:
     746         * Multi-dimensional Array
     747         *
     748         * $item_requests = Array( Array(
     749         * ~ Required Fields:
     750         *      '_name' => '$productID|$productName', - This format makes it easy to show the Shop Manager what's packed into the box.
     751         *      'dimensions' => array(
     752         *          'length => 123,
     753         *          'width' => 123,
     754         *          'height' => 123,
     755         *          'unit' => 'inch', - ShipStation expects a specific string. See \IQLRSS\Core\Api\Shipstation::convert_unit_term( $unit )
     756         *      ),
     757         *      'weight' => array(
     758         *          'value' => 123,
     759         *          'unit' => 'pound',  - ShipStation expects a specific string. See \IQLRSS\Core\Api\Shipstation::convert_unit_term( $unit )
     760         *      ),
     761         *
     762         * ~ Entirely optional, but the system will try to read them if available.
     763         *      'packed' => Array( '$productID|$productName', '$productID|$productName' ),
     764         *      'price'  => 123,
     765         *      'nickname' => 'String' - Displayed to the Shop Owner on the Edit Order page.
     766         *      'box_weight' => 123,
     767         *      'box_max_weight'=> 123,
     768         *      'package_code' => 'ups_ground',
     769         *      'carrier_code' => 'ups', - Carrier Code should match what ShipStation expects. I.E. fedex_walleted. This is to group packages with carriers for discounts.
     770         * ) )
     771         *
     772         * @hook filter
     773         *
     774         * @param Array $item_requests - Array of Package dimensions that the API will use to get rates on. Multidimensional Array.
     775         * @param Array $packages - The cart contents. See $packages['contents'] for items.
     776         * @param \IQLRSS\Core\Shipping_Method_Shipstation $this
     777         *
     778         * @return Array $settings
     779         */
     780        $filtered_requests = apply_filters( 'iqlrss/shipping/packages', $item_requests, $packages, $packing_type, $this );
     781
     782        // IF the hash doesn't match what was given to the filter, note it in the logs so the store owner will know.
     783        $item_req_hash      = ( ! empty( $item_requests ) ) ? md5( maybe_serialize( $item_requests ) ) : '';
     784        $filtered_req_hash  = ( ! empty( $filtered_requests ) ) ? md5( maybe_serialize( $filtered_requests ) ) : '';
     785        if( $item_req_hash !== $filtered_req_hash ) {
     786            $this->log( esc_html__( 'The Shipping packages were modified by a 3rd party using the `iqlrss/shipping/packages` filter hook.', 'live-rates-for-shipstation' ), 'notice' );
     787        }
     788
     789        /**
     790         * We have to return reates per package.
     791         * The /rates/estimate endpoint requires less info
     792         * and /rates endpoint is way slower.
     793         */
    681794        $rates = array();
    682 
    683         /**
    684          * This has to be done per package as the other rates endpoint
    685          * requires the customers address1 for verification and really
    686          * it's not much faster.
    687          */
    688         foreach( $item_requests as $item_id => $req ) {
     795        foreach( $filtered_requests as $item_id => $req ) {
    689796
    690797            // Create the API request combining the package (weight, dimensions), general request data, and the carrier info.
     
    700807            // Continue - Something went wrong, should be logged on the API side.
    701808            $available_rates = $this->shipStationApi->get_shipping_estimates( $api_request );
     809
    702810            if( is_wp_error( $available_rates ) || empty( $available_rates ) ) {
    703811                continue;
     
    711819                }
    712820
     821                $ratehash    = md5( sprintf( '%s%s', $shiprate['code'], $shiprate['carrier_id'] ) );
    713822                $service_arr = $enabled_services[ $shiprate['carrier_id'] ][ $shiprate['code'] ];
    714                 $cost = floatval( $shiprate['cost'] );
    715                 $ratemeta = array(
    716                     '_name'=> ( isset( $req['_name'] ) ) ? $req['_name'] : '', // Item product name.
     823                $cost        = floatval( $shiprate['cost'] );
     824                $rate_name   = ( isset( $req['_name'] ) ) ? $req['_name'] : '';
     825                $rate_name   = ( empty( $rate_name ) && isset( $req['nickname'] ) ) ? $req['nickname'] : $rate_name;
     826                $ratemeta    = array(
     827                    '_name'=> $rate_name, // Item products(ID|Name) or box nickname.
    717828                    'rate' => $cost,
    718829                );
     
    767878                }
    768879
     880                // Maybe a package price
     881                if( 'wc-box-packer' == $packing_type && isset( $req['price'] ) && ! empty( $req['price'] ) ) {
     882                    $cost += floatval( $req['price'] );
     883                    $ratemeta['other_costs']['box_price'] = $req['price'];
     884                }
     885
    769886                // Maybe apply per item.
    770887                if( 'individual' == $packing_type ) {
     
    774891
    775892                // Set rate or append the estimated item ship cost.
    776                 if( ! isset( $rates[ $shiprate['code'] ] ) ) {
    777 
    778                     $rates[ $shiprate['code'] ] = array(
    779                         'id'        => $shiprate['code'],
     893                if( ! isset( $rates[ $ratehash ] ) ) {
     894
     895                    $rates[ $ratehash ] = array(
     896                        'id'        => $ratehash,
    780897                        'label'     => ( ! empty( $service_arr['nickname'] ) ) ? $service_arr['nickname'] : $shiprate['name'],
    781898                        'package'   => $packages,
     
    795912
    796913                } else {
    797                     $rates[ $shiprate['code'] ]['cost'][] = $cost;
     914                    $rates[ $ratehash ]['cost'][] = $cost;
    798915                }
    799916
    800917                // Merge item rates
    801                 $rates[ $shiprate['code'] ]['meta_data']['rates'] = array_merge(
    802                     $rates[ $shiprate['code'] ]['meta_data']['rates'],
     918                $rates[ $ratehash ]['meta_data']['rates'] = array_merge(
     919                    $rates[ $ratehash ]['meta_data']['rates'],
    803920                    array( $ratemeta ),
    804921                );
    805922
    806923                // Merge item boxes
    807                 $rates[ $shiprate['code'] ]['meta_data']['boxes'] = array_merge(
    808                     $rates[ $shiprate['code'] ]['meta_data']['boxes'],
     924                $rates[ $ratehash ]['meta_data']['boxes'] = array_merge(
     925                    $rates[ $ratehash ]['meta_data']['boxes'],
    809926                    array( $req ),
    810927                );
     
    814931        }
    815932
    816         $single_lowest          = \IQLRSS\Driver::get_ss_opt( 'return_lowest', 'no' );
    817         $single_lowest_label    = \IQLRSS\Driver::get_ss_opt( 'return_lowest_label', '' );
     933        $single_lowest       = \IQLRSS\Driver::get_ss_opt( 'return_lowest', 'no' );
     934        $single_lowest_label = \IQLRSS\Driver::get_ss_opt( 'return_lowest_label', '' );
    818935
    819936        // Add all shipping rates, let the user decide.
     
    822939            foreach( $rates as $rate_arr ) {
    823940
    824                 // Skip incomplete rate requests
    825                 if( count( $item_requests ) != count( $rate_arr['cost'] ) ) {
    826                     continue;
     941                // If more than 1 rate, add the cheapest.
     942                if( count( $rate_arr['cost'] ) > 1 ) {
     943                    usort( $rate_arr['cost'], fn( $r1, $r2 ) => ( (float)$r1 < (float)$r2 ) ? -1 : 1 );
     944                    $rate_arr['cost'] = (array)array_shift( $rate_arr['cost'] );
    827945                }
    828946
    829947                // WooCommerce skips serialized data when outputting order item meta, this is a workaround.
    830948                // See hooks above for formatting.
    831                 $rate_arr['meta_data']['rates'] = json_encode( $rate_arr['meta_data']['rates'] );
    832                 $rate_arr['meta_data']['boxes'] = json_encode( $rate_arr['meta_data']['boxes'] );
     949                $rate_arr['meta_data']['rates'] = wp_json_encode( $rate_arr['meta_data']['rates'] );
     950                $rate_arr['meta_data']['boxes'] = wp_json_encode( $rate_arr['meta_data']['boxes'] );
    833951
    834952                $this->add_rate( $rate_arr );
     
    855973            // WooCommerce skips serialized data when outputting order item meta, this is a workaround.
    856974            // See hooks above for formatting.
    857             $rates[ $lowest_service ]['meta_data']['rates'] = json_encode( $rates[ $lowest_service ]['meta_data']['rates'] );
    858             $rates[ $lowest_service ]['meta_data']['boxes'] = json_encode( $rates[ $lowest_service ]['meta_data']['boxes'] );
     975            $rates[ $lowest_service ]['meta_data']['rates'] = wp_json_encode( $rates[ $lowest_service ]['meta_data']['rates'] );
     976            $rates[ $lowest_service ]['meta_data']['boxes'] = wp_json_encode( $rates[ $lowest_service ]['meta_data']['boxes'] );
    859977
    860978            $this->add_rate( $rates[ $lowest_service ] );
     
    862980        }
    863981
    864         // Add a cache key to check against.
    865         WC()->session->set( $this->plugin_prefix, array_merge(
    866             WC()->session->get( $this->plugin_prefix, array() ),
    867             array( 'method_hash' => $packages_hash ),
     982        $cachehash = $this->generate_packages_cache_key( $packages );
     983        if( empty( $cachehash ) ) return;
     984
     985        // Cache packages to prevent multiple requests.
     986        WC()->session->set( $this->plugin_prefix . '_packages', array_merge(
     987            WC()->session->get( $this->plugin_prefix . '_packages', array() ),
     988            array( 'method_hash' => $cachehash ),
    868989            array( 'method_cache_time' => time() ),
    869990        ) );
     
    8791000     * @return Array $requests
    8801001     */
    881     protected function get_individual_requests( $items ) {
    882 
    883         $item_requests = array();
     1002    public function group_requestsby_individual( $items ) {
     1003
     1004        $item_requests  = array();
     1005        $default_weight = $this->get_option( 'minweight', '' );
     1006
    8841007        foreach( $items as $item_id => $item ) {
    8851008
     
    8941017                    $item['data']->get_name(),
    8951018                ),
     1019                'weight' => ( ! empty( $item['data']->get_weight() ) ) ? $item['data']->get_weight() : $default_weight,
    8961020            );
    8971021            $physicals = array_filter( array(
    898                 'weight'    => $item['data']->get_weight(),
    8991022                'length'    => $item['data']->get_length(),
    9001023                'width'     => $item['data']->get_width(),
     
    9031026
    9041027            // Return Early - Product missing one of the 4 key dimensions.
    905             if( count( $physicals ) < 4 ) {
     1028            if( count( $physicals ) < 3 || empty( $request['weight'] ) ) {
    9061029                $this->log( sprintf(
    9071030
    9081031                    /* translators: %1$d is the Product ID. %2$s is the Product Dimensions separated by a comma. */
    909                     esc_html__( 'Product ID #%1$d missing (%2$s) dimensions. Shipping calculations terminated.', 'live-rates-for-shipstation' ),
     1032                    esc_html__( 'Product ID #%1$d missing (%2$s) dimensions. Weight is a minimum requirement. Shipping calculations terminated.', 'live-rates-for-shipstation' ),
     1033                    $item['product_id'],
     1034                    implode( ', ', array_diff_key( array(
     1035                        'length'    => 'length',
     1036                        'width'     => 'width',
     1037                        'height'    => 'height',
     1038                        'weight'    => 'weight',
     1039                    ), $physicals + array( 'weight' => $request['weight'] ) ) )
     1040                ) );
     1041
     1042                return array();
     1043            }
     1044
     1045            // Set rate request dimensions.
     1046            sort( $physicals );
     1047            if( 3 == count( $physicals ) ) {
     1048                $request['dimensions'] = array(
     1049                    'length'    => round( wc_get_dimension( $physicals[2], $this->store_data['dim_unit'] ), 2 ),
     1050                    'width'     => round( wc_get_dimension( $physicals[1], $this->store_data['dim_unit'] ), 2 ),
     1051                    'height'    => round( wc_get_dimension( $physicals[0], $this->store_data['dim_unit'] ), 2 ),
     1052                    'unit'      => $this->shipStationApi->convert_unit_term( $this->store_data['dim_unit'] ),
     1053                );
     1054            }
     1055
     1056            // Set rate request weight.
     1057            if( ! empty( $request['weight'] ) ) {
     1058                $request['weight'] = array(
     1059                    'value' => (float)round( wc_get_weight( $request['weight'], $this->store_data['weight_unit'] ), 2 ),
     1060                    'unit'  => $this->shipStationApi->convert_unit_term( $this->store_data['weight_unit'] ),
     1061                );
     1062            }
     1063
     1064            $item_requests[ $item_id ] = $request;
     1065
     1066        }
     1067
     1068        return $item_requests;
     1069
     1070    }
     1071
     1072
     1073    /**
     1074     * One Big Box
     1075     * Group all the products by weight and get rates by total weight.
     1076     *
     1077     * @param Array $items
     1078     *
     1079     * @return Array $requests
     1080     */
     1081    public function group_requestsby_onebox( $items ) {
     1082
     1083        $default_weight = $this->get_option( 'minweight', 0 );
     1084        $subtype        = $this->get_option( 'packing_sub', 'weightonly' );
     1085        $dimensions = array(
     1086            'running' => array_combine( array( 'length', 'width', 'height', 'weight' ), array_fill( 0, 4, 0 ) ),
     1087            'largest' => array_combine( array( 'length', 'width', 'height', 'weight' ), array_fill( 0, 4, 0 ) ),
     1088        );
     1089
     1090        foreach( $items as $item_id => $item ) {
     1091
     1092            // Continue - No shipping needed for product.
     1093            if( ! $item['data']->needs_shipping() ) {
     1094                continue;
     1095            }
     1096
     1097            $request = array(
     1098                '_name' => sprintf( '%s|%s',
     1099                    $item['data']->get_id(),
     1100                    $item['data']->get_name(),
     1101                ),
     1102                'weight' => ( ! empty( $item['data']->get_weight() ) ) ? $item['data']->get_weight() : $default_weight,
     1103            );
     1104
     1105            // Return Early - Missing minimum requirement: weight.
     1106            if( empty( $request['weight'] ) ) {
     1107
     1108                $this->log( sprintf(
     1109
     1110                    /* translators: %1$d is the Product ID. */
     1111                    esc_html__( 'Product ID #%1$d missing weight. Shipping Zone weight fallback could not be used. Shipping calculations terminated.', 'live-rates-for-shipstation' ),
     1112                    $item['product_id']
     1113                ) );
     1114
     1115                return array();
     1116
     1117            }
     1118
     1119            $dimensions['running']['weight'] = $dimensions['running']['weight'] + ( floatval( $request['weight'] ) * $item['quantity'] );
     1120            $dimensions['running']['height'] = $dimensions['running']['height'] + ( floatval( $item['data']->get_height() ) * $item['quantity'] );
     1121            $dimensions['largest'] = array(
     1122                'length'    => ( $dimensions['largest']['length'] < $item['data']->get_length() ) ? $item['data']->get_length() : $dimensions['largest']['length'],
     1123                'width'     => ( $dimensions['largest']['width'] < $item['data']->get_width() )   ? $item['data']->get_width()  : $dimensions['largest']['width'],
     1124                'height'    => ( $dimensions['largest']['height'] < $item['data']->get_height() ) ? $item['data']->get_height() : $dimensions['largest']['height'],
     1125                'weight'    => ( $dimensions['largest']['weight'] < $request['weight'] )          ? $request['weight']          : $dimensions['largest']['weight'],
     1126            );
     1127
     1128        }
     1129
     1130        // Return Early - Rates by total weight.
     1131        if( 'weightonly' == $subtype ) {
     1132
     1133            return array( array(
     1134                'weight' => array(
     1135                    'value' => (float)round( wc_get_weight( $dimensions['running']['weight'], $this->store_data['weight_unit'] ), 2 ),
     1136                    'unit'  => $this->shipStationApi->convert_unit_term( $this->store_data['weight_unit'] ),
     1137                ),
     1138            ) );
     1139
     1140        }
     1141
     1142        $physicals = array_filter( array(
     1143            'length'    => $dimensions['largest']['length'],
     1144            'width'     => $dimensions['largest']['width'],
     1145            'height'    => $dimensions['running']['height'],
     1146            'weight'    => $dimensions['running']['weight'],
     1147        ) );
     1148
     1149        // Return Early - Error - Missing dimensions to work with.
     1150        if( $physicals < 4 ) {
     1151
     1152            $this->log( sprintf(
     1153
     1154                /* translators: %1$d is the Product ID. %2$s is the Product Dimensions separated by a comma. */
     1155                esc_html__( 'OneBox rate requestion missing dimensions (%1$s). Weight is a minimum requirement. Shipping calculations terminated.', 'live-rates-for-shipstation' ),
     1156                implode( ', ', array_diff_key( array(
     1157                    'length'    => 'length',
     1158                    'width'     => 'width',
     1159                    'height'    => 'height',
     1160                    'weight'    => 'weight',
     1161                ), $physicals ) )
     1162            ) );
     1163
     1164            return array();
     1165
     1166        }
     1167
     1168        // Default - Stacked Verticially
     1169        return array( array(
     1170            'weight' => array(
     1171                'unit'  => $this->shipStationApi->convert_unit_term( $this->store_data['weight_unit'] ),
     1172                'value' => (float)round( wc_get_weight( $physicals['weight'], $this->store_data['weight_unit'] ), 2 ),
     1173            ),
     1174            'dimensions' => array(
     1175                'unit'      => $this->shipStationApi->convert_unit_term( $this->store_data['dim_unit'] ),
     1176
     1177                // Largest
     1178                'length'    => round( wc_get_dimension( $physicals['length'], $this->store_data['dim_unit'] ), 2 ),
     1179                'width'     => round( wc_get_dimension( $physicals['width'], $this->store_data['dim_unit'] ), 2 ),
     1180
     1181                // Running
     1182                'height'    => round( wc_get_dimension( $physicals['height'], $this->store_data['dim_unit'] ), 2 ),
     1183            ),
     1184        ) );
     1185
     1186    }
     1187
     1188
     1189    /**
     1190     * Return an array of API requests for custom packed boxes.
     1191     * Shoutout to Mike Jolly & Co.
     1192     *
     1193     * @param Array $items
     1194     *
     1195     * @return Array $requests
     1196     */
     1197    public function group_requestsby_wc_box_packer( $items ) {
     1198
     1199        $item_requests  = array();
     1200        $boxes          = $this->get_option( 'customboxes', array() );
     1201        $default_weight = $this->get_option( 'minweight', '' );
     1202
     1203        /* Return Early - No custom boxes found. */
     1204        if( empty( $boxes ) ) {
     1205            $this->log( esc_html__( 'Custom Boxes selected, but no boxes found. Items packed individually', 'live-rates-for-shipstation' ), 'warning' );
     1206            return $this->group_requestsby_individual( $items );
     1207        }
     1208
     1209        if( ! class_exists( '\IQRLSS\WC_Box_Packer\WC_Boxpack' ) ) {
     1210            include_once 'wc-box-packer/class-wc-boxpack.php';
     1211        }
     1212
     1213        // Setup the WC_Boxpack boxes based on user submitted custom boxes.
     1214        $wc_boxpack = new WC_Box_Packer\WC_Boxpack();
     1215        foreach( $boxes as $box ) {
     1216            if( empty( $box['active'] ) ) continue;
     1217            $wc_boxpack->add_box( $box );
     1218        }
     1219
     1220        // Loop the items, grabs their dimensions, and assocaite them with WC_Boxpack for future packing.
     1221        foreach( $items as $item_id => $item ) {
     1222            if( ! $item['data']->needs_shipping() ) continue;
     1223
     1224            $weight = ( ! empty( $item['data']->get_weight() ) ) ? $item['data']->get_weight() : $default_weight;
     1225            $data   = array(
     1226                'weight' => (float)round( wc_get_weight( $weight, $this->store_data['weight_unit'] ), 2 ),
     1227            );
     1228            $physicals = array_filter( array(
     1229                'length'    => $item['data']->get_length(),
     1230                'width'     => $item['data']->get_width(),
     1231                'height'    => $item['data']->get_height(),
     1232            ) );
     1233
     1234            // Return Early - Product missing one of the 4 key dimensions.
     1235            if( count( $physicals ) < 3 && empty( $data['weight'] ) ) {
     1236                $this->log( sprintf(
     1237
     1238                    /* translators: %1$d is the Product ID. %2$s is the Product Dimensions separated by a comma. */
     1239                    esc_html__( 'Product ID #%1$d missing (%2$s) dimensions and no weight found. Shipping calculations terminated.', 'live-rates-for-shipstation' ),
    9101240                    $item['product_id'],
    9111241                    implode( ', ', array_diff_key( array(
     
    9131243                        'height'    => 'height',
    9141244                        'length'    => 'length',
    915                         'weight'    => 'weight',
    9161245                    ), $physicals ) )
    9171246                ) );
     
    9191248            }
    9201249
    921             $request['weight'] = array(
    922                 'value' => (float)round( wc_get_weight( $physicals['weight'], $this->store_data['weight_unit'] ), 2 ),
    923                 'unit'  => $this->shipStationApi->convert_unit_term( $this->store_data['weight_unit'] ),
    924             );
    925 
    926             // Unset weight and sort dimensions
    927             unset( $physicals['weight'] );
    9281250            sort( $physicals );
    929 
    930             $request['dimensions'] = array(
    931                 'length'    => round( wc_get_dimension( $physicals[2], $this->store_data['dim_unit'] ), 2 ),
    932                 'width'     => round( wc_get_dimension( $physicals[1], $this->store_data['dim_unit'] ), 2 ),
    933                 'height'    => round( wc_get_dimension( $physicals[0], $this->store_data['dim_unit'] ), 2 ),
    934                 'unit'      => $this->shipStationApi->convert_unit_term( $this->store_data['dim_unit'] ),
    935             );
    936 
    937             $item_requests[ $item_id ] = $request;
    938 
    939         }
    940 
    941         return $item_requests;
    942 
    943     }
    944 
    945 
    946     /**
    947      * Return an array of API requests for custom packed boxes.
    948      * Shoutout to Mike Jolly & Co.
    949      *
    950      * @param Array $items
    951      *
    952      * @return Array $requests
    953      */
    954     protected function get_custombox_requests( $items ) {
    955 
    956         if( ! class_exists( '\IQRLSS\WC_Box_Packer\WC_Boxpack' ) ) {
    957             include_once 'wc-box-packer/class-wc-boxpack.php';
    958         }
    959 
    960         $item_requests = array();
    961         $wc_boxpack = new WC_Box_Packer\WC_Boxpack();
    962         $boxes = $this->get_option( 'customboxes', array() );
    963 
    964         if( empty( $boxes ) ) {
    965             $this->log( esc_html__( 'Custom Boxes selected, but no boxes found. Items packed individually', 'live-rates-for-shipstation' ), 'warning' );
    966         }
    967 
    968         // Setup the WC_Boxpack boxes based on user submitted custom boxes.
    969         foreach( $boxes as $box ) {
    970 
    971             $custombox = $wc_boxpack->add_box( $box['outer']['length'], $box['outer']['width'], $box['outer']['height'], $box['weight'] );
    972             $custombox->set_inner_dimensions( $box['inner']['length'], $box['inner']['width'], $box['inner']['height'] );
    973             if( $box['weight_max'] ) $custombox->set_max_weight( $box['weight_max'] );
    974 
    975         }
    976 
    977         // Loop the items, grabs their dimensions, and assocaite them with WC_Boxpack for future packing.
    978         foreach( $items as $item_id => $item ) {
    979 
    980             // Continue - No shipping needed for product.
    981             if( ! $item['data']->needs_shipping() ) {
    982                 continue;
    983             }
    984 
    985             $data = array();
    986             $physicals = array_filter( array(
    987                 'weight'    => $item['data']->get_weight(),
    988                 'length'    => $item['data']->get_length(),
    989                 'width'     => $item['data']->get_width(),
    990                 'height'    => $item['data']->get_height(),
    991             ) );
    992 
    993             // Return Early - Product missing one of the 4 key dimensions.
    994             if( count( $physicals ) < 4 ) {
    995                 $this->log( sprintf(
    996 
    997                     /* translators: %1$d is the Product ID. %2$s is the Product Dimensions separated by a comma. */
    998                     esc_html__( 'Product ID #%1$d missing (%2$s) dimensions. Shipping calculations terminated.', 'live-rates-for-shipstation' ),
    999                     $item['product_id'],
    1000                     implode( ', ', array_diff_key( array(
    1001                         'width'     => 'width',
    1002                         'height'    => 'height',
    1003                         'length'    => 'length',
    1004                         'weight'    => 'weight',
    1005                     ), $physicals ) )
    1006                 ) );
    1007                 return array();
    1008             }
    1009 
    1010             $data['weight'] = (float)round( wc_get_weight( $physicals['weight'], $this->store_data['weight_unit'] ), 2 );
    1011 
    1012             // Unset weight to exclude it from sort
    1013             unset( $physicals['weight'] );
    1014             sort( $physicals );
    1015 
    10161251            $data = array(
    10171252                'length'    => round( wc_get_dimension( $physicals[2], $this->store_data['dim_unit'] ), 2 ),
    10181253                'width'     => round( wc_get_dimension( $physicals[1], $this->store_data['dim_unit'] ), 2 ),
    10191254                'height'    => round( wc_get_dimension( $physicals[0], $this->store_data['dim_unit'] ), 2 ),
    1020             ) + $data;
    1021 
     1255                'weight'    => round( wc_get_weight( $data['weight'], $this->store_data['weight_unit'] ), 2 ),
     1256            );
     1257
     1258            // Pack Products
    10221259            for( $i = 0; $i < $item['quantity']; $i++ ) {
    10231260                $wc_boxpack->add_item(
     
    10481285            $item_requests[] = array(
    10491286                'weight' => array(
    1050                     'value' => $package->weight,
     1287                    'value' => round( $package->weight, 2 ),
    10511288                    'unit'  => $this->shipStationApi->convert_unit_term( $this->store_data['weight_unit'] ),
    10521289                ),
    10531290                'dimensions' => array(
    1054                     'length'    => $package->length,
    1055                     'width'     => $package->width,
    1056                     'height'    => $package->height,
     1291                    'length'    => round( $package->length, 2 ),
     1292                    'width'     => round( $package->width, 2 ),
     1293                    'height'    => round( $package->height, 2 ),
    10571294                    'unit'      => $this->shipStationApi->convert_unit_term( $this->store_data['dim_unit'] ),
    10581295                ),
    10591296                'packed' => $packed_items,
     1297                'price'  => ( ! empty( $package->data ) ) ? $package->data['price'] : 0,
     1298                'nickname'      => ( ! empty( $package->data ) ) ? $package->data['nickname'] : '',
     1299                'box_weight'    => ( ! empty( $package->data ) ) ? $package->data['weight'] : 0,
     1300                'box_max_weight'=> ( ! empty( $package->data ) ) ? $package->data['weight_max'] : 0,
     1301                'package_code'  => ( ! empty( $package->data ) ) ? $package->data['preset'] : '',
     1302                'carrier_code'  => ( ! empty( $package->data ) ) ? $package->data['carrier_code'] : '',
    10601303            );
    10611304
     
    10731316                ),
    10741317                'max_volume' => floatval( $package->width * $package->height * $package->length ),
     1318                'data' => ( ! empty( $package->data ) ) ? $package->data : array(),
    10751319            );
    10761320
     
    10871331
    10881332    /**
    1089      * Attempt to pull from the WC() Session cache to prevent multiple caclulation
     1333     * Set the rates based on cached packages.
     1334     *
     1335     * Attempt to pull from the WC() Session cache to prevent multiple calculations
    10901336     * requests, which could unnecessarily ping the API or add duplicate logs.
    10911337     * This issue is common when dealing with WP Blocks/Gutenberg Editor.
     
    10931339     * @param Array $packages - Packages in use.
    10941340     *
    1095      * @return String $hash - hash key neded to reset cache.
     1341     * @return void
    10961342     */
    10971343    protected function check_packages_rate_cache( $packages ) {
    10981344
    1099         $session    = WC()->session->get( $this->plugin_prefix, array() );
     1345        $session    = WC()->session->get( $this->plugin_prefix . '_packages', array() );
    11001346        $cleartime  = get_transient( \IQLRSS\Driver::plugin_prefix( 'wcs_timeout' ) );
     1347        $cachehash  = $this->generate_packages_cache_key( $packages );
     1348
     1349        // Return Early - Cache cleared or 30 minuites has passed (invalidate cache).
     1350        if( isset( $session['method_cache_time'] ) && ( $cleartime > $session['method_cache_time'] || $session['method_cache_time'] < ( time() - ( 30 * 60 ) ) ) ) {
     1351            return;
     1352
     1353        // Return Early- Cart has changed.
     1354        } else if( ! isset( $session['method_hash'] ) || empty( $cachehash ) || $session['method_hash'] != $cachehash ) {
     1355            return;
     1356        }
     1357
     1358        // Try to populate Rates.
     1359        $size = count( $packages );
     1360        for( $i = 0; $i < $size; $i++ ) {
     1361
     1362            $cache = WC()->session->get( 'shipping_for_package_' . $i, false );
     1363            if( empty( $cache ) || ! is_array( $cache ) ) {
     1364                break;
     1365            }
     1366            $this->rates = array_merge( $cache['rates'], $this->rates );
     1367
     1368        }
     1369
     1370    }
     1371
     1372
     1373    /**
     1374     * Generate a hash key based off of the given packages.
     1375     *
     1376     * @param Array $packages
     1377     *
     1378     * @return String $hash
     1379     */
     1380    protected function generate_packages_cache_key( $packages ) {
    11011381
    11021382        $keys = array();
     
    11081388            );
    11091389        }
    1110         $hash = md5( wp_json_encode( $keys ) ) . \WC_Cache_Helper::get_transient_version( 'shipping' );
    1111 
    1112         // Return Early - Cache cleared or 30 minuites has passed (invalidate cache).
    1113         if( isset( $session['method_cache_time'] ) && ( $cleartime > $session['method_cache_time'] || $session['method_cache_time'] < ( time() - ( 30 * 60 ) ) ) ) {
    1114             return $hash;
    1115 
    1116         // Return Early- Cart has changed.
    1117         } else if( ! isset( $session['method_hash'] ) || $session['method_hash'] != $hash ) {
    1118             return $hash;
    1119         }
    1120 
    1121         // Try to populate Rates.
    1122         $size = count( $packages );
    1123         for( $i = 0; $i < $size; $i++ ) {
    1124 
    1125             $cache = WC()->session->get( 'shipping_for_package_' . $i, false );
    1126             if( empty( $cache ) || ! is_array( $cache ) ) {
    1127                 break;
    1128             }
    1129             $this->rates = array_merge( $cache['rates'], $this->rates );
    1130 
    1131         }
    1132 
    1133         return $hash;
    1134 
    1135     }
    1136 
    1137 
    1138     /**
    1139      * Generate a hash key based off of the given packages.
    1140      *
    1141      * @param Array $packages
    1142      *
    1143      * @return String $hash
    1144      */
    1145     protected function generate_packages_cache_key( $packages ) {
    1146 
    1147         // Maybe skip if cache was cleared.
    1148         $session    = WC()->session->get( $this->plugin_prefix, array() );
    1149         $cleartime  = get_transient( \IQLRSS\Driver::plugin_prefix( 'wcs_timeout' ) );
    1150         if( isset( $session['method_cache_time'] ) && $cleartime > $session['method_cache_time'] ) {
    1151             return '';
    1152         }
    1153 
    1154         $keys = array();
    1155         foreach( $packages['contents'] as $key => $package ) {
    1156             $keys[] = array(
    1157                 $key,
    1158                 $package['quantity'],
    1159                 $package['line_total'],
    1160             );
    1161         }
    1162 
    1163         $hash = md5( wp_json_encode( $keys ) ) . \WC_Cache_Helper::get_transient_version( 'shipping' );
    1164         return ( ! empty( $keys ) ) ? $hash : '';
     1390
     1391        if( empty( $keys ) ) return '';
     1392        return md5( wp_json_encode( $keys ) ) . \WC_Cache_Helper::get_transient_version( 'shipping' );
    11651393
    11661394    }
     
    11711399    /** :: Helper Methods :: **/
    11721400    /**------------------------------------------------------------------------------------------------ **/
     1401    /**
     1402     * Map known packages.
     1403     * @see assets/json
     1404     *
     1405     * @param String $key
     1406     *
     1407     * @return String
     1408     */
     1409    public function get_package_label( $key ) {
     1410
     1411        $labels = array(
     1412            // UPS
     1413            'flat_rate_envelope'    => esc_html__( 'USPS Flat Rate Envelope', 'live-rates-for-shipstation' ),
     1414            'flat_rate_legal_envelope'  => esc_html__( 'USPS Flat Rate Legal Envelope', 'live-rates-for-shipstation' ),
     1415            'flat_rate_padded_envelope' => esc_html__( 'USPS Flat Rate Padded Envelope', 'live-rates-for-shipstation' ),
     1416            'large_envelope_or_flat'=> esc_html__( 'USPS Large Envelope or Flat', 'live-rates-for-shipstation' ),
     1417            'large_flat_rate_box'   => esc_html__( 'USPS Large Flat Rate Box', 'live-rates-for-shipstation' ),
     1418            'medium_flat_rate_box'  => esc_html__( 'USPS Medium Flat Rate Box', 'live-rates-for-shipstation' ),
     1419            'small_flat_rate_box'   => esc_html__( 'USPS Small Flat Rate Box', 'live-rates-for-shipstation' ),
     1420            'regional_rate_box_a'   => esc_html__( 'USPS Regional Rate Box A', 'live-rates-for-shipstation' ),
     1421            'regional_rate_box_b'   => esc_html__( 'USPS Regional Rate Box B', 'live-rates-for-shipstation' ),
     1422
     1423            // USPS
     1424            'ups_10_kg_box'         => esc_html__( 'UPS 10kg (22lbs) Box', 'live-rates-for-shipstation' ),
     1425            'ups_25_kg_box'         => esc_html__( 'UPS 25kg (55lbs) Box', 'live-rates-for-shipstation' ),
     1426            'ups__express_box_large'=> esc_html__( 'UPS Express Box - Large', 'live-rates-for-shipstation' ), // Why does this have an extra underscore? Ask ShipStation.
     1427            'ups_express_box_medium'=> esc_html__( 'UPS Express Box - Medium', 'live-rates-for-shipstation' ),
     1428            'ups_express_box_small' => esc_html__( 'UPS Express Box - Small', 'live-rates-for-shipstation' ),
     1429            'ups_tube'              => esc_html__( 'UPS Tube', 'live-rates-for-shipstation' ),
     1430            'ups_express_pak'       => esc_html__( 'UPS Express Pak', 'live-rates-for-shipstation' ),
     1431            'ups_letter'            => esc_html__( 'UPS Letter', 'live-rates-for-shipstation' ),
     1432
     1433            // FedEx
     1434            'fedex_10kg_box'    => esc_html__( 'FedEx 10kg (22lbs) Box', 'live-rates-for-shipstation' ),
     1435            'fedex_25kg_box'    => esc_html__( 'FedEx 25kg (55lbs) Box', 'live-rates-for-shipstation' ),
     1436            'fedex_extra_large_box' => esc_html__( 'FedEx Extra Large Box', 'live-rates-for-shipstation' ),
     1437            'fedex_large_box'   => esc_html__( 'FedEx Large Box', 'live-rates-for-shipstation' ),
     1438            'fedex_medium_box'  => esc_html__( 'FedEx Medium Box', 'live-rates-for-shipstation' ),
     1439            'fedex_small_box'   => esc_html__( 'FedEx Small Box', 'live-rates-for-shipstation' ),
     1440            'fedex_tube'        => esc_html__( 'FedEx Tube', 'live-rates-for-shipstation' ),
     1441            'fedex_envelope'    => esc_html__( 'FedEx Envelope', 'live-rates-for-shipstation' ),
     1442            'fedex_pak'         => esc_html__( 'FedEx Padded Pak', 'live-rates-for-shipstation' ),
     1443        );
     1444
     1445        return ( isset( $labels [ $key ] ) ) ? $labels[ $key ] : esc_html__( 'Unknown Package', 'live-rates-for-shipstation' );
     1446
     1447    }
     1448
     1449
    11731450    /**
    11741451     * Return an array of Price Adjustment Type options.
     
    12141491
    12151492    /**
     1493     * Convert a WooCommerce unit to a ShipStation unit.
     1494     *
     1495     * @param String $unit
     1496     *
     1497     * @return String $new_unit
     1498     */
     1499    public function convert_unit_term( $unit ) {
     1500        return $this->shipStationApi->convert_unit_term( $unit );
     1501    }
     1502
     1503
     1504    /**
     1505     * Return an array of package options.
     1506     *
     1507     * @return Array
     1508     */
     1509    protected function get_package_options() {
     1510
     1511        $packages = wp_cache_get( 'packages', $this->plugin_prefix );
     1512        if( ! empty( $packages ) ) {
     1513            return $packages;
     1514        }
     1515
     1516        $global_carriers= $this->shipStationApi->get_carriers();
     1517        $carrier_codes  = wp_list_pluck( $global_carriers, 'carrier_code' );
     1518        $carrier_codes  = array_intersect_key( $carrier_codes, array_flip( $this->carriers ) );
     1519
     1520        $data = array(
     1521            'usps' => array(
     1522                'label'     => esc_html__( 'USPS', 'live-rates-for-shipstation' ),
     1523                'packages'  => json_decode( file_get_contents( \IQLRSS\Driver::get_asset_path( 'json/usps-packages.json' ) ), true ),
     1524            ),
     1525            'ups'   => array(
     1526                'label'     => esc_html__( 'UPS', 'live-rates-for-shipstation' ),
     1527                'packages'  => json_decode( file_get_contents( \IQLRSS\Driver::get_asset_path( 'json/ups-packages.json' ) ), true ),
     1528            ),
     1529            'fedex' => array(
     1530                'label'     => esc_html__( 'FedEx', 'live-rates-for-shipstation' ),
     1531                'packages'  => json_decode( file_get_contents( \IQLRSS\Driver::get_asset_path( 'json/fedex-packages.json' ) ), true ),
     1532            ),
     1533        );
     1534
     1535        // Append Translated Labels
     1536        $carrier_packages = array();
     1537        foreach( $data as $carrier_code => &$carriers ) {
     1538
     1539            // Match carrier slug with known carrier code.
     1540            $carrier_found = array_filter( $carrier_codes, fn( $c ) => $c === $carrier_code );
     1541            if( empty( $carrier_found ) ) {
     1542                $carrier_found = array_filter( $carrier_codes, fn( $c ) => false !== strpos( $c, $carrier_code . '_' ) );
     1543            }
     1544
     1545            // Skip - Carrier may not be set.
     1546            if( empty( $carrier_found ) ) continue;
     1547
     1548            $codes = wp_list_pluck( $carriers['packages'], 'code' );
     1549            $dupes = array_count_values( $codes );
     1550
     1551            foreach( $carriers['packages'] as &$package ) {
     1552
     1553                $package['carrier_code'] = $carrier_code;
     1554                $package['label'] = $this->get_package_label( $package['code'] );
     1555
     1556                if( $dupes[ $package['code'] ] > 1 ) {
     1557                    $package['label'] .= sprintf( ' (%s x %s x %s)', $package['length'], $package['width'], $package['height'] );
     1558                }
     1559            }
     1560
     1561            usort( $carriers['packages'], fn( $pa, $pb ) => strcmp( $pa['label'], $pb['label'] ) );
     1562            $carrier_packages[ $carrier_code ] = $carriers;
     1563
     1564        }
     1565
     1566        $data = array( '' => esc_html__( '-- Select Package Preset --', 'live-rates-for-shipstation' ) ) + $carrier_packages;
     1567
     1568
     1569        /**
     1570         * Allow hooking into Custom Package presets for 3rd party management.
     1571         *
     1572         * @hook filter
     1573         *
     1574         * @param Array $data - Array( Array(
     1575         *      'label' => 'Optional Optgroup Name',
     1576         *      'packages' => Array(
     1577         *          'label'  => '',
     1578         *          'code'   => '',
     1579         *          'length' => 0,
     1580         *          'width'  => 0,
     1581         *          'height' => 0,
     1582         *          'weight_max' => 0,
     1583         *          'carrier_code' => '',
     1584         *      )
     1585         * ) )
     1586         * @param \IQLRSS\Core\Shipping_Method_Shipstation $this
     1587         *
     1588         * @return Array $data
     1589         */
     1590        $packages = apply_filters( 'iqlrss/zone/package_presets', $data, $this );
     1591
     1592        // Maybe reset if what we're given is not what we expect.
     1593        if( ! is_array( $packages ) ) $packages = $data;
     1594
     1595        // Cache results to avoid multiple file reads per request.
     1596        if( ! empty( $packages ) ) {
     1597            wp_cache_add( 'packages', $packages, $this->plugin_prefix );
     1598
     1599        // Maybe provide a default options / text when empty.
     1600        } else {
     1601            $packages = array( '' => esc_html__( 'No package presets.', 'live-rates-for-shipstation' ) );
     1602        }
     1603
     1604        return $packages;
     1605
     1606    }
     1607
     1608
     1609    /**
    12161610     * Format a stringified product name.
    12171611     * ex. 213|Shirt|optional|meta|data
     
    12231617     * @return String $name
    12241618     */
    1225     public function format_shipitem_name( $shipitem_name, $link = false, $context = 'edit' ) {
     1619    protected function format_shipitem_name( $shipitem_name, $link = false, $context = 'edit' ) {
    12261620
    12271621        $name = mb_strimwidth( $shipitem_name, 0, 47, '...' );
  • live-rates-for-shipstation/tags/1.1.0/core/wc-box-packer/class-wc-boxpack-box.php

    r3376459 r3407166  
    22/**
    33 * Box Packing class found in woocommerce-shipping-ups
    4  * Updated by IQComputing because many of these methods
    5  * have the wrong return documentation.
     4 * Updated by IQComputing
    65 *
    76 * @version 2.0.1
     
    6867
    6968    /**
    70      * __construct function.
    71      *
    72      * @access public
     69     * Box info - contains the core box properties and
     70     * additonal data like nickname and price.
     71     * See the Shipping Method Custom Packing Boxes for more info.
     72     *
     73     * @var Array
     74     */
     75    private $data = array();
     76
     77
     78    /**
     79     * Setup box properties.
     80     *
     81     * @param Array $box - Array( 'outer' => array( 'length', 'width', 'height' ), 'inner' => array( see outer ) )
     82     *
    7383     * @return void
    7484     */
    75     public function __construct( $length, $width, $height, $weight = 0 ) {
    76         $dimensions = array( $length, $width, $height );
    77 
    78         sort( $dimensions );
    79 
    80         $this->outer_length = $this->length = floatval( $dimensions[2] );
    81         $this->outer_width  = $this->width  = floatval( $dimensions[1] );
    82         $this->outer_height = $this->height = floatval( $dimensions[0] );
    83         $this->weight       = floatval( $weight );
     85    public function __construct( $box ) {
     86
     87        // Default - All Outer
     88        $this->length = floatval( $box['outer']['length'] );
     89        $this->width  = floatval( $box['outer']['width'] );
     90        $this->height = floatval( $box['outer']['height'] );
     91        $this->outer_length = floatval( $box['outer']['length'] );
     92        $this->outer_width  = floatval( $box['outer']['width'] );
     93        $this->outer_height = floatval( $box['outer']['height'] );
     94
     95        // Inner
     96        if( ! empty( array_filter( (array)$box['inner'] ) ) ) {
     97            $this->length = floatval( $box['inner']['length'] );
     98            $this->width  = floatval( $box['inner']['width'] );
     99            $this->height = floatval( $box['inner']['height'] );
     100        }
     101
     102        // Weight
     103        $this->weight = floatval( $box['weight'] );
     104
     105        // Everything else
     106        $this->data = $box;
     107
    84108    }
    85109
     
    211235        $this->reset_packed_dimensions();
    212236
    213         // @todo Rememer this kind of loop, neat method, love it.
    214237        while ( sizeof( $items ) > 0 ) {
    215238            $item = array_shift( $items );
     
    268291        $package->height   = $this->get_outer_height();
    269292        $package->value    = $packed_value;
     293        $package->data     = $this->data;
    270294
    271295        // Calculate packing success % based on % of weight and volume of all items packed
     
    281305        }
    282306
     307        // Fallback to amount packed
    283308        if ( is_null( $packed_weight_ratio ) && is_null( $packed_volume_ratio ) ) {
    284             // Fallback to amount packed
    285309            $package->percent = ( sizeof( $packed ) / ( sizeof( $unpacked ) + sizeof( $packed ) ) ) * 100;
     310
     311        // Volume only
    286312        } elseif ( is_null( $packed_weight_ratio ) ) {
    287             // Volume only
    288313            $package->percent = $packed_volume_ratio * 100;
     314
     315        // Weight only
    289316        } elseif ( is_null( $packed_volume_ratio ) ) {
    290             // Weight only
    291317            $package->percent = $packed_weight_ratio * 100;
     318
     319        // Default?
    292320        } else {
    293321            $package->percent = $packed_weight_ratio * $packed_volume_ratio * 100;
     
    388416        return $this->packed_length;
    389417    }
     418
     419    /**
     420     * Return box data.
     421     *
     422     * @param String $key
     423     * @param Mixed $default
     424     *
     425     * @return Mixed
     426     */
     427    public function get_data( $key, $default = '' ) {
     428        return ( isset( $this->data[ $key ] ) ) ? $this->data[ $key ] : $default;
     429    }
    390430}
  • live-rates-for-shipstation/tags/1.1.0/core/wc-box-packer/class-wc-boxpack-item.php

    r3339099 r3407166  
    22/**
    33 * Box Packing class found in woocommerce-shipping-ups
    4  * Updated by IQComputing because many of these methods
    5  * have the wrong return documentation.
     4 * Updated by IQComputing
    65 *
    76 * @version 2.0.1
  • live-rates-for-shipstation/tags/1.1.0/core/wc-box-packer/class-wc-boxpack.php

    r3376459 r3407166  
    22/**
    33 * Box Packing class found in woocommerce-shipping-ups
    4  * Updated by IQComputing because many of these methods
    5  * have the wrong return documentation.
     4 * Updated by IQComputing
    65 *
    76 * @version 2.0.1
     
    7069     *
    7170     * @access public
    72      * @param mixed $length
    73      * @param mixed $width
    74      * @param mixed $height
    75      * @param mixed $weight
     71     * @param Array $box - Array( 'outer' => array( 'length', 'width', 'height' ), 'inner' => array( see outer ) )
    7672     * @return object WC_Boxpack_Box
    7773     */
    78     public function add_box( $length, $width, $height, $weight = 0 ) {
    79         $new_box = new WC_Boxpack_Box( $length, $width, $height, $weight );
     74    public function add_box( $box ) {
     75        $new_box = new WC_Boxpack_Box( $box );
    8076        $this->boxes[] = $new_box;
    8177        return $new_box;
     
    174170                    $package->unpacked = true;
    175171                    $package->packed   = array( $item );
     172                    $package->data     = array();
    176173                    $this->packages[]  = $package;
    177174                }
  • live-rates-for-shipstation/tags/1.1.0/live-rates-for-shipstation.php

    r3376459 r3407166  
    44 * Plugin URI: https://iqcomputing.com/contact/
    55 * Description: ShipStation shipping method with live rates.
    6  * Version: 1.0.8
    7  * Requries at least: 5.9
     6 * Version: 1.1.0
     7 * Requries at least: 6.2
    88 * Author: IQComputing
    99 * Author URI: https://iqcomputing.com/
     
    1212 * Text Domain: live-rates-for-shipstation
    1313 * Requires Plugins: woocommerce, woocommerce-shipstation-integration
    14  *
    15  * @notes ShipStation does not make it easy or obvious how to update / create a Shipment for an Order.
    16  *      The shipment create endpoint keeps coming back successful, but nothing on the ShipStation side
    17  *      appears to change.
    18  *      The v1 API update Order endpoint also doesn't seem to allow Shipment updates, but is required
    19  *      to get the OrderID, required for any kind of create/update endpoints.
    20  *
    21  * @todo Add warehosue locations to Shipping Zone packages.
    22  * @todo Look into updating warehouses through Edit Order > Order Items.
    2314 */
    2415namespace IQLRSS;
     
    3526     * @var String
    3627     */
    37     protected static $version = '1.0.8';
     28    protected static $version = '1.1.0';
    3829
    3930
     
    8677     * @param Mixed $value
    8778     *
    88      * @return Mixed
     79     * @return void
    8980     */
    9081    public static function set_ss_opt( $key, $value ) {
     
    10596
    10697    /**
     98     * Return a ShipStation Plugin Option Value
     99     *
     100     * @param String $key
     101     * @param Mixed $default
     102     * @param Boolean $skip_prefix - Skip Plugin Prefix and return a core ShipStation setting value.
     103     *
     104     * @return Mixed
     105     */
     106    public static function get_opt( $key, $default = '' ) {
     107        $settings = get_option( static::plugin_prefix( 'plugin' ) );
     108        return ( isset( $settings[ $key ] ) && '' !== $settings[ $key ] ) ? maybe_unserialize( $settings[ $key ] ) : $default;
     109    }
     110
     111
     112    /**
     113     * Set a plugin option.
     114     *
     115     * @param String $key
     116     * @param Mixed $value
     117     *
     118     * @return void
     119     */
     120    public static function set_opt( $key, $value ) {
     121
     122        $option     = static::plugin_prefix( 'plugin' );
     123        $settings   = get_option( $option, array() );
     124
     125        if( is_bool( $value ) ) {
     126            $settings[ $key ] = boolval( $value );
     127        } else if( is_string( $value ) || is_numeric( $value ) ) {
     128            $settings[ $key ] = sanitize_text_field( $value );
     129        }
     130
     131        update_option( $option, $settings );
     132
     133    }
     134
     135
     136    /**
     137     * Clear the Plugin API cache.
     138     *
     139     * @return void
     140     */
     141    public static function clear_cache() {
     142
     143        global $wpdb;
     144
     145        /**
     146         * The API Class creates various transients to cache carrier services.
     147         * These transients are not tracked but generated based on the responses carrier codes.
     148         * All these transients are prefixed with our plugins unique string slug.
     149         * The first WHERE ensures only `_transient_` and the 2nd ensures only our plugins transients.
     150         */
     151        $wpdb->query( $wpdb->prepare( "DELETE FROM %i WHERE option_name LIKE %s AND option_name LIKE %s", // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.UnsupportedIdentifierPlaceholder
     152            $wpdb->options,
     153            $wpdb->esc_like( '_transient_' ) . '%',
     154            '%' . $wpdb->esc_like( '_' . static::get( 'slug' ) . '_' ) . '%'
     155        ) );
     156
     157        // Set transient to clear any WC_Session caches if they are found.
     158        $expires = absint( apply_filters( 'wc_session_expiration', DAY_IN_SECONDS * 2 ) ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
     159        set_transient( static::plugin_prefix( 'wcs_timeout' ), time(), $expires );
     160
     161    }
     162
     163
     164    /**
    107165     * Prefix a string with the plugin slug.
    108166     *
     
    124182
    125183    /**
    126      * Return a URL to an asset (JS/CSS)
     184     * Return a URL to an asset (JS/CSS usually)
    127185     *
    128186     * @param String $asset
    129187     *
    130      * @return String $url
     188     * @return String
    131189     */
    132190    public static function get_asset_url( $asset ) {
     
    141199
    142200    /**
     201     * Return a path to an asset.
     202     *
     203     * @param String $asset
     204     *
     205     * @return String
     206     */
     207    public static function get_asset_path( $asset ) {
     208
     209        return sprintf( '%s/core/assets/%s',
     210            rtrim( plugin_dir_path( __FILE__ ), '\\/' ),
     211            $asset
     212        );
     213
     214    }
     215
     216
     217    /**
    143218     * Initialize the core controllers
    144219     * Vroom!
     
    147222     */
    148223    public static function drive() {
     224
     225        // Run any version transition actions.
     226        Stallation::transversion( static::$version );
     227
     228        // Load core controllers.
     229        Core\Rest_Router::initialize();
    149230        Core\Settings_Shipstation::initialize();
    150231    }
  • live-rates-for-shipstation/tags/1.1.0/readme.txt

    r3376459 r3407166  
    44Requires at least: 5.9
    55Tested up to: 6.8
    6 Stable tag: 1.0.8
     6Stable tag: 1.1.0
    77License: GPLv3 or later
    88License URI: https://www.gnu.org/licenses/gpl-3.0.html
     
    1616This plugin connects to the ShipStation API using an authentication key to display shipping rates from various common carriers supported by ShipStation. This allows store owners to group all their shipping carriers under one umbrella which makes management easier and allows customers to choose the best shipping method for them which leads to happier customers.
    1717
    18 In order to use the Live Rates for ShipStation plugin, you must have a [premium ShipStation account](https://www.dpbolvw.net/click-101532691-11646582), and purchased the [ShipStation for WooCommerce](https://woocommerce.com/products/shipstation-integration/) plugin. This plugin **will not work** without access to the ShipStation API which is tied to your premium ShipStation account.
     18In order to use the Live Rates for ShipStation plugin, you must have a [premium ShipStation account](https://www.kqzyfj.com/click-101532691-15733876), and purchased the [ShipStation for WooCommerce](https://woocommerce.com/products/shipstation-integration/) plugin. This plugin **will not work** without access to the ShipStation API which is tied to your premium ShipStation account.
    1919
    2020Please review [ShipStations Terms of Service](https://www.shipstation.com/terms-of-service/) and [ShipStations Privacy Policy](https://auctane.com/legal/privacy-policy/) for more information about how your data is managed.
    2121
    22 Don't have a ShipStation account? [Open a ShipStation account today!](https://www.dpbolvw.net/click-101532691-11646582)
     22Don't have a ShipStation account? [Open a ShipStation account today!](https://www.kqzyfj.com/click-101532691-15733876)
    2323
    2424== Plugin Requirements ==
    2525
    26 1. [A Premium ShipStation Account](https://www.dpbolvw.net/click-101532691-11646582)
     261. [A Premium ShipStation Account](https://www.kqzyfj.com/click-101532691-15733876)
    27271. [The WooCommerce Plugin](https://wordpress.org/plugins/woocommerce/)
    28281. [The ShipStation for WooCommerce Plugin](https://woocommerce.com/products/shipstation-integration/)
     
    5151== Changelog ==
    5252
     53= 1.1.0 (2025-12-01) =
     54* Redux the Custom Packaging screen and options.
     55* Packing option for Weight Only.
     56* Packing option for Stacked Vertically.
     57* Packing option for default product weight.
     58* Custom Package Presets from UPS, FedEx, and USPS.
     59* New filter hook for Shipping Zone Settings `iqlrss/zone/settings`. Useful for managing Product Packing options.
     60* New filter hook for Shipping Zone Settings `iqlrss/zone/package_presets`. Useful for managing Custom Package presets.
     61* New filter hook for Shipping Estimates `iqlrss/shipping/packages`. Useful for modifying what gets sent to ShipStation API for retrieving shipping estimates.
     62
    5363= 1.0.8 (2025-10-10) =
    5464* Patches issue of missing `other_amount` when applying shipping rates (thanks @centuryperf)!
  • live-rates-for-shipstation/trunk/README.md

    r3366009 r3407166  
    66[![Plugin Version](https://img.shields.io/wordpress/plugin/v/live-rates-for-shipstation.svg?style=flat-square)](https://wordpress.org/plugins/live-rates-for-shipstation/)
    77
    8 Live Rates for ShipStation is a free Open Source plugin that works with [ShipStation](https://www.dpbolvw.net/click-101532691-11646582) and [WooCommerce](https://woocommerce.com/) to pull in shipping estimates from the most common shipping providers.
     8Live Rates for ShipStation is a free Open Source plugin that works with [ShipStation](https://www.kqzyfj.com/click-101532691-15733876) and [WooCommerce](https://woocommerce.com/) to pull in shipping estimates from the most common shipping providers.
    99
    1010**ShipStation** is a 3rd party provider helping WooCommerce store owners compare shipping carrier rates, automate shipping processes, print labels, sync order data, and group tracking information, among other features.
     
    1212This plugin connects to the ShipStation API using an authentication key to display shipping rates from various common carriers supported by ShipStation. This allows store owners to group all their shipping carriers under one umbrella which makes management easier and allows customers to choose the best shipping method for them which leads to happier customers.
    1313
    14 In order to use the Live Rates for ShipStation plugin, you must have a [premium ShipStation account](https://www.dpbolvw.net/click-101532691-11646582), and purchased the [ShipStation for WooCommerce](https://woocommerce.com/products/shipstation-integration/) plugin. This plugin **will not work** without access to the ShipStation API which is tied to your premium ShipStation account.
     14In order to use the Live Rates for ShipStation plugin, you must have a [premium ShipStation account](https://www.kqzyfj.com/click-101532691-15733876), and purchased the [ShipStation for WooCommerce](https://woocommerce.com/products/shipstation-integration/) plugin. This plugin **will not work** without access to the ShipStation API which is tied to your premium ShipStation account.
    1515
    1616Please review [ShipStations Terms of Service](https://www.shipstation.com/terms-of-service/) and [ShipStations Privacy Policy](https://auctane.com/legal/privacy-policy/) for more information about how your data is managed.
    1717
    18 Don't have a ShipStation account? [Open a ShipStation account today!](https://www.dpbolvw.net/click-101532691-11646582)
     18Don't have a ShipStation account? [Open a ShipStation account today!](https://www.kqzyfj.com/click-101532691-15733876)
    1919
    2020## Requirements
     
    2222Live Rates for ShipStation is free to use, but it does require a premium ShipStation account to access their REST API. In addition, there are plugin requirements as well. Here's a list of requirements in order to use this plugin properly:
    2323
    24 1. [A Premium ShipStation Account](https://www.dpbolvw.net/click-101532691-11646582) (Gold+)
     241. [A Premium ShipStation Account](https://www.kqzyfj.com/click-101532691-15733876) (Gold+)
    25251. [WooCommerce Plugin](https://wordpress.org/plugins/woocommerce/)
    26261. [ShipStation for WooCommerce Plugin](https://woocommerce.com/products/shipstation-integration/)
  • live-rates-for-shipstation/trunk/_stallation.php

    r3375346 r3407166  
    1515     */
    1616    public static function deactivate() {
    17 
    18         $settings = new Core\Settings_Shipstation();
    19         $settings->clear_cache();
    20 
     17        \IQLRSS\Driver::clear_cache();
    2118    }
    2219
     
    2724    public static function uninstall() {
    2825
    29         $settings = new Core\Settings_Shipstation();
    30         $settings->clear_cache();
    31 
     26        // Normalize ShipStation Settings by removing our keys.
    3227        $settings = get_option( 'woocommerce_shipstation_settings' );
    3328        foreach( $settings as $key => $val ) {
     
    3934        update_option( 'woocommerce_shipstation_settings', $settings );
    4035
     36        // Clear Cache
     37        \IQLRSS\Driver::clear_cache();
     38
     39    }
     40
     41
     42    /**
     43     * Transition the old plugin version to the current plugin verison.
     44     * This may trigger additional actions.
     45     *
     46     * @param String $version
     47     *
     48     * @return void
     49     */
     50    public static function transversion( $version ) {
     51
     52        $found_version = \IQLRSS\Driver::get_opt( 'version', '1.0.0' );
     53        if( 0 == version_compare( $version, $found_version ) ) {
     54            return;
     55        }
     56
     57        \IQLRSS\Driver::set_opt( 'version', $version );
     58        flush_rewrite_rules();
     59
    4160    }
    4261
  • live-rates-for-shipstation/trunk/changelog.txt

    r3376459 r3407166  
    22
    33This is a brief text document keeping track of changes to the plugin. For a full history, see the Github Repository.
     4
     5= 1.1.0 =
     6
     7Relase Date: December 01, 2025
     8
     9* Overview
     10    * Custom Packages really needed to be redone to better support label creation.
     11        * Having modal support will make creating custom options / screens easier in future updates.
     12        * Having named custom boxes and a modal of options will allow users to better manage product and boxes when requesting a shipping label in a future update.
     13            * For example, if products need to be repackaged into different boxes before label creation.
     14    * Redo of the Custom Packages options.
     15        * New options for Weight Only
     16        * New options for Stacked Vertically
     17        * New Box Price field.
     18        * New Package Presets.
     19            * These are pulled from static JSON files + known values.
     20            * Support: UPS, FedEx, USPS.
     21    * New default product weight field.
     22
     23* Code Updates
     24    * Filter hook `iqlrss/zone/settings`
     25        * Expects array of setting fields.
     26        * This hook is useful to manage custom Product Packing options.
     27        * core\shipping-method-shipstation.php LN 543
     28    * Filter hook `iqlrss/zone/package_presets`
     29        * Expects an array of specific key value pairs.
     30        * This hook is useful to manage the Custom Package Options when a zone uses this setting.
     31        * The carrier_code is important to correctly get One rates from supported carriers.
     32        * core\shipping-method-shipstation.php LN 1569
     33    * Filter hook `iqlrss/shipping/packages`
     34        * Expects an array of ShipStation API v2 /rates/estimate API args.
     35            * https://docs.shipstation.com/openapi/rates/estimate_rates
     36        * Useful for custom shipping / package rules. This gives developers the cart items to repackage and retrieve rates from.
     37        * core\shipping-method-shipstation.php LN 742
     38    * Lots of code rearranging, better comments, and better methods to prepare for future updates, features, and functionalty.
    439
    540= 1.0.8 =
  • live-rates-for-shipstation/trunk/core/settings-shipstation.php

    r3375346 r3407166  
    4141        add_action( 'admin_enqueue_scripts',                    array( $this, 'enqueue_admin_assets' ) );
    4242        add_action( 'woocommerce_cart_totals_after_order_total',array( $this, 'display_cart_weight' ) ) ;
    43         add_action( 'rest_api_init',                            array( $this, 'api_actions_endpoint' ) );
    4443        add_action( 'woocommerce_update_option',                array( $this, 'clear_cache_on_update' ) );
    45 
    46         // Track and Update exported ShipStation Orders
    47         add_action( 'added_order_meta', array( $this, 'denote_shipstation_export' ), 15, 4 );
    48         add_action( 'init',             array( $this, 'update_exported_orders' ), 15, 4 );
    4944
    5045    }
     
    6156        wp_register_style(
    6257            \IQLRSS\Driver::plugin_prefix( 'admin', '-' ),
    63             \IQLRSS\Driver::get_asset_url( 'admin.css' ),
     58            \IQLRSS\Driver::get_asset_url( 'css/admin.css' ),
    6459            array(),
    6560            \IQLRSS\Driver::get( 'version', '1.0.0' )
     
    6964        wp_register_script_module(
    7065            \IQLRSS\Driver::plugin_prefix( 'admin', '-' ),
    71             \IQLRSS\Driver::get_asset_url( 'admin.js' ),
     66            \IQLRSS\Driver::get_asset_url( 'js/admin.js' ),
    7267            array( 'jquery' ),
    7368            \IQLRSS\Driver::get( 'version', '1.0.0' )
     
    9388        $data = array(
    9489            'api_verified'  => \IQLRSS\Driver::get_ss_opt( 'api_key_valid', false ),
    95             'apiv1_verified'=> \IQLRSS\Driver::get_ss_opt( 'apiv1_key_valid', false ),
    9690            'global_adjustment_type' => \IQLRSS\Driver::get_ss_opt( 'global_adjustment_type', '' ),
     91            'store' => array(
     92                'currency_symbol' => get_woocommerce_currency_symbol( get_woocommerce_currency() ),
     93            ),
    9794            'rest' => array(
    9895                'nonce'     => wp_create_nonce( 'wp_rest' ),
    99                 'apiactions'=> get_rest_url( null, sprintf( '/%s/v1/apiactions',
     96                'settings'=> get_rest_url( null, sprintf( '/%s/v1/settings',
    10097                    \IQLRSS\Driver::get( 'slug' )
    10198                ) ),
     
    105102                'button_api_clearcache' => esc_html__( 'Clear API Cache', 'live-rates-for-shipstation' ),
    106103                'confirm_box_removal'   => esc_html__( 'Please confirm you would like to completely remove (x) custom boxes.', 'live-rates-for-shipstation' ),
     104                'confirm_modal_closure' => esc_html__( 'Changes you made may not be saved. Close modal window?', 'live-rates-for-shipstation' ),
     105                'error_field_required'  => esc_html__( 'This field is required, please enter a value.', 'live-rates-for-shipstation' ),
     106                'error_custombox_json'  => esc_html__( 'Something went wrong while saving your data. Please try again.', 'live-rates-for-shipstation' ),
    107107                'error_rest_generic'    => esc_html__( 'Something went wrong with the REST Request. Please resave permalinks and try again.', 'live-rates-for-shipstation' ),
    108108                'error_verification_required'       => esc_html__( 'Please click the Verify API button to ensure a connection exists.', 'live-rates-for-shipstation' ),
     109                'success_custombox_added'           => esc_html__( 'The Custom Box has been added to the list successfully!', 'live-rates-for-shipstation' ),
    109110                'desc_global_adjustment_percentage' => esc_html__( 'Example: IF UPS Ground is $7.25 and you input 15% ($1.08), the final shipping rate the customer sees is: $8.33', 'live-rates-for-shipstation' ),
    110111                'desc_global_adjustment_flatrate'   => esc_html__( 'Example: IF UPS Ground is $5.50 and you input $2.37, the final shipping rate the customer sees is: $7.87', 'live-rates-for-shipstation' ),
     
    196197
    197198    /**
    198      * REST Endpoint to validate the users API Key and clear API caches.
    199      *
    200      * @return void
    201      */
    202     public function api_actions_endpoint() {
    203 
    204         $prefix = \IQLRSS\Driver::get( 'slug' );
    205 
    206         // Handle ajax requests
    207         register_rest_route( "{$prefix}/v1", 'apiactions', array(
    208             'methods' => array( 'POST' ),
    209             'permission_callback' => fn() => is_user_logged_in(),
    210             'callback' => function( $request ) {
    211 
    212                 $params = $request->get_params();
    213                 if( ! isset( $params['action'] ) || empty( $params['action'] ) ) {
    214                     wp_send_json_error();
    215                 }
    216 
    217                 switch( $params['action'] ) {
    218 
    219                     // Clear the API Caches
    220                     case 'clearcache':
    221 
    222                         // Success!
    223                         $this->clear_cache();
    224                         wp_send_json_success();
    225 
    226                     break;
    227 
    228 
    229                     // Verify API Key
    230                     case 'verify':
    231 
    232                         // Error - Unknown Type
    233                         if( empty( $params['type'] ) || ! in_array( $params['type'], array( 'v1', 'v2' ) ) ) {
    234                             wp_send_json_error( esc_html__( 'System could not discern API type.', 'live-rates-for-shipstation' ), 401 );
    235 
    236                         // Error - v1 API missing key or secret.
    237                         } else if( 'v1' == $params['type'] && ( empty( $params['key'] ) || empty( $params['secret'] ) ) ) {
    238                             wp_send_json_error( esc_html__( 'The ShipStation [v1] API required both a valid [v1] key and [v1] secret.', 'live-rates-for-shipstation' ), 401 );
    239 
    240                         // Error v2 API missing api key.
    241                         } else if( empty( $params['key'] ) ) {
    242                             wp_send_json_error( esc_html__( 'The ShipStation v2 API requires an API key.', 'live-rates-for-shipstation' ), 401 );
    243                         }
    244 
    245                         $type = sanitize_title( $params['type'] );
    246                         $settings = array(
    247                             'v2'            => \IQLRSS\Driver::get_ss_opt( 'api_key' ),
    248                             'v2valid'       => \IQLRSS\Driver::get_ss_opt( 'api_key_valid' ),
    249                             'v2valid_time'  => \IQLRSS\Driver::get_ss_opt( 'api_key_vt' ),
    250                             'v1'            => \IQLRSS\Driver::get_ss_opt( 'apiv1_key' ),
    251                             'v1secret'      => \IQLRSS\Driver::get_ss_opt( 'apiv1_secret' ),
    252                             'v1valid'       => \IQLRSS\Driver::get_ss_opt( 'apiv1_key_valid' ),
    253                             'v1valid_time'  => \IQLRSS\Driver::get_ss_opt( 'apiv1_key_vt' ),
    254                         );
    255                         $keydata = array(
    256                             'old' => array(
    257                                 'key'    => $settings[ $type ],
    258                                 'secret' => $settings['v1secret'],
    259                             ),
    260                             'new' => array(
    261                                 'key'    => sanitize_text_field( $params['key'] ),
    262                                 'secret' => ( ! empty( $params['secret'] ) ) ? sanitize_text_field( $params['secret'] ) : '',
    263                             )
    264                         );
    265 
    266                         // Only allow verification once a day if the data is the same.
    267                         if( $keydata['old']['key'] == $keydata['new']['key'] ) {
    268 
    269                             $valid_time = $settings["{$type}valid_time"];
    270                             if( 'v1' == $type ) {
    271                                 $valid_time = ( $keydata['old']['secret'] != $keydata['new']['secret'] ) ? 0 : $valid_time;
    272                             }
    273 
    274                             // Return Early - We don't need to make a call, it is still valid.
    275                             if( ! empty( $valid_time ) && $valid_time >= gmdate( 'Ymd', strtotime( 'today' ) ) ) {
    276                                 wp_send_json_success();
    277                             }
    278 
    279                         }
    280 
    281                         // Verify the v1 API
    282                         if( 'v1' == $type ) {
    283 
    284                             // The API requires the keys to exist before being pinged.
    285                             \IQLRSS\Driver::set_ss_opt( 'apiv1_key', $keydata['new']['key'] );
    286                             \IQLRSS\Driver::set_ss_opt( 'apiv1_secret', $keydata['new']['secret'] );
    287 
    288                             // Ping the stores so that it sets the currently connected store ID.
    289                             $shipStationAPI = new Shipstation_Apiv1();
    290                             $request = $shipStationAPI->get_stores();
    291 
    292                             // Error - Something went wrong, the API should let us know.
    293                             if( is_wp_error( $request ) || empty( $request ) ) {
    294 
    295                                 // Revert to old key and secret.
    296                                 \IQLRSS\Driver::set_ss_opt( 'apiv1_key', $keydata['old']['key'] );
    297                                 \IQLRSS\Driver::set_ss_opt( 'apiv1_secret', $keydata['old']['secret'] );
    298 
    299                                 $message = ( is_wp_error( $request ) ) ? $request->get_error_message() : '';
    300                                 $code = ( is_wp_error( $request ) ) ? $request->get_error_code() : 400;
    301                                 wp_send_json_error( $message, $code );
    302 
    303                             }
    304 
    305                             // Success! - Denote v2 validity and valid time.
    306                             \IQLRSS\Driver::set_ss_opt( 'apiv1_key_valid', true );
    307                             \IQLRSS\Driver::set_ss_opt( 'apiv1_key_vt', gmdate( 'Ymd', strtotime( 'today' ) ) );
    308                             wp_send_json_success();
    309 
    310                         // Verify the v2 API
    311                         } else {
    312 
    313                             // The API requires the keys to exist before being pinged.
    314                             \IQLRSS\Driver::set_ss_opt( 'api_key', $keydata['new']['key'] );
    315 
    316                             // Ping the carriers so that they are cached.
    317                             $shipStationAPI = new Shipstation_Api();
    318                             $request = $shipStationAPI->get_carriers();
    319 
    320                             // Error - Something went wrong, the API should let us know.
    321                             if( is_wp_error( $request ) || empty( $request ) ) {
    322 
    323                                 // Revert to old key.
    324                                 \IQLRSS\Driver::get_ss_opt( 'api_key', $keydata['old']['key'] );
    325 
    326                                 $message = ( is_wp_error( $request ) ) ? $request->get_error_message() : '';
    327                                 $code = ( is_wp_error( $request ) ) ? $request->get_error_code() : 400;
    328                                 wp_send_json_error( $message, $code );
    329 
    330                             }
    331 
    332                             // Success! - Denote v2 validity and valid time.
    333                             \IQLRSS\Driver::set_ss_opt( 'api_key_valid', true );
    334                             \IQLRSS\Driver::set_ss_opt( 'api_key_vt', gmdate( 'Ymd', strtotime( 'today' ) ) );
    335                             wp_send_json_success();
    336 
    337                         }
    338 
    339                     break;
    340                 }
    341 
    342                 // Cases should return their own error/success.
    343                 wp_send_json_error();
    344             }
    345         ) );
    346 
    347     }
    348 
    349 
    350     /**
    351199     * Clear the API cache.
    352200     *
     
    363211         * The first WHERE ensures only `_transient_` and the 2nd ensures only our plugins transients.
    364212         */
    365         $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s AND option_name LIKE %s",
     213        $wpdb->query( $wpdb->prepare( "DELETE FROM %i WHERE option_name LIKE %s AND option_name LIKE %s", // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.UnsupportedIdentifierPlaceholder
     214            $wpdb->options,
    366215            $wpdb->esc_like( '_transient_' ) . '%',
    367216            '%' . $wpdb->esc_like( '_' . \IQLRSS\Driver::get( 'slug' ) . '_' ) . '%'
     
    369218
    370219        // Set transient to clear any WC_Session caches if they are found.
    371         $expires = absint( apply_filters( 'wc_session_expiration', DAY_IN_SECONDS * 2 ) );
     220        $expires = absint( apply_filters( 'wc_session_expiration', DAY_IN_SECONDS * 2 ) ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
    372221        set_transient( \IQLRSS\Driver::plugin_prefix( 'wcs_timeout' ), time(), $expires );
    373222
     
    388237        }
    389238
    390         $this->clear_cache();
    391 
    392     }
    393 
    394 
    395     /**
    396      * Denote the exported order as a transient.
    397      * Use the transient later to update the order via the v1 API.
    398      *
    399      * @param Integer $meta_id
    400      * @param Integer $order_id
    401      * @param String $meta_key
    402      * @param String $meta_value
    403      *
    404      * @return void
    405      */
    406     public function denote_shipstation_export( $meta_id, $order_id, $meta_key, $meta_value ) {
    407 
    408         if( '_shipstation_exported' != $meta_key || 'yes' != $meta_value ) {
    409             return;
    410         }
    411 
    412         $trans_key = \IQLRSS\Driver::plugin_prefix( 'exported_orders' );
    413         $order_ids = get_transient( $trans_key );
    414         $order_ids = ( ! empty( $order_ids ) ) ? $order_ids : array();
    415 
    416         // Return Early - Order ID already exists.
    417         if( in_array( $order_id, $order_ids ) ) {
    418             return;
    419         }
    420 
    421         $order_ids[] = $order_id;
    422         set_transient( $trans_key, $order_ids, HOUR_IN_SECONDS );
    423 
    424     }
    425 
    426 
    427     /**
    428      * If an `_exported_orders` transient exists
    429      * Update the order with some better info.
    430      *
    431      * @return void
    432      */
    433     public function update_exported_orders() {
    434 
    435         $trans_key = \IQLRSS\Driver::plugin_prefix( 'exported_orders' );
    436         $order_ids = get_transient( $trans_key );
    437 
    438         // Return Early - Delete transient, it's empty.
    439         if( empty( $order_ids ) || ! is_array( $order_ids ) ) {
    440             return delete_transient( $trans_key );
    441         }
    442 
    443         // Grab the oldest order while also priming the WC_Order cache.
    444         $wc_orders = wc_get_orders( array(
    445             'include'   => array_map( 'absint', $order_ids ),
    446             'orderby'   => 'date',
    447             'order'     => 'ASC',
    448             'limit'     => count( $order_ids ),
    449         ) );
    450 
    451         // Return Early - Could't associate WC_Orders with transient order ids.
    452         if( empty( $wc_orders ) ) {
    453             return delete_transient( $trans_key );
    454         }
    455 
    456         // Prime the cache
    457         // API v1 will always cache it's ShipStation data in the WC_Order as metadata.
    458         $apiv1 = new Shipstation_Apiv1( true );
    459         $apiv1->get_orders( array(
    460             'createDateEnd' => gmdate( 'c', time() ),
    461         ) );
    462 
    463         $api = new Shipstation_Api( true );
    464         $api->create_shipments_from_wc_orders( $wc_orders );
    465 
    466         return delete_transient( $trans_key );
     239        \IQLRSS\Driver::clear_cache();
    467240
    468241    }
     
    514287    public function append_shipstation_integration_settings( $fields ) {
    515288
    516         $carriers = array();
     289        $carriers = array(
     290            '' => esc_html__( 'ShipStation carriers may still be loading...', 'live-rates-for-shipstation' ),
     291        );
    517292        $appended_fields = array();
    518293
     
    520295
    521296            $carrier_desc = esc_html__( 'Select which ShipStation carriers you would like to see live shipping rates from.', 'live-rates-for-shipstation' );
    522             $shipStationAPI = new Shipstation_Api();
    523             $response = $shipStationAPI->get_carriers();
    524 
     297            $response = ( new Api\Shipstation() )->get_carriers();
     298
     299            $carriers = array();
    525300            if( is_a( $response, 'WP_Error' ) ) {
    526301                $carriers[''] = $response->get_error_message();
     
    552327                    'default'       => '',
    553328                );
    554 
    555                 // $appended_fields[ \IQLRSS\Driver::plugin_prefix( 'apiv1_key' ) ] = array(
    556                 //  'title'         => esc_html__( 'ShipStation [v1] API Key', 'live-rates-for-shipstation' ),
    557                 //  'type'          => 'password',
    558                 //  'description'   => esc_html__( 'See "ShipStation REST API Key" description, but instead of selecting [v2], select [v1].', 'live-rates-for-shipstation' ),
    559                 //  'default'       => '',
    560                 // );
    561 
    562                 // $appended_fields[ \IQLRSS\Driver::plugin_prefix( 'apiv1_secret' ) ] = array(
    563                 //  'title'         => esc_html__( 'ShipStation [v1] API Secret', 'live-rates-for-shipstation' ),
    564                 //  'type'          => 'password',
    565                 //  'description'   => esc_html__( 'The v1 API is _required_ to manage orders. The v2 API handles Live Rates.', 'live-rates-for-shipstation' ),
    566                 //  'default'       => '',
    567                 // );
    568329
    569330                $appended_fields[ \IQLRSS\Driver::plugin_prefix( 'carriers' ) ] = array(
     
    643404            }
    644405
    645             $this->clear_cache();
    646         }
    647 
    648         // No [v1] API Key? Invalid!
    649         $apiv1_key_key = \IQLRSS\Driver::plugin_prefix( 'apiv1_key' );
    650         if( ! isset( $settings[ $apiv1_key_key ] ) || empty( $settings[ $apiv1_key_key ] ) ) {
    651 
    652             $settings[ \IQLRSS\Driver::plugin_prefix( 'apiv1_key_valid' ) ] = false;
    653             if( isset( $settings[ \IQLRSS\Driver::plugin_prefix( 'apiv1_key_vt' ) ] ) ) {
    654                 unset( $settings[ \IQLRSS\Driver::plugin_prefix( 'apiv1_key_vt' ) ] );
    655             }
    656 
    657             $this->clear_cache();
     406            \IQLRSS\Driver::clear_cache();
    658407        }
    659408
     
    745494
    746495            // Integration > ShipStation settings page
    747             $enqueue = ( $enqueue || isset( $_GET, $_GET['section'] ) && 'shipstation' == $_GET['section'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
     496            $enqueue = ( $enqueue || ( isset( $_GET, $_GET['section'] ) && 'shipstation' == $_GET['section'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
    748497
    749498            // Overprotective WooCommerce settings page check
  • live-rates-for-shipstation/trunk/core/shipping-method-shipstation.php

    r3376459 r3407166  
    22/**
    33 * ShipStation Live Shipping Rates Method
     4 *
     5 * @todo Consider moving Shipping Calculations into it's own class.
     6 *
     7 * @link https://www.fedex.com/en-us/shipping/one-rate.html
     8 * @link https://www.usps.com/ship/priority-mail.htm#flatrate
     9 * @link https://www.ups.com/worldshiphelp/WSA/ENG/AppHelp/mergedProjects/CORE/Codes/Package_Type_Codes.htm
    410 *
    511 * :: Action Hooks
     
    2834
    2935    /**
    30      * Array of expected dimension keys (width, height, length, weight)
    31      *
    32      * @var Array
    33      */
    34     protected $dimension_keys = array(
    35         'width'     => 'width',
    36         'height'    => 'height',
    37         'length'    => 'length',
    38         'weight'    => 'weight',
    39     );
    40 
    41 
    42     /**
    4336     * Array of store specific settings.
    4437     *
     
    8679
    8780        $this->plugin_prefix        = \IQLRSS\Driver::get( 'slug' );
    88         $this->shipStationApi       = new Shipstation_Api();
     81        $this->shipStationApi       = new Api\Shipstation();
    8982        $this->id                   = \IQLRSS\Driver::plugin_prefix( 'shipstation' );
    9083        $this->instance_id          = absint( $instance_id );
     
    133126     */
    134127    private function action_hooks() {
     128
    135129        add_action( 'woocommerce_update_options_shipping_' . $this->id, array( $this, 'process_admin_options' ) );
     130        add_action( 'admin_footer', array( $this, 'hide_zone_setting_fields' ) );
     131
    136132    }
    137133
     
    146142        ( new \IQLRSS\Core\Settings_Shipstation() )->clear_cache();
    147143        return parent::process_admin_options();
     144
     145    }
     146
     147
     148    /**
     149     * Hide Shipping Zone setting fields
     150     * 1). Since they're row options we rarely have markup control over.
     151     * 2). Since modules load JS a bit later.
     152     *
     153     * @return void
     154     */
     155    public function hide_zone_setting_fields() {
     156
     157        ?><script type="text/javascript">
     158
     159            /* Hide onebox when not set */
     160            if( document.getElementById( 'woocommerce_iqlrss_shipstation_packing' ) ) { ( function() {
     161                if( 'onebox' != document.getElementById( 'woocommerce_iqlrss_shipstation_packing' ).value ) {
     162                    document.getElementById( 'woocommerce_iqlrss_shipstation_packing' ).closest( 'tr' ).nextElementSibling.style.display = 'none';
     163                }
     164            } )(); }
     165        </script><?php
    148166
    149167    }
     
    293311                            if( isset( $rate_arr['other_costs'] ) ) {
    294312                                foreach( $rate_arr['other_costs'] as $o_slug => $o_amount ) {
    295                                     $new_display .= sprintf( ' | %s: %s', ucwords( $o_slug ), wc_price( $o_amount ) );
     313                                    $new_display .= sprintf( ' | %s: %s', ucwords( str_replace( array( '-', '_' ), ' ', $o_slug ) ), wc_price( $o_amount ) );
    296314                                }
    297315                            }
     
    314332                    $display_arr = array();
    315333                    foreach( $value as $i => $box_arr ) {
     334
     335                        /* translators: %1$d is box/package count (1,2,3). */
     336                        $box_name = sprintf( esc_html__( 'Package %1$d', 'live-rates-for-shipstation' ), $i + 1 );
     337                        if( ! empty( $box_arr['nickname'] ) ) {
     338                            $box_name = $box_arr['nickname'];
     339                        }
    316340
    317341                        $names = esc_html__( 'Product', 'live-rates-for-shipstation' );
     
    323347                            }, $box_arr['packed'] );
    324348                        }
    325 
    326349                        $display_arr[] = sprintf( '%s ( %s ) [ %s %s ( %s x %s x %s %s ) ]',
    327 
    328                             /* translators: %1$d is box/package count (1,2,3). */
    329                             sprintf( esc_html__( 'Package %1$d', 'live-rates-for-shipstation' ), $i + 1 ),
     350                            $box_name,
    330351                            implode( ', ', (array)$names ),
    331352                            $box_arr['weight']['value'],
     
    388409    protected function init_instance_form_fields() {
    389410
    390         $this->instance_form_fields = array(
     411        $settings = array(
    391412            'title' => array(
    392413                'title'         => esc_html__( 'Title', 'live-rates-for-shipstation' ),
     
    395416                'default'       => esc_html__( 'ShipStation Rates', 'live-rates-for-shipstation' ),
    396417                'desc_tip'      => true,
     418            ),
     419            'minweight' => array(
     420                'title'         => esc_html__( 'Product Weight Fallback', 'live-rates-for-shipstation' ),
     421                'type'          => 'text',
     422                'description'   => esc_html__( 'This value will be used if both weight and dimensions are missing from any given product. ShipStation at minimum needs a product weight to retrieve rates.', 'live-rates-for-shipstation' ),
    397423            ),
    398424            'packing' => array(
     
    403429                    'individual'    => esc_html__( 'Pack items individually', 'live-rates-for-shipstation' ),
    404430                    'wc-box-packer' => esc_html__( 'Pack items using Custom Packing Boxes', 'live-rates-for-shipstation' ),
     431                    'onebox'        => esc_html__( 'Pack items into one package derived from products', 'live-rates-for-shipstation' ),
    405432                ),
    406433                'description'   => esc_html__( 'Individually can be more costly. Custom packing boxes will automatically fit as many products in set dimensions lowering shipping costs.', 'live-rates-for-shipstation' ),
     434            ),
     435            'packing_sub' => array(
     436                'title'         => esc_html__( 'Package Dimensions', 'live-rates-for-shipstation' ),
     437                'type'          => 'select',
     438                'options'       => array(
     439                    'weightonly'    => esc_html__( 'Total weight', 'live-rates-for-shipstation' ),
     440                    'stacked'       => esc_html__( 'Stacked vertically', 'live-rates-for-shipstation' ),
     441                ),
     442                'description'   => esc_html__( 'Stacked vertically - sums product heights and weights, takes largest of other dimensions. Weight only sums product weights and retrieves rates using the total.', 'live-rates-for-shipstation' ),
    407443            ),
    408444            'customboxes' => array(
     
    414450        );
    415451
     452
     453        /**
     454         * Allow filtering the Shipping Zone settings
     455         *
     456         * @hook filter
     457         *
     458         * @param Array $settings
     459         * @param \IQLRSS\Core\Shipping_Method_Shipstation $this
     460         *
     461         * @return Array $settings
     462         */
     463        $settings = apply_filters( 'iqlrss/zone/settings', $settings, $this );
     464        $this->instance_form_fields = $settings;
     465
     466    }
     467
     468
     469    /**
     470     * Automatic dynamic method inherited from parent.
     471     * Generate HTML for custom boxes fields.
     472     *
     473     * @return String - HTML
     474     */
     475    public function generate_customboxes_html() {
     476
     477        $prefix         = $this->plugin_prefix;
     478        $show_custom    = ( 'wc-box-packer' == $this->get_option( 'packing', 'individual' ) );
     479        $saved_boxes    = $this->get_option( 'customboxes', array() );
     480        $packages       = $this->get_package_options();
     481
     482        ob_start();
     483            include 'assets/views/customboxes-table.php';
     484        return ob_get_clean();
     485
     486    }
     487
     488
     489    /**
     490     * Validate customboxes.
     491     *
     492     * @return Array $boxes
     493     */
     494    public function validate_customboxes_field() {
     495
     496        if( ! isset( $_POST['_wpnonce'] ) ) {
     497            return;
     498        }
     499
     500        $nonce = sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) );
     501        if( ! wp_verify_nonce( $nonce, 'woocommerce-settings' ) ) {
     502            return;
     503        } else if( ! isset( $_POST['custombox'] ) || ! is_array( $_POST['custombox'] ) ) {
     504            return;
     505        }
     506
     507        // Input sanitized during processing.
     508        $posted_boxes = wp_unslash( $_POST['custombox'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
     509
     510        $boxes = array();
     511        foreach( $posted_boxes as $box_arr ) {
     512
     513            if( isset( $box_arr['json'] ) ) {
     514
     515                $json = json_decode( $box_arr['json'], true );
     516                if( empty( $json['outer'] ) ) continue;
     517
     518                $boxes[] = array(
     519                    'active' => absint( $json['active'] ),
     520                    'preset' => ( isset( $json['preset'] ) ) ? sanitize_text_field( $json['preset'] ) : '',
     521                    'nickname' => sanitize_text_field( $json['nickname'] ),
     522                    'outer' => array(
     523                        'length'    => floatval( $json['outer']['length'] ),
     524                        'width'     => floatval( $json['outer']['width'] ),
     525                        'height'    => floatval( $json['outer']['height'] ),
     526                    ),
     527                    'inner' => array(
     528                        'length'    => floatval( $json['inner']['length'] ),
     529                        'width'     => floatval( $json['inner']['width'] ),
     530                        'height'    => floatval( $json['inner']['height'] ),
     531                    ),
     532                    'weight'    => floatval( $json['weight'] ),
     533                    'weight_max'=> floatval( $json['weight_max'] ),
     534                    'price'     => floatval( $json['price'] ),
     535                    'carrier_code' => ( isset( $json['carrier_code'] ) ) ? sanitize_text_field( $json['carrier_code'] ) : '',
     536                );
     537
     538            }
     539        }
     540
     541        usort( $boxes, function( $arrA, $arrB ) {
     542            return strcasecmp( $arrA['nickname'], $arrB['nickname'] );
     543        } );
     544
     545        return $boxes;
     546
    416547    }
    417548
     
    459590
    460591        ob_start();
    461             include 'views/services-table.php';
    462         return ob_get_clean();
    463 
    464     }
    465 
    466 
    467     /**
    468      * Automatic dynamic method inherited from parent.
    469      * Generate HTML for custom boxes fields.
    470      *
    471      * @return String - HTML
    472      */
    473     public function generate_customboxes_html() {
    474 
    475         $prefix         = $this->plugin_prefix;
    476         $show_custom    = ( 'wc-box-packer' == $this->get_option( 'packing', 'individual' ) );
    477         $saved_boxes    = $this->get_option( 'customboxes', array() );
    478 
    479         ob_start();
    480             include 'views/customboxes-table.php';
     592            include 'assets/views/services-table.php';
    481593        return ob_get_clean();
    482594
     
    563675
    564676
    565     /**
    566      * Validate customboxes field.
    567      *
    568      * @return Array $boxes
    569      */
    570     public function validate_customboxes_field() {
    571 
    572         if( ! isset( $_POST['_wpnonce'] ) ) {
    573             return;
    574         }
    575 
    576         $nonce = sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) );
    577         if( ! wp_verify_nonce( $nonce, 'woocommerce-settings' ) ) {
    578             return;
    579         } else if( ! isset( $_POST['custombox'] ) || ! is_array( $_POST['custombox'] ) ) {
    580             return;
    581         }
    582 
    583         // Input sanitized during processing.
    584         $posted_boxes = wp_unslash( $_POST['custombox'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
    585 
    586         $boxes = array();
    587         foreach( $posted_boxes as $box_arr ) {
    588 
    589             $vals = array_filter( $box_arr, 'is_numeric' );
    590             if( count( $vals ) < 7 ) continue;
    591 
    592             $boxes[] = array(
    593                 'outer' => array(
    594                     'length'    => floatval( $box_arr['ol'] ),
    595                     'width'     => floatval( $box_arr['ow'] ),
    596                     'height'    => floatval( $box_arr['oh'] ),
    597                 ),
    598                 'inner' => array(
    599                     'length'    => floatval( $box_arr['il'] ),
    600                     'width'     => floatval( $box_arr['iw'] ),
    601                     'height'    => floatval( $box_arr['ih'] ),
    602                 ),
    603                 'weight'    => floatval( $box_arr['w'] ),
    604                 'weight_max'=> floatval( $box_arr['wm'] ),
    605             );
    606 
    607         }
    608 
    609         return $boxes;
    610 
    611     }
    612 
    613 
    614677
    615678    /**------------------------------------------------------------------------------------------------ **/
     
    619682     * Calculate shipping costs
    620683     *
    621      * @param Array $package
     684     * @param Array $packages
    622685     *
    623686     * @return void
     
    631694        // Try to pull from cache. This may set $this->rates
    632695        // Return Early - We have cached rates to work with!
    633         $packages_hash = $this->check_packages_rate_cache( $packages );
     696        $this->check_packages_rate_cache( $packages );
    634697        if( ! empty( $this->rates ) ) {
    635698            return;
     
    642705        $enabled_services = $this->get_enabled_services();
    643706        if( empty( $enabled_services ) ) {
     707            $this->log( esc_html__( 'No enabled carrier services found. Please enable carrier services within the shipping zone.', 'live-rates-for-shipstation' ) );
    644708            return;
    645709        }
     
    669733        );
    670734
    671         // Individual Packaging
    672         if( 'individual' == $packing_type ) {
    673             $item_requests = $this->get_individual_requests( $packages['contents'] );
    674 
    675         // WC Boxed Packaging
    676         } else {
    677             $item_requests = $this->get_custombox_requests( $packages['contents'] );
    678         }
    679 
    680         // Rates groups shipping estimates by service ID.
     735        $item_requests = array();
     736        $callback = sprintf( 'group_requestsby_%s', str_replace( '-', '_', $packing_type ) );
     737        if( method_exists( $this, $callback ) ) {
     738            $item_requests = call_user_func( array( $this, $callback ), $packages['contents'] );
     739        }
     740
     741
     742        /**
     743         * Allow filtering the packages before requesting estimates.
     744         *
     745         * The returned array should follow this format:
     746         * Multi-dimensional Array
     747         *
     748         * $item_requests = Array( Array(
     749         * ~ Required Fields:
     750         *      '_name' => '$productID|$productName', - This format makes it easy to show the Shop Manager what's packed into the box.
     751         *      'dimensions' => array(
     752         *          'length => 123,
     753         *          'width' => 123,
     754         *          'height' => 123,
     755         *          'unit' => 'inch', - ShipStation expects a specific string. See \IQLRSS\Core\Api\Shipstation::convert_unit_term( $unit )
     756         *      ),
     757         *      'weight' => array(
     758         *          'value' => 123,
     759         *          'unit' => 'pound',  - ShipStation expects a specific string. See \IQLRSS\Core\Api\Shipstation::convert_unit_term( $unit )
     760         *      ),
     761         *
     762         * ~ Entirely optional, but the system will try to read them if available.
     763         *      'packed' => Array( '$productID|$productName', '$productID|$productName' ),
     764         *      'price'  => 123,
     765         *      'nickname' => 'String' - Displayed to the Shop Owner on the Edit Order page.
     766         *      'box_weight' => 123,
     767         *      'box_max_weight'=> 123,
     768         *      'package_code' => 'ups_ground',
     769         *      'carrier_code' => 'ups', - Carrier Code should match what ShipStation expects. I.E. fedex_walleted. This is to group packages with carriers for discounts.
     770         * ) )
     771         *
     772         * @hook filter
     773         *
     774         * @param Array $item_requests - Array of Package dimensions that the API will use to get rates on. Multidimensional Array.
     775         * @param Array $packages - The cart contents. See $packages['contents'] for items.
     776         * @param \IQLRSS\Core\Shipping_Method_Shipstation $this
     777         *
     778         * @return Array $settings
     779         */
     780        $filtered_requests = apply_filters( 'iqlrss/shipping/packages', $item_requests, $packages, $packing_type, $this );
     781
     782        // IF the hash doesn't match what was given to the filter, note it in the logs so the store owner will know.
     783        $item_req_hash      = ( ! empty( $item_requests ) ) ? md5( maybe_serialize( $item_requests ) ) : '';
     784        $filtered_req_hash  = ( ! empty( $filtered_requests ) ) ? md5( maybe_serialize( $filtered_requests ) ) : '';
     785        if( $item_req_hash !== $filtered_req_hash ) {
     786            $this->log( esc_html__( 'The Shipping packages were modified by a 3rd party using the `iqlrss/shipping/packages` filter hook.', 'live-rates-for-shipstation' ), 'notice' );
     787        }
     788
     789        /**
     790         * We have to return reates per package.
     791         * The /rates/estimate endpoint requires less info
     792         * and /rates endpoint is way slower.
     793         */
    681794        $rates = array();
    682 
    683         /**
    684          * This has to be done per package as the other rates endpoint
    685          * requires the customers address1 for verification and really
    686          * it's not much faster.
    687          */
    688         foreach( $item_requests as $item_id => $req ) {
     795        foreach( $filtered_requests as $item_id => $req ) {
    689796
    690797            // Create the API request combining the package (weight, dimensions), general request data, and the carrier info.
     
    700807            // Continue - Something went wrong, should be logged on the API side.
    701808            $available_rates = $this->shipStationApi->get_shipping_estimates( $api_request );
     809
    702810            if( is_wp_error( $available_rates ) || empty( $available_rates ) ) {
    703811                continue;
     
    711819                }
    712820
     821                $ratehash    = md5( sprintf( '%s%s', $shiprate['code'], $shiprate['carrier_id'] ) );
    713822                $service_arr = $enabled_services[ $shiprate['carrier_id'] ][ $shiprate['code'] ];
    714                 $cost = floatval( $shiprate['cost'] );
    715                 $ratemeta = array(
    716                     '_name'=> ( isset( $req['_name'] ) ) ? $req['_name'] : '', // Item product name.
     823                $cost        = floatval( $shiprate['cost'] );
     824                $rate_name   = ( isset( $req['_name'] ) ) ? $req['_name'] : '';
     825                $rate_name   = ( empty( $rate_name ) && isset( $req['nickname'] ) ) ? $req['nickname'] : $rate_name;
     826                $ratemeta    = array(
     827                    '_name'=> $rate_name, // Item products(ID|Name) or box nickname.
    717828                    'rate' => $cost,
    718829                );
     
    767878                }
    768879
     880                // Maybe a package price
     881                if( 'wc-box-packer' == $packing_type && isset( $req['price'] ) && ! empty( $req['price'] ) ) {
     882                    $cost += floatval( $req['price'] );
     883                    $ratemeta['other_costs']['box_price'] = $req['price'];
     884                }
     885
    769886                // Maybe apply per item.
    770887                if( 'individual' == $packing_type ) {
     
    774891
    775892                // Set rate or append the estimated item ship cost.
    776                 if( ! isset( $rates[ $shiprate['code'] ] ) ) {
    777 
    778                     $rates[ $shiprate['code'] ] = array(
    779                         'id'        => $shiprate['code'],
     893                if( ! isset( $rates[ $ratehash ] ) ) {
     894
     895                    $rates[ $ratehash ] = array(
     896                        'id'        => $ratehash,
    780897                        'label'     => ( ! empty( $service_arr['nickname'] ) ) ? $service_arr['nickname'] : $shiprate['name'],
    781898                        'package'   => $packages,
     
    795912
    796913                } else {
    797                     $rates[ $shiprate['code'] ]['cost'][] = $cost;
     914                    $rates[ $ratehash ]['cost'][] = $cost;
    798915                }
    799916
    800917                // Merge item rates
    801                 $rates[ $shiprate['code'] ]['meta_data']['rates'] = array_merge(
    802                     $rates[ $shiprate['code'] ]['meta_data']['rates'],
     918                $rates[ $ratehash ]['meta_data']['rates'] = array_merge(
     919                    $rates[ $ratehash ]['meta_data']['rates'],
    803920                    array( $ratemeta ),
    804921                );
    805922
    806923                // Merge item boxes
    807                 $rates[ $shiprate['code'] ]['meta_data']['boxes'] = array_merge(
    808                     $rates[ $shiprate['code'] ]['meta_data']['boxes'],
     924                $rates[ $ratehash ]['meta_data']['boxes'] = array_merge(
     925                    $rates[ $ratehash ]['meta_data']['boxes'],
    809926                    array( $req ),
    810927                );
     
    814931        }
    815932
    816         $single_lowest          = \IQLRSS\Driver::get_ss_opt( 'return_lowest', 'no' );
    817         $single_lowest_label    = \IQLRSS\Driver::get_ss_opt( 'return_lowest_label', '' );
     933        $single_lowest       = \IQLRSS\Driver::get_ss_opt( 'return_lowest', 'no' );
     934        $single_lowest_label = \IQLRSS\Driver::get_ss_opt( 'return_lowest_label', '' );
    818935
    819936        // Add all shipping rates, let the user decide.
     
    822939            foreach( $rates as $rate_arr ) {
    823940
    824                 // Skip incomplete rate requests
    825                 if( count( $item_requests ) != count( $rate_arr['cost'] ) ) {
    826                     continue;
     941                // If more than 1 rate, add the cheapest.
     942                if( count( $rate_arr['cost'] ) > 1 ) {
     943                    usort( $rate_arr['cost'], fn( $r1, $r2 ) => ( (float)$r1 < (float)$r2 ) ? -1 : 1 );
     944                    $rate_arr['cost'] = (array)array_shift( $rate_arr['cost'] );
    827945                }
    828946
    829947                // WooCommerce skips serialized data when outputting order item meta, this is a workaround.
    830948                // See hooks above for formatting.
    831                 $rate_arr['meta_data']['rates'] = json_encode( $rate_arr['meta_data']['rates'] );
    832                 $rate_arr['meta_data']['boxes'] = json_encode( $rate_arr['meta_data']['boxes'] );
     949                $rate_arr['meta_data']['rates'] = wp_json_encode( $rate_arr['meta_data']['rates'] );
     950                $rate_arr['meta_data']['boxes'] = wp_json_encode( $rate_arr['meta_data']['boxes'] );
    833951
    834952                $this->add_rate( $rate_arr );
     
    855973            // WooCommerce skips serialized data when outputting order item meta, this is a workaround.
    856974            // See hooks above for formatting.
    857             $rates[ $lowest_service ]['meta_data']['rates'] = json_encode( $rates[ $lowest_service ]['meta_data']['rates'] );
    858             $rates[ $lowest_service ]['meta_data']['boxes'] = json_encode( $rates[ $lowest_service ]['meta_data']['boxes'] );
     975            $rates[ $lowest_service ]['meta_data']['rates'] = wp_json_encode( $rates[ $lowest_service ]['meta_data']['rates'] );
     976            $rates[ $lowest_service ]['meta_data']['boxes'] = wp_json_encode( $rates[ $lowest_service ]['meta_data']['boxes'] );
    859977
    860978            $this->add_rate( $rates[ $lowest_service ] );
     
    862980        }
    863981
    864         // Add a cache key to check against.
    865         WC()->session->set( $this->plugin_prefix, array_merge(
    866             WC()->session->get( $this->plugin_prefix, array() ),
    867             array( 'method_hash' => $packages_hash ),
     982        $cachehash = $this->generate_packages_cache_key( $packages );
     983        if( empty( $cachehash ) ) return;
     984
     985        // Cache packages to prevent multiple requests.
     986        WC()->session->set( $this->plugin_prefix . '_packages', array_merge(
     987            WC()->session->get( $this->plugin_prefix . '_packages', array() ),
     988            array( 'method_hash' => $cachehash ),
    868989            array( 'method_cache_time' => time() ),
    869990        ) );
     
    8791000     * @return Array $requests
    8801001     */
    881     protected function get_individual_requests( $items ) {
    882 
    883         $item_requests = array();
     1002    public function group_requestsby_individual( $items ) {
     1003
     1004        $item_requests  = array();
     1005        $default_weight = $this->get_option( 'minweight', '' );
     1006
    8841007        foreach( $items as $item_id => $item ) {
    8851008
     
    8941017                    $item['data']->get_name(),
    8951018                ),
     1019                'weight' => ( ! empty( $item['data']->get_weight() ) ) ? $item['data']->get_weight() : $default_weight,
    8961020            );
    8971021            $physicals = array_filter( array(
    898                 'weight'    => $item['data']->get_weight(),
    8991022                'length'    => $item['data']->get_length(),
    9001023                'width'     => $item['data']->get_width(),
     
    9031026
    9041027            // Return Early - Product missing one of the 4 key dimensions.
    905             if( count( $physicals ) < 4 ) {
     1028            if( count( $physicals ) < 3 || empty( $request['weight'] ) ) {
    9061029                $this->log( sprintf(
    9071030
    9081031                    /* translators: %1$d is the Product ID. %2$s is the Product Dimensions separated by a comma. */
    909                     esc_html__( 'Product ID #%1$d missing (%2$s) dimensions. Shipping calculations terminated.', 'live-rates-for-shipstation' ),
     1032                    esc_html__( 'Product ID #%1$d missing (%2$s) dimensions. Weight is a minimum requirement. Shipping calculations terminated.', 'live-rates-for-shipstation' ),
     1033                    $item['product_id'],
     1034                    implode( ', ', array_diff_key( array(
     1035                        'length'    => 'length',
     1036                        'width'     => 'width',
     1037                        'height'    => 'height',
     1038                        'weight'    => 'weight',
     1039                    ), $physicals + array( 'weight' => $request['weight'] ) ) )
     1040                ) );
     1041
     1042                return array();
     1043            }
     1044
     1045            // Set rate request dimensions.
     1046            sort( $physicals );
     1047            if( 3 == count( $physicals ) ) {
     1048                $request['dimensions'] = array(
     1049                    'length'    => round( wc_get_dimension( $physicals[2], $this->store_data['dim_unit'] ), 2 ),
     1050                    'width'     => round( wc_get_dimension( $physicals[1], $this->store_data['dim_unit'] ), 2 ),
     1051                    'height'    => round( wc_get_dimension( $physicals[0], $this->store_data['dim_unit'] ), 2 ),
     1052                    'unit'      => $this->shipStationApi->convert_unit_term( $this->store_data['dim_unit'] ),
     1053                );
     1054            }
     1055
     1056            // Set rate request weight.
     1057            if( ! empty( $request['weight'] ) ) {
     1058                $request['weight'] = array(
     1059                    'value' => (float)round( wc_get_weight( $request['weight'], $this->store_data['weight_unit'] ), 2 ),
     1060                    'unit'  => $this->shipStationApi->convert_unit_term( $this->store_data['weight_unit'] ),
     1061                );
     1062            }
     1063
     1064            $item_requests[ $item_id ] = $request;
     1065
     1066        }
     1067
     1068        return $item_requests;
     1069
     1070    }
     1071
     1072
     1073    /**
     1074     * One Big Box
     1075     * Group all the products by weight and get rates by total weight.
     1076     *
     1077     * @param Array $items
     1078     *
     1079     * @return Array $requests
     1080     */
     1081    public function group_requestsby_onebox( $items ) {
     1082
     1083        $default_weight = $this->get_option( 'minweight', 0 );
     1084        $subtype        = $this->get_option( 'packing_sub', 'weightonly' );
     1085        $dimensions = array(
     1086            'running' => array_combine( array( 'length', 'width', 'height', 'weight' ), array_fill( 0, 4, 0 ) ),
     1087            'largest' => array_combine( array( 'length', 'width', 'height', 'weight' ), array_fill( 0, 4, 0 ) ),
     1088        );
     1089
     1090        foreach( $items as $item_id => $item ) {
     1091
     1092            // Continue - No shipping needed for product.
     1093            if( ! $item['data']->needs_shipping() ) {
     1094                continue;
     1095            }
     1096
     1097            $request = array(
     1098                '_name' => sprintf( '%s|%s',
     1099                    $item['data']->get_id(),
     1100                    $item['data']->get_name(),
     1101                ),
     1102                'weight' => ( ! empty( $item['data']->get_weight() ) ) ? $item['data']->get_weight() : $default_weight,
     1103            );
     1104
     1105            // Return Early - Missing minimum requirement: weight.
     1106            if( empty( $request['weight'] ) ) {
     1107
     1108                $this->log( sprintf(
     1109
     1110                    /* translators: %1$d is the Product ID. */
     1111                    esc_html__( 'Product ID #%1$d missing weight. Shipping Zone weight fallback could not be used. Shipping calculations terminated.', 'live-rates-for-shipstation' ),
     1112                    $item['product_id']
     1113                ) );
     1114
     1115                return array();
     1116
     1117            }
     1118
     1119            $dimensions['running']['weight'] = $dimensions['running']['weight'] + ( floatval( $request['weight'] ) * $item['quantity'] );
     1120            $dimensions['running']['height'] = $dimensions['running']['height'] + ( floatval( $item['data']->get_height() ) * $item['quantity'] );
     1121            $dimensions['largest'] = array(
     1122                'length'    => ( $dimensions['largest']['length'] < $item['data']->get_length() ) ? $item['data']->get_length() : $dimensions['largest']['length'],
     1123                'width'     => ( $dimensions['largest']['width'] < $item['data']->get_width() )   ? $item['data']->get_width()  : $dimensions['largest']['width'],
     1124                'height'    => ( $dimensions['largest']['height'] < $item['data']->get_height() ) ? $item['data']->get_height() : $dimensions['largest']['height'],
     1125                'weight'    => ( $dimensions['largest']['weight'] < $request['weight'] )          ? $request['weight']          : $dimensions['largest']['weight'],
     1126            );
     1127
     1128        }
     1129
     1130        // Return Early - Rates by total weight.
     1131        if( 'weightonly' == $subtype ) {
     1132
     1133            return array( array(
     1134                'weight' => array(
     1135                    'value' => (float)round( wc_get_weight( $dimensions['running']['weight'], $this->store_data['weight_unit'] ), 2 ),
     1136                    'unit'  => $this->shipStationApi->convert_unit_term( $this->store_data['weight_unit'] ),
     1137                ),
     1138            ) );
     1139
     1140        }
     1141
     1142        $physicals = array_filter( array(
     1143            'length'    => $dimensions['largest']['length'],
     1144            'width'     => $dimensions['largest']['width'],
     1145            'height'    => $dimensions['running']['height'],
     1146            'weight'    => $dimensions['running']['weight'],
     1147        ) );
     1148
     1149        // Return Early - Error - Missing dimensions to work with.
     1150        if( $physicals < 4 ) {
     1151
     1152            $this->log( sprintf(
     1153
     1154                /* translators: %1$d is the Product ID. %2$s is the Product Dimensions separated by a comma. */
     1155                esc_html__( 'OneBox rate requestion missing dimensions (%1$s). Weight is a minimum requirement. Shipping calculations terminated.', 'live-rates-for-shipstation' ),
     1156                implode( ', ', array_diff_key( array(
     1157                    'length'    => 'length',
     1158                    'width'     => 'width',
     1159                    'height'    => 'height',
     1160                    'weight'    => 'weight',
     1161                ), $physicals ) )
     1162            ) );
     1163
     1164            return array();
     1165
     1166        }
     1167
     1168        // Default - Stacked Verticially
     1169        return array( array(
     1170            'weight' => array(
     1171                'unit'  => $this->shipStationApi->convert_unit_term( $this->store_data['weight_unit'] ),
     1172                'value' => (float)round( wc_get_weight( $physicals['weight'], $this->store_data['weight_unit'] ), 2 ),
     1173            ),
     1174            'dimensions' => array(
     1175                'unit'      => $this->shipStationApi->convert_unit_term( $this->store_data['dim_unit'] ),
     1176
     1177                // Largest
     1178                'length'    => round( wc_get_dimension( $physicals['length'], $this->store_data['dim_unit'] ), 2 ),
     1179                'width'     => round( wc_get_dimension( $physicals['width'], $this->store_data['dim_unit'] ), 2 ),
     1180
     1181                // Running
     1182                'height'    => round( wc_get_dimension( $physicals['height'], $this->store_data['dim_unit'] ), 2 ),
     1183            ),
     1184        ) );
     1185
     1186    }
     1187
     1188
     1189    /**
     1190     * Return an array of API requests for custom packed boxes.
     1191     * Shoutout to Mike Jolly & Co.
     1192     *
     1193     * @param Array $items
     1194     *
     1195     * @return Array $requests
     1196     */
     1197    public function group_requestsby_wc_box_packer( $items ) {
     1198
     1199        $item_requests  = array();
     1200        $boxes          = $this->get_option( 'customboxes', array() );
     1201        $default_weight = $this->get_option( 'minweight', '' );
     1202
     1203        /* Return Early - No custom boxes found. */
     1204        if( empty( $boxes ) ) {
     1205            $this->log( esc_html__( 'Custom Boxes selected, but no boxes found. Items packed individually', 'live-rates-for-shipstation' ), 'warning' );
     1206            return $this->group_requestsby_individual( $items );
     1207        }
     1208
     1209        if( ! class_exists( '\IQRLSS\WC_Box_Packer\WC_Boxpack' ) ) {
     1210            include_once 'wc-box-packer/class-wc-boxpack.php';
     1211        }
     1212
     1213        // Setup the WC_Boxpack boxes based on user submitted custom boxes.
     1214        $wc_boxpack = new WC_Box_Packer\WC_Boxpack();
     1215        foreach( $boxes as $box ) {
     1216            if( empty( $box['active'] ) ) continue;
     1217            $wc_boxpack->add_box( $box );
     1218        }
     1219
     1220        // Loop the items, grabs their dimensions, and assocaite them with WC_Boxpack for future packing.
     1221        foreach( $items as $item_id => $item ) {
     1222            if( ! $item['data']->needs_shipping() ) continue;
     1223
     1224            $weight = ( ! empty( $item['data']->get_weight() ) ) ? $item['data']->get_weight() : $default_weight;
     1225            $data   = array(
     1226                'weight' => (float)round( wc_get_weight( $weight, $this->store_data['weight_unit'] ), 2 ),
     1227            );
     1228            $physicals = array_filter( array(
     1229                'length'    => $item['data']->get_length(),
     1230                'width'     => $item['data']->get_width(),
     1231                'height'    => $item['data']->get_height(),
     1232            ) );
     1233
     1234            // Return Early - Product missing one of the 4 key dimensions.
     1235            if( count( $physicals ) < 3 && empty( $data['weight'] ) ) {
     1236                $this->log( sprintf(
     1237
     1238                    /* translators: %1$d is the Product ID. %2$s is the Product Dimensions separated by a comma. */
     1239                    esc_html__( 'Product ID #%1$d missing (%2$s) dimensions and no weight found. Shipping calculations terminated.', 'live-rates-for-shipstation' ),
    9101240                    $item['product_id'],
    9111241                    implode( ', ', array_diff_key( array(
     
    9131243                        'height'    => 'height',
    9141244                        'length'    => 'length',
    915                         'weight'    => 'weight',
    9161245                    ), $physicals ) )
    9171246                ) );
     
    9191248            }
    9201249
    921             $request['weight'] = array(
    922                 'value' => (float)round( wc_get_weight( $physicals['weight'], $this->store_data['weight_unit'] ), 2 ),
    923                 'unit'  => $this->shipStationApi->convert_unit_term( $this->store_data['weight_unit'] ),
    924             );
    925 
    926             // Unset weight and sort dimensions
    927             unset( $physicals['weight'] );
    9281250            sort( $physicals );
    929 
    930             $request['dimensions'] = array(
    931                 'length'    => round( wc_get_dimension( $physicals[2], $this->store_data['dim_unit'] ), 2 ),
    932                 'width'     => round( wc_get_dimension( $physicals[1], $this->store_data['dim_unit'] ), 2 ),
    933                 'height'    => round( wc_get_dimension( $physicals[0], $this->store_data['dim_unit'] ), 2 ),
    934                 'unit'      => $this->shipStationApi->convert_unit_term( $this->store_data['dim_unit'] ),
    935             );
    936 
    937             $item_requests[ $item_id ] = $request;
    938 
    939         }
    940 
    941         return $item_requests;
    942 
    943     }
    944 
    945 
    946     /**
    947      * Return an array of API requests for custom packed boxes.
    948      * Shoutout to Mike Jolly & Co.
    949      *
    950      * @param Array $items
    951      *
    952      * @return Array $requests
    953      */
    954     protected function get_custombox_requests( $items ) {
    955 
    956         if( ! class_exists( '\IQRLSS\WC_Box_Packer\WC_Boxpack' ) ) {
    957             include_once 'wc-box-packer/class-wc-boxpack.php';
    958         }
    959 
    960         $item_requests = array();
    961         $wc_boxpack = new WC_Box_Packer\WC_Boxpack();
    962         $boxes = $this->get_option( 'customboxes', array() );
    963 
    964         if( empty( $boxes ) ) {
    965             $this->log( esc_html__( 'Custom Boxes selected, but no boxes found. Items packed individually', 'live-rates-for-shipstation' ), 'warning' );
    966         }
    967 
    968         // Setup the WC_Boxpack boxes based on user submitted custom boxes.
    969         foreach( $boxes as $box ) {
    970 
    971             $custombox = $wc_boxpack->add_box( $box['outer']['length'], $box['outer']['width'], $box['outer']['height'], $box['weight'] );
    972             $custombox->set_inner_dimensions( $box['inner']['length'], $box['inner']['width'], $box['inner']['height'] );
    973             if( $box['weight_max'] ) $custombox->set_max_weight( $box['weight_max'] );
    974 
    975         }
    976 
    977         // Loop the items, grabs their dimensions, and assocaite them with WC_Boxpack for future packing.
    978         foreach( $items as $item_id => $item ) {
    979 
    980             // Continue - No shipping needed for product.
    981             if( ! $item['data']->needs_shipping() ) {
    982                 continue;
    983             }
    984 
    985             $data = array();
    986             $physicals = array_filter( array(
    987                 'weight'    => $item['data']->get_weight(),
    988                 'length'    => $item['data']->get_length(),
    989                 'width'     => $item['data']->get_width(),
    990                 'height'    => $item['data']->get_height(),
    991             ) );
    992 
    993             // Return Early - Product missing one of the 4 key dimensions.
    994             if( count( $physicals ) < 4 ) {
    995                 $this->log( sprintf(
    996 
    997                     /* translators: %1$d is the Product ID. %2$s is the Product Dimensions separated by a comma. */
    998                     esc_html__( 'Product ID #%1$d missing (%2$s) dimensions. Shipping calculations terminated.', 'live-rates-for-shipstation' ),
    999                     $item['product_id'],
    1000                     implode( ', ', array_diff_key( array(
    1001                         'width'     => 'width',
    1002                         'height'    => 'height',
    1003                         'length'    => 'length',
    1004                         'weight'    => 'weight',
    1005                     ), $physicals ) )
    1006                 ) );
    1007                 return array();
    1008             }
    1009 
    1010             $data['weight'] = (float)round( wc_get_weight( $physicals['weight'], $this->store_data['weight_unit'] ), 2 );
    1011 
    1012             // Unset weight to exclude it from sort
    1013             unset( $physicals['weight'] );
    1014             sort( $physicals );
    1015 
    10161251            $data = array(
    10171252                'length'    => round( wc_get_dimension( $physicals[2], $this->store_data['dim_unit'] ), 2 ),
    10181253                'width'     => round( wc_get_dimension( $physicals[1], $this->store_data['dim_unit'] ), 2 ),
    10191254                'height'    => round( wc_get_dimension( $physicals[0], $this->store_data['dim_unit'] ), 2 ),
    1020             ) + $data;
    1021 
     1255                'weight'    => round( wc_get_weight( $data['weight'], $this->store_data['weight_unit'] ), 2 ),
     1256            );
     1257
     1258            // Pack Products
    10221259            for( $i = 0; $i < $item['quantity']; $i++ ) {
    10231260                $wc_boxpack->add_item(
     
    10481285            $item_requests[] = array(
    10491286                'weight' => array(
    1050                     'value' => $package->weight,
     1287                    'value' => round( $package->weight, 2 ),
    10511288                    'unit'  => $this->shipStationApi->convert_unit_term( $this->store_data['weight_unit'] ),
    10521289                ),
    10531290                'dimensions' => array(
    1054                     'length'    => $package->length,
    1055                     'width'     => $package->width,
    1056                     'height'    => $package->height,
     1291                    'length'    => round( $package->length, 2 ),
     1292                    'width'     => round( $package->width, 2 ),
     1293                    'height'    => round( $package->height, 2 ),
    10571294                    'unit'      => $this->shipStationApi->convert_unit_term( $this->store_data['dim_unit'] ),
    10581295                ),
    10591296                'packed' => $packed_items,
     1297                'price'  => ( ! empty( $package->data ) ) ? $package->data['price'] : 0,
     1298                'nickname'      => ( ! empty( $package->data ) ) ? $package->data['nickname'] : '',
     1299                'box_weight'    => ( ! empty( $package->data ) ) ? $package->data['weight'] : 0,
     1300                'box_max_weight'=> ( ! empty( $package->data ) ) ? $package->data['weight_max'] : 0,
     1301                'package_code'  => ( ! empty( $package->data ) ) ? $package->data['preset'] : '',
     1302                'carrier_code'  => ( ! empty( $package->data ) ) ? $package->data['carrier_code'] : '',
    10601303            );
    10611304
     
    10731316                ),
    10741317                'max_volume' => floatval( $package->width * $package->height * $package->length ),
     1318                'data' => ( ! empty( $package->data ) ) ? $package->data : array(),
    10751319            );
    10761320
     
    10871331
    10881332    /**
    1089      * Attempt to pull from the WC() Session cache to prevent multiple caclulation
     1333     * Set the rates based on cached packages.
     1334     *
     1335     * Attempt to pull from the WC() Session cache to prevent multiple calculations
    10901336     * requests, which could unnecessarily ping the API or add duplicate logs.
    10911337     * This issue is common when dealing with WP Blocks/Gutenberg Editor.
     
    10931339     * @param Array $packages - Packages in use.
    10941340     *
    1095      * @return String $hash - hash key neded to reset cache.
     1341     * @return void
    10961342     */
    10971343    protected function check_packages_rate_cache( $packages ) {
    10981344
    1099         $session    = WC()->session->get( $this->plugin_prefix, array() );
     1345        $session    = WC()->session->get( $this->plugin_prefix . '_packages', array() );
    11001346        $cleartime  = get_transient( \IQLRSS\Driver::plugin_prefix( 'wcs_timeout' ) );
     1347        $cachehash  = $this->generate_packages_cache_key( $packages );
     1348
     1349        // Return Early - Cache cleared or 30 minuites has passed (invalidate cache).
     1350        if( isset( $session['method_cache_time'] ) && ( $cleartime > $session['method_cache_time'] || $session['method_cache_time'] < ( time() - ( 30 * 60 ) ) ) ) {
     1351            return;
     1352
     1353        // Return Early- Cart has changed.
     1354        } else if( ! isset( $session['method_hash'] ) || empty( $cachehash ) || $session['method_hash'] != $cachehash ) {
     1355            return;
     1356        }
     1357
     1358        // Try to populate Rates.
     1359        $size = count( $packages );
     1360        for( $i = 0; $i < $size; $i++ ) {
     1361
     1362            $cache = WC()->session->get( 'shipping_for_package_' . $i, false );
     1363            if( empty( $cache ) || ! is_array( $cache ) ) {
     1364                break;
     1365            }
     1366            $this->rates = array_merge( $cache['rates'], $this->rates );
     1367
     1368        }
     1369
     1370    }
     1371
     1372
     1373    /**
     1374     * Generate a hash key based off of the given packages.
     1375     *
     1376     * @param Array $packages
     1377     *
     1378     * @return String $hash
     1379     */
     1380    protected function generate_packages_cache_key( $packages ) {
    11011381
    11021382        $keys = array();
     
    11081388            );
    11091389        }
    1110         $hash = md5( wp_json_encode( $keys ) ) . \WC_Cache_Helper::get_transient_version( 'shipping' );
    1111 
    1112         // Return Early - Cache cleared or 30 minuites has passed (invalidate cache).
    1113         if( isset( $session['method_cache_time'] ) && ( $cleartime > $session['method_cache_time'] || $session['method_cache_time'] < ( time() - ( 30 * 60 ) ) ) ) {
    1114             return $hash;
    1115 
    1116         // Return Early- Cart has changed.
    1117         } else if( ! isset( $session['method_hash'] ) || $session['method_hash'] != $hash ) {
    1118             return $hash;
    1119         }
    1120 
    1121         // Try to populate Rates.
    1122         $size = count( $packages );
    1123         for( $i = 0; $i < $size; $i++ ) {
    1124 
    1125             $cache = WC()->session->get( 'shipping_for_package_' . $i, false );
    1126             if( empty( $cache ) || ! is_array( $cache ) ) {
    1127                 break;
    1128             }
    1129             $this->rates = array_merge( $cache['rates'], $this->rates );
    1130 
    1131         }
    1132 
    1133         return $hash;
    1134 
    1135     }
    1136 
    1137 
    1138     /**
    1139      * Generate a hash key based off of the given packages.
    1140      *
    1141      * @param Array $packages
    1142      *
    1143      * @return String $hash
    1144      */
    1145     protected function generate_packages_cache_key( $packages ) {
    1146 
    1147         // Maybe skip if cache was cleared.
    1148         $session    = WC()->session->get( $this->plugin_prefix, array() );
    1149         $cleartime  = get_transient( \IQLRSS\Driver::plugin_prefix( 'wcs_timeout' ) );
    1150         if( isset( $session['method_cache_time'] ) && $cleartime > $session['method_cache_time'] ) {
    1151             return '';
    1152         }
    1153 
    1154         $keys = array();
    1155         foreach( $packages['contents'] as $key => $package ) {
    1156             $keys[] = array(
    1157                 $key,
    1158                 $package['quantity'],
    1159                 $package['line_total'],
    1160             );
    1161         }
    1162 
    1163         $hash = md5( wp_json_encode( $keys ) ) . \WC_Cache_Helper::get_transient_version( 'shipping' );
    1164         return ( ! empty( $keys ) ) ? $hash : '';
     1390
     1391        if( empty( $keys ) ) return '';
     1392        return md5( wp_json_encode( $keys ) ) . \WC_Cache_Helper::get_transient_version( 'shipping' );
    11651393
    11661394    }
     
    11711399    /** :: Helper Methods :: **/
    11721400    /**------------------------------------------------------------------------------------------------ **/
     1401    /**
     1402     * Map known packages.
     1403     * @see assets/json
     1404     *
     1405     * @param String $key
     1406     *
     1407     * @return String
     1408     */
     1409    public function get_package_label( $key ) {
     1410
     1411        $labels = array(
     1412            // UPS
     1413            'flat_rate_envelope'    => esc_html__( 'USPS Flat Rate Envelope', 'live-rates-for-shipstation' ),
     1414            'flat_rate_legal_envelope'  => esc_html__( 'USPS Flat Rate Legal Envelope', 'live-rates-for-shipstation' ),
     1415            'flat_rate_padded_envelope' => esc_html__( 'USPS Flat Rate Padded Envelope', 'live-rates-for-shipstation' ),
     1416            'large_envelope_or_flat'=> esc_html__( 'USPS Large Envelope or Flat', 'live-rates-for-shipstation' ),
     1417            'large_flat_rate_box'   => esc_html__( 'USPS Large Flat Rate Box', 'live-rates-for-shipstation' ),
     1418            'medium_flat_rate_box'  => esc_html__( 'USPS Medium Flat Rate Box', 'live-rates-for-shipstation' ),
     1419            'small_flat_rate_box'   => esc_html__( 'USPS Small Flat Rate Box', 'live-rates-for-shipstation' ),
     1420            'regional_rate_box_a'   => esc_html__( 'USPS Regional Rate Box A', 'live-rates-for-shipstation' ),
     1421            'regional_rate_box_b'   => esc_html__( 'USPS Regional Rate Box B', 'live-rates-for-shipstation' ),
     1422
     1423            // USPS
     1424            'ups_10_kg_box'         => esc_html__( 'UPS 10kg (22lbs) Box', 'live-rates-for-shipstation' ),
     1425            'ups_25_kg_box'         => esc_html__( 'UPS 25kg (55lbs) Box', 'live-rates-for-shipstation' ),
     1426            'ups__express_box_large'=> esc_html__( 'UPS Express Box - Large', 'live-rates-for-shipstation' ), // Why does this have an extra underscore? Ask ShipStation.
     1427            'ups_express_box_medium'=> esc_html__( 'UPS Express Box - Medium', 'live-rates-for-shipstation' ),
     1428            'ups_express_box_small' => esc_html__( 'UPS Express Box - Small', 'live-rates-for-shipstation' ),
     1429            'ups_tube'              => esc_html__( 'UPS Tube', 'live-rates-for-shipstation' ),
     1430            'ups_express_pak'       => esc_html__( 'UPS Express Pak', 'live-rates-for-shipstation' ),
     1431            'ups_letter'            => esc_html__( 'UPS Letter', 'live-rates-for-shipstation' ),
     1432
     1433            // FedEx
     1434            'fedex_10kg_box'    => esc_html__( 'FedEx 10kg (22lbs) Box', 'live-rates-for-shipstation' ),
     1435            'fedex_25kg_box'    => esc_html__( 'FedEx 25kg (55lbs) Box', 'live-rates-for-shipstation' ),
     1436            'fedex_extra_large_box' => esc_html__( 'FedEx Extra Large Box', 'live-rates-for-shipstation' ),
     1437            'fedex_large_box'   => esc_html__( 'FedEx Large Box', 'live-rates-for-shipstation' ),
     1438            'fedex_medium_box'  => esc_html__( 'FedEx Medium Box', 'live-rates-for-shipstation' ),
     1439            'fedex_small_box'   => esc_html__( 'FedEx Small Box', 'live-rates-for-shipstation' ),
     1440            'fedex_tube'        => esc_html__( 'FedEx Tube', 'live-rates-for-shipstation' ),
     1441            'fedex_envelope'    => esc_html__( 'FedEx Envelope', 'live-rates-for-shipstation' ),
     1442            'fedex_pak'         => esc_html__( 'FedEx Padded Pak', 'live-rates-for-shipstation' ),
     1443        );
     1444
     1445        return ( isset( $labels [ $key ] ) ) ? $labels[ $key ] : esc_html__( 'Unknown Package', 'live-rates-for-shipstation' );
     1446
     1447    }
     1448
     1449
    11731450    /**
    11741451     * Return an array of Price Adjustment Type options.
     
    12141491
    12151492    /**
     1493     * Convert a WooCommerce unit to a ShipStation unit.
     1494     *
     1495     * @param String $unit
     1496     *
     1497     * @return String $new_unit
     1498     */
     1499    public function convert_unit_term( $unit ) {
     1500        return $this->shipStationApi->convert_unit_term( $unit );
     1501    }
     1502
     1503
     1504    /**
     1505     * Return an array of package options.
     1506     *
     1507     * @return Array
     1508     */
     1509    protected function get_package_options() {
     1510
     1511        $packages = wp_cache_get( 'packages', $this->plugin_prefix );
     1512        if( ! empty( $packages ) ) {
     1513            return $packages;
     1514        }
     1515
     1516        $global_carriers= $this->shipStationApi->get_carriers();
     1517        $carrier_codes  = wp_list_pluck( $global_carriers, 'carrier_code' );
     1518        $carrier_codes  = array_intersect_key( $carrier_codes, array_flip( $this->carriers ) );
     1519
     1520        $data = array(
     1521            'usps' => array(
     1522                'label'     => esc_html__( 'USPS', 'live-rates-for-shipstation' ),
     1523                'packages'  => json_decode( file_get_contents( \IQLRSS\Driver::get_asset_path( 'json/usps-packages.json' ) ), true ),
     1524            ),
     1525            'ups'   => array(
     1526                'label'     => esc_html__( 'UPS', 'live-rates-for-shipstation' ),
     1527                'packages'  => json_decode( file_get_contents( \IQLRSS\Driver::get_asset_path( 'json/ups-packages.json' ) ), true ),
     1528            ),
     1529            'fedex' => array(
     1530                'label'     => esc_html__( 'FedEx', 'live-rates-for-shipstation' ),
     1531                'packages'  => json_decode( file_get_contents( \IQLRSS\Driver::get_asset_path( 'json/fedex-packages.json' ) ), true ),
     1532            ),
     1533        );
     1534
     1535        // Append Translated Labels
     1536        $carrier_packages = array();
     1537        foreach( $data as $carrier_code => &$carriers ) {
     1538
     1539            // Match carrier slug with known carrier code.
     1540            $carrier_found = array_filter( $carrier_codes, fn( $c ) => $c === $carrier_code );
     1541            if( empty( $carrier_found ) ) {
     1542                $carrier_found = array_filter( $carrier_codes, fn( $c ) => false !== strpos( $c, $carrier_code . '_' ) );
     1543            }
     1544
     1545            // Skip - Carrier may not be set.
     1546            if( empty( $carrier_found ) ) continue;
     1547
     1548            $codes = wp_list_pluck( $carriers['packages'], 'code' );
     1549            $dupes = array_count_values( $codes );
     1550
     1551            foreach( $carriers['packages'] as &$package ) {
     1552
     1553                $package['carrier_code'] = $carrier_code;
     1554                $package['label'] = $this->get_package_label( $package['code'] );
     1555
     1556                if( $dupes[ $package['code'] ] > 1 ) {
     1557                    $package['label'] .= sprintf( ' (%s x %s x %s)', $package['length'], $package['width'], $package['height'] );
     1558                }
     1559            }
     1560
     1561            usort( $carriers['packages'], fn( $pa, $pb ) => strcmp( $pa['label'], $pb['label'] ) );
     1562            $carrier_packages[ $carrier_code ] = $carriers;
     1563
     1564        }
     1565
     1566        $data = array( '' => esc_html__( '-- Select Package Preset --', 'live-rates-for-shipstation' ) ) + $carrier_packages;
     1567
     1568
     1569        /**
     1570         * Allow hooking into Custom Package presets for 3rd party management.
     1571         *
     1572         * @hook filter
     1573         *
     1574         * @param Array $data - Array( Array(
     1575         *      'label' => 'Optional Optgroup Name',
     1576         *      'packages' => Array(
     1577         *          'label'  => '',
     1578         *          'code'   => '',
     1579         *          'length' => 0,
     1580         *          'width'  => 0,
     1581         *          'height' => 0,
     1582         *          'weight_max' => 0,
     1583         *          'carrier_code' => '',
     1584         *      )
     1585         * ) )
     1586         * @param \IQLRSS\Core\Shipping_Method_Shipstation $this
     1587         *
     1588         * @return Array $data
     1589         */
     1590        $packages = apply_filters( 'iqlrss/zone/package_presets', $data, $this );
     1591
     1592        // Maybe reset if what we're given is not what we expect.
     1593        if( ! is_array( $packages ) ) $packages = $data;
     1594
     1595        // Cache results to avoid multiple file reads per request.
     1596        if( ! empty( $packages ) ) {
     1597            wp_cache_add( 'packages', $packages, $this->plugin_prefix );
     1598
     1599        // Maybe provide a default options / text when empty.
     1600        } else {
     1601            $packages = array( '' => esc_html__( 'No package presets.', 'live-rates-for-shipstation' ) );
     1602        }
     1603
     1604        return $packages;
     1605
     1606    }
     1607
     1608
     1609    /**
    12161610     * Format a stringified product name.
    12171611     * ex. 213|Shirt|optional|meta|data
     
    12231617     * @return String $name
    12241618     */
    1225     public function format_shipitem_name( $shipitem_name, $link = false, $context = 'edit' ) {
     1619    protected function format_shipitem_name( $shipitem_name, $link = false, $context = 'edit' ) {
    12261620
    12271621        $name = mb_strimwidth( $shipitem_name, 0, 47, '...' );
  • live-rates-for-shipstation/trunk/core/wc-box-packer/class-wc-boxpack-box.php

    r3376459 r3407166  
    22/**
    33 * Box Packing class found in woocommerce-shipping-ups
    4  * Updated by IQComputing because many of these methods
    5  * have the wrong return documentation.
     4 * Updated by IQComputing
    65 *
    76 * @version 2.0.1
     
    6867
    6968    /**
    70      * __construct function.
    71      *
    72      * @access public
     69     * Box info - contains the core box properties and
     70     * additonal data like nickname and price.
     71     * See the Shipping Method Custom Packing Boxes for more info.
     72     *
     73     * @var Array
     74     */
     75    private $data = array();
     76
     77
     78    /**
     79     * Setup box properties.
     80     *
     81     * @param Array $box - Array( 'outer' => array( 'length', 'width', 'height' ), 'inner' => array( see outer ) )
     82     *
    7383     * @return void
    7484     */
    75     public function __construct( $length, $width, $height, $weight = 0 ) {
    76         $dimensions = array( $length, $width, $height );
    77 
    78         sort( $dimensions );
    79 
    80         $this->outer_length = $this->length = floatval( $dimensions[2] );
    81         $this->outer_width  = $this->width  = floatval( $dimensions[1] );
    82         $this->outer_height = $this->height = floatval( $dimensions[0] );
    83         $this->weight       = floatval( $weight );
     85    public function __construct( $box ) {
     86
     87        // Default - All Outer
     88        $this->length = floatval( $box['outer']['length'] );
     89        $this->width  = floatval( $box['outer']['width'] );
     90        $this->height = floatval( $box['outer']['height'] );
     91        $this->outer_length = floatval( $box['outer']['length'] );
     92        $this->outer_width  = floatval( $box['outer']['width'] );
     93        $this->outer_height = floatval( $box['outer']['height'] );
     94
     95        // Inner
     96        if( ! empty( array_filter( (array)$box['inner'] ) ) ) {
     97            $this->length = floatval( $box['inner']['length'] );
     98            $this->width  = floatval( $box['inner']['width'] );
     99            $this->height = floatval( $box['inner']['height'] );
     100        }
     101
     102        // Weight
     103        $this->weight = floatval( $box['weight'] );
     104
     105        // Everything else
     106        $this->data = $box;
     107
    84108    }
    85109
     
    211235        $this->reset_packed_dimensions();
    212236
    213         // @todo Rememer this kind of loop, neat method, love it.
    214237        while ( sizeof( $items ) > 0 ) {
    215238            $item = array_shift( $items );
     
    268291        $package->height   = $this->get_outer_height();
    269292        $package->value    = $packed_value;
     293        $package->data     = $this->data;
    270294
    271295        // Calculate packing success % based on % of weight and volume of all items packed
     
    281305        }
    282306
     307        // Fallback to amount packed
    283308        if ( is_null( $packed_weight_ratio ) && is_null( $packed_volume_ratio ) ) {
    284             // Fallback to amount packed
    285309            $package->percent = ( sizeof( $packed ) / ( sizeof( $unpacked ) + sizeof( $packed ) ) ) * 100;
     310
     311        // Volume only
    286312        } elseif ( is_null( $packed_weight_ratio ) ) {
    287             // Volume only
    288313            $package->percent = $packed_volume_ratio * 100;
     314
     315        // Weight only
    289316        } elseif ( is_null( $packed_volume_ratio ) ) {
    290             // Weight only
    291317            $package->percent = $packed_weight_ratio * 100;
     318
     319        // Default?
    292320        } else {
    293321            $package->percent = $packed_weight_ratio * $packed_volume_ratio * 100;
     
    388416        return $this->packed_length;
    389417    }
     418
     419    /**
     420     * Return box data.
     421     *
     422     * @param String $key
     423     * @param Mixed $default
     424     *
     425     * @return Mixed
     426     */
     427    public function get_data( $key, $default = '' ) {
     428        return ( isset( $this->data[ $key ] ) ) ? $this->data[ $key ] : $default;
     429    }
    390430}
  • live-rates-for-shipstation/trunk/core/wc-box-packer/class-wc-boxpack-item.php

    r3339099 r3407166  
    22/**
    33 * Box Packing class found in woocommerce-shipping-ups
    4  * Updated by IQComputing because many of these methods
    5  * have the wrong return documentation.
     4 * Updated by IQComputing
    65 *
    76 * @version 2.0.1
  • live-rates-for-shipstation/trunk/core/wc-box-packer/class-wc-boxpack.php

    r3376459 r3407166  
    22/**
    33 * Box Packing class found in woocommerce-shipping-ups
    4  * Updated by IQComputing because many of these methods
    5  * have the wrong return documentation.
     4 * Updated by IQComputing
    65 *
    76 * @version 2.0.1
     
    7069     *
    7170     * @access public
    72      * @param mixed $length
    73      * @param mixed $width
    74      * @param mixed $height
    75      * @param mixed $weight
     71     * @param Array $box - Array( 'outer' => array( 'length', 'width', 'height' ), 'inner' => array( see outer ) )
    7672     * @return object WC_Boxpack_Box
    7773     */
    78     public function add_box( $length, $width, $height, $weight = 0 ) {
    79         $new_box = new WC_Boxpack_Box( $length, $width, $height, $weight );
     74    public function add_box( $box ) {
     75        $new_box = new WC_Boxpack_Box( $box );
    8076        $this->boxes[] = $new_box;
    8177        return $new_box;
     
    174170                    $package->unpacked = true;
    175171                    $package->packed   = array( $item );
     172                    $package->data     = array();
    176173                    $this->packages[]  = $package;
    177174                }
  • live-rates-for-shipstation/trunk/live-rates-for-shipstation.php

    r3376459 r3407166  
    44 * Plugin URI: https://iqcomputing.com/contact/
    55 * Description: ShipStation shipping method with live rates.
    6  * Version: 1.0.8
    7  * Requries at least: 5.9
     6 * Version: 1.1.0
     7 * Requries at least: 6.2
    88 * Author: IQComputing
    99 * Author URI: https://iqcomputing.com/
     
    1212 * Text Domain: live-rates-for-shipstation
    1313 * Requires Plugins: woocommerce, woocommerce-shipstation-integration
    14  *
    15  * @notes ShipStation does not make it easy or obvious how to update / create a Shipment for an Order.
    16  *      The shipment create endpoint keeps coming back successful, but nothing on the ShipStation side
    17  *      appears to change.
    18  *      The v1 API update Order endpoint also doesn't seem to allow Shipment updates, but is required
    19  *      to get the OrderID, required for any kind of create/update endpoints.
    20  *
    21  * @todo Add warehosue locations to Shipping Zone packages.
    22  * @todo Look into updating warehouses through Edit Order > Order Items.
    2314 */
    2415namespace IQLRSS;
     
    3526     * @var String
    3627     */
    37     protected static $version = '1.0.8';
     28    protected static $version = '1.1.0';
    3829
    3930
     
    8677     * @param Mixed $value
    8778     *
    88      * @return Mixed
     79     * @return void
    8980     */
    9081    public static function set_ss_opt( $key, $value ) {
     
    10596
    10697    /**
     98     * Return a ShipStation Plugin Option Value
     99     *
     100     * @param String $key
     101     * @param Mixed $default
     102     * @param Boolean $skip_prefix - Skip Plugin Prefix and return a core ShipStation setting value.
     103     *
     104     * @return Mixed
     105     */
     106    public static function get_opt( $key, $default = '' ) {
     107        $settings = get_option( static::plugin_prefix( 'plugin' ) );
     108        return ( isset( $settings[ $key ] ) && '' !== $settings[ $key ] ) ? maybe_unserialize( $settings[ $key ] ) : $default;
     109    }
     110
     111
     112    /**
     113     * Set a plugin option.
     114     *
     115     * @param String $key
     116     * @param Mixed $value
     117     *
     118     * @return void
     119     */
     120    public static function set_opt( $key, $value ) {
     121
     122        $option     = static::plugin_prefix( 'plugin' );
     123        $settings   = get_option( $option, array() );
     124
     125        if( is_bool( $value ) ) {
     126            $settings[ $key ] = boolval( $value );
     127        } else if( is_string( $value ) || is_numeric( $value ) ) {
     128            $settings[ $key ] = sanitize_text_field( $value );
     129        }
     130
     131        update_option( $option, $settings );
     132
     133    }
     134
     135
     136    /**
     137     * Clear the Plugin API cache.
     138     *
     139     * @return void
     140     */
     141    public static function clear_cache() {
     142
     143        global $wpdb;
     144
     145        /**
     146         * The API Class creates various transients to cache carrier services.
     147         * These transients are not tracked but generated based on the responses carrier codes.
     148         * All these transients are prefixed with our plugins unique string slug.
     149         * The first WHERE ensures only `_transient_` and the 2nd ensures only our plugins transients.
     150         */
     151        $wpdb->query( $wpdb->prepare( "DELETE FROM %i WHERE option_name LIKE %s AND option_name LIKE %s", // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.UnsupportedIdentifierPlaceholder
     152            $wpdb->options,
     153            $wpdb->esc_like( '_transient_' ) . '%',
     154            '%' . $wpdb->esc_like( '_' . static::get( 'slug' ) . '_' ) . '%'
     155        ) );
     156
     157        // Set transient to clear any WC_Session caches if they are found.
     158        $expires = absint( apply_filters( 'wc_session_expiration', DAY_IN_SECONDS * 2 ) ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
     159        set_transient( static::plugin_prefix( 'wcs_timeout' ), time(), $expires );
     160
     161    }
     162
     163
     164    /**
    107165     * Prefix a string with the plugin slug.
    108166     *
     
    124182
    125183    /**
    126      * Return a URL to an asset (JS/CSS)
     184     * Return a URL to an asset (JS/CSS usually)
    127185     *
    128186     * @param String $asset
    129187     *
    130      * @return String $url
     188     * @return String
    131189     */
    132190    public static function get_asset_url( $asset ) {
     
    141199
    142200    /**
     201     * Return a path to an asset.
     202     *
     203     * @param String $asset
     204     *
     205     * @return String
     206     */
     207    public static function get_asset_path( $asset ) {
     208
     209        return sprintf( '%s/core/assets/%s',
     210            rtrim( plugin_dir_path( __FILE__ ), '\\/' ),
     211            $asset
     212        );
     213
     214    }
     215
     216
     217    /**
    143218     * Initialize the core controllers
    144219     * Vroom!
     
    147222     */
    148223    public static function drive() {
     224
     225        // Run any version transition actions.
     226        Stallation::transversion( static::$version );
     227
     228        // Load core controllers.
     229        Core\Rest_Router::initialize();
    149230        Core\Settings_Shipstation::initialize();
    150231    }
  • live-rates-for-shipstation/trunk/readme.txt

    r3376459 r3407166  
    44Requires at least: 5.9
    55Tested up to: 6.8
    6 Stable tag: 1.0.8
     6Stable tag: 1.1.0
    77License: GPLv3 or later
    88License URI: https://www.gnu.org/licenses/gpl-3.0.html
     
    1616This plugin connects to the ShipStation API using an authentication key to display shipping rates from various common carriers supported by ShipStation. This allows store owners to group all their shipping carriers under one umbrella which makes management easier and allows customers to choose the best shipping method for them which leads to happier customers.
    1717
    18 In order to use the Live Rates for ShipStation plugin, you must have a [premium ShipStation account](https://www.dpbolvw.net/click-101532691-11646582), and purchased the [ShipStation for WooCommerce](https://woocommerce.com/products/shipstation-integration/) plugin. This plugin **will not work** without access to the ShipStation API which is tied to your premium ShipStation account.
     18In order to use the Live Rates for ShipStation plugin, you must have a [premium ShipStation account](https://www.kqzyfj.com/click-101532691-15733876), and purchased the [ShipStation for WooCommerce](https://woocommerce.com/products/shipstation-integration/) plugin. This plugin **will not work** without access to the ShipStation API which is tied to your premium ShipStation account.
    1919
    2020Please review [ShipStations Terms of Service](https://www.shipstation.com/terms-of-service/) and [ShipStations Privacy Policy](https://auctane.com/legal/privacy-policy/) for more information about how your data is managed.
    2121
    22 Don't have a ShipStation account? [Open a ShipStation account today!](https://www.dpbolvw.net/click-101532691-11646582)
     22Don't have a ShipStation account? [Open a ShipStation account today!](https://www.kqzyfj.com/click-101532691-15733876)
    2323
    2424== Plugin Requirements ==
    2525
    26 1. [A Premium ShipStation Account](https://www.dpbolvw.net/click-101532691-11646582)
     261. [A Premium ShipStation Account](https://www.kqzyfj.com/click-101532691-15733876)
    27271. [The WooCommerce Plugin](https://wordpress.org/plugins/woocommerce/)
    28281. [The ShipStation for WooCommerce Plugin](https://woocommerce.com/products/shipstation-integration/)
     
    5151== Changelog ==
    5252
     53= 1.1.0 (2025-12-01) =
     54* Redux the Custom Packaging screen and options.
     55* Packing option for Weight Only.
     56* Packing option for Stacked Vertically.
     57* Packing option for default product weight.
     58* Custom Package Presets from UPS, FedEx, and USPS.
     59* New filter hook for Shipping Zone Settings `iqlrss/zone/settings`. Useful for managing Product Packing options.
     60* New filter hook for Shipping Zone Settings `iqlrss/zone/package_presets`. Useful for managing Custom Package presets.
     61* New filter hook for Shipping Estimates `iqlrss/shipping/packages`. Useful for modifying what gets sent to ShipStation API for retrieving shipping estimates.
     62
    5363= 1.0.8 (2025-10-10) =
    5464* Patches issue of missing `other_amount` when applying shipping rates (thanks @centuryperf)!
Note: See TracChangeset for help on using the changeset viewer.