├── config-example.php ├── .gitignore ├── .tmp └── migration.csv ├── includes ├── WooPayments │ └── class-migrator-cli-woopayments-customers.php ├── class-migrator-cli-order-tags.php ├── class-migrator-cli-utils.php ├── class-migrator-cli.php ├── class-migrator-cli-payment-methods.php ├── class-migrator-cli-subscriptions.php ├── class-migrator-cli-coupons.php ├── class-migrator-cli-products.php └── class-migrator-cli-orders.php ├── migrator.php └── README.md /config-example.php: -------------------------------------------------------------------------------- 1 | set_param( 'starting_after', $starting_after ); 31 | } 32 | 33 | /** 34 | * Stores the page size. 35 | * 36 | * @param int $limit how many customers to get. 37 | */ 38 | public function set_per_page( int $limit = 1 ) { 39 | $this->set_param( 'limit', $limit ); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /migrator.php: -------------------------------------------------------------------------------- 1 | $perpage, 28 | 'created_at_max' => $before, 29 | 'created_at_min' => $after, 30 | 'status' => 'any', 31 | 'fields' => 'id,order_number,tags,created_at', 32 | ) 33 | ); 34 | } 35 | 36 | if ( ! $response_data || empty( $response_data->data->orders ) ) { 37 | WP_CLI::error( 'Could not find order in Shopify.' ); 38 | } 39 | 40 | WP_CLI::line( sprintf( 'Found %d orders in Shopify. Processing %d orders.', count( $response_data->data->orders ), min( $limit, $perpage, count( $response_data->data->orders ) ), count( $response_data->data->orders ) ) ); 41 | 42 | foreach ( $response_data->data->orders as $shopify_order ) { 43 | WP_CLI::line( '-------------------------------' ); 44 | $order_number = $shopify_order->order_number; 45 | 46 | WP_CLI::line( sprintf( 'Processing Shopify order: %d, created @ %s', $order_number, $shopify_order->created_at ) ); 47 | 48 | if ( ! $shopify_order->tags ) { 49 | WP_CLI::line( sprintf( 'Order %d has no tags.', $order_number ) ); 50 | continue; 51 | } 52 | 53 | $tags = explode( ',', $shopify_order->tags ); 54 | 55 | // search for the order by the order number 56 | $query_args = array( 57 | 'numberposts' => 1, 58 | 'meta_key' => '_order_number', 59 | 'meta_value' => $order_number, 60 | 'post_type' => 'shop_order', 61 | 'post_status' => 'any', 62 | 'fields' => 'ids', 63 | ); 64 | 65 | $posts = get_posts( $query_args ); 66 | list( $order_id ) = ! empty( $posts ) ? $posts : null; 67 | 68 | if ( ! $order_id ) { 69 | WP_CLI::error( 'Could not find the corresponding order in WooCommerce.' ); 70 | continue; 71 | } 72 | 73 | WP_CLI::line( sprintf( 'Found the WooCommerce order: %d', $order_id ) ); 74 | 75 | // Set tag for the current order. 76 | foreach ( $tags as $tag ) { 77 | $tag = trim( $tag ); 78 | 79 | if ( ! $tag ) { 80 | WP_CLI::line( 'Invalid tag, skipping.' ); 81 | continue; 82 | } 83 | 84 | WP_CLI::line( sprintf( '- processing tag: "%s"', $tag, $order_id ) ); 85 | 86 | if ( $dry_run ) { 87 | WP_CLI::line( 'Dry run, skipping.' ); 88 | continue; 89 | } 90 | 91 | // Find the term id if it exists. 92 | $term = get_term_by( 'name', $tag, 'wcot_order_tag', ARRAY_A ); 93 | 94 | if ( ! $term ) { 95 | $term = wp_insert_term( $tag, 'wcot_order_tag' ); 96 | } 97 | 98 | wp_set_post_terms( $order_id, $term['term_id'], 'wcot_order_tag', true ); 99 | } 100 | } 101 | 102 | WP_CLI::line( '===============================' ); 103 | 104 | $next_link = $response_data->next_link; 105 | if ( $next_link && $limit > $perpage ) { 106 | Migrator_CLI_Utils::reset_in_memory_cache(); 107 | WP_CLI::line( WP_CLI::colorize( '%BInfo:%n ' ) . 'There are more orders to process.' ); 108 | $this->fix_missing_order_tags( 109 | array( 110 | 'next' => $next_link, 111 | 'limit' => $limit - $perpage, 112 | ) 113 | ); 114 | } else { 115 | WP_CLI::success( 'All orders have been processed.' ); 116 | } 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # migrator-cli 2 | 3 | ## Getting started 4 | 5 | 1. Clone the repo into `wp-content/plugins`. 6 | 2. Copy the `config-example.php` to `config.php`. 7 | 3. Grab the Shopify Access token by [creating a custom app](https://help.shopify.com/en/manual/apps/app-types/custom-apps). Make sure the required scopes for products and orders migration are selected. 8 | 4. Update domain and access token in the config.php file. 9 | 5. Activate the plugin. 10 | 11 | ## Important Notes 12 | 13 | 1. For Order migration - To prevent accidental sending of notifications, there is a `--mode` flag that is set to test by default. This masks the email and phone. Please add `--mode=live` flag whenever you wish to do a final migration with unmasked email and phone. 14 | 2. For order imports, notifications are disabled. These notifications include default emails sent by WooCommerce to users ( 'New Account Created') and site admin ('New Order Received'), for every order has been disabled to prevent spamming. For any reason if you wish to have those notifications sent, please add `--send-notifications` 15 | 16 | ## Commands 17 | 18 | ``` 19 | wp migrator products [--dry-run] [--before] [--after] [--limit] [--perpage] [--next] [--status] [--ids] [--exclude] [--handle] [--product-type] [--no-update] [--fields] [--exclude-fields] [--remove-orphans] 20 | 21 | OPTIONS 22 | 23 | [--before] 24 | Query Order before this date. ISO 8601 format. 25 | 26 | [--after] 27 | Query Order after this date. ISO 8601 format. 28 | 29 | [--limit] 30 | Limit the total number of orders to process. Set to PHP_INT_MAX by default. 31 | 32 | [--perpage] 33 | Limit the number of orders to process each time. 34 | 35 | [--next] 36 | Next page link from Shopify. 37 | 38 | [--status] 39 | Product status. 40 | 41 | [--ids] 42 | Query products by IDs. 43 | 44 | [--exclude] 45 | Exclude products by IDs or by SKU pattern. 46 | 47 | [--handle] 48 | Query products by handles 49 | 50 | [--product-type] 51 | single or variable or all. 52 | 53 | [--no-update] 54 | Force create new products instead of updating existing one base on the handle. 55 | 56 | [--fields] 57 | Only migrate/update selected fields. 58 | 59 | [--exclude-fields] 60 | Exclude selected fields from update. 61 | 62 | [--remove-orphans] 63 | Remove orphans order items 64 | 65 | Example: 66 | wp migrator products --limit=100 --perpage=10 --status=active --product-type=single --exclude="CANAL_SKU_*" 67 | ``` 68 | 69 | ``` 70 | wp migrator orders [--before] [--after] [--limit] [--perpage] [--next] [--status] [--ids] [--exclude] [--no-update] [--sorting] [--remove-orphans] [--mode=] 71 | 72 | OPTIONS 73 | 74 | [--before] 75 | Query Order before this date. ISO 8601 format. 76 | 77 | [--after] 78 | Query Order after this date. ISO 8601 format. 79 | 80 | [--limit] 81 | Limit the total number of orders to process. Set to PHP_INT_MAX by default. 82 | 83 | [--perpage] 84 | Limit the number of orders to process each time. 85 | 86 | [--next] 87 | Next page link from Shopify. 88 | 89 | [--status] 90 | Order status. 91 | 92 | [--ids] 93 | Query orders by IDs. 94 | 95 | [--exclude] 96 | Exclude orders by IDs. 97 | 98 | [--no-update] 99 | Skip existing order without updating. 100 | 101 | [--sorting] 102 | Sort the response. Default to 'id asc'. 103 | 104 | [--remove-orphans] 105 | Remove orphans order items 106 | 107 | [--mode=] 108 | Defaults to 'test' where email address is suffixed with '.masked' and phone number is blanked. Please set this flag as 'live' when you wish to do the final migration with unmasked email and phone. 109 | 110 | [--send-notifications] 111 | If this flag is added, the migrator will send out 'New Account created' email notifications to users, for every new user imported; and 'New 112 | order' notification for each order to the site admin email. Beware of potential spamming before adding this flag! 113 | ``` 114 | 115 | ``` 116 | wp migrator skio_subscriptions [--subscriptions_export_file] [--orders_export_file] 117 | 118 | The json files can downloaded from the Skio dashboard at https://dashboard.skio.com/subscriptions/export 119 | 120 | OPTIONS 121 | 122 | [--subscriptions_export_file] 123 | The subscriptions json file exported from Skio dashboard 124 | 125 | [--orders_export_file] 126 | The orders json file exported from Skio dashboard 127 | ``` 128 | 129 | ``` 130 | wp import_stripe_data_into_woopayments [--limit] 131 | 132 | OPTIONS 133 | 134 | [--limit] 135 | : Limit the total number of coupons to process. This won't count the sub codes. Default to PHP_INT_MAX. 136 | 137 | Example 138 | wp import_stripe_data_into_woopayments --limit=1 139 | 140 | ``` 141 | 142 | 143 | ``` 144 | wp migrator update_payment_methods [--migration_file] [--order-ids] [--subscription-ids] 145 | 146 | OPTIONS 147 | 148 | [--migration_file] 149 | : The csv file stripe created containing the mapping between old and new data 150 | 151 | [--order-ids] 152 | : A list of Woo order ids to be processed. Limited to 100. 153 | 154 | [--subscription-ids] 155 | : A list of Woo subscription ids to be processed. Limited to 100. 156 | 157 | 158 | Example: 159 | 160 | wp migrator update_payment_methods --migration_file= --order-ids="1,2,3" --subscription-ids="3,4,5" 161 | ``` 162 | 163 | ``` 164 | 165 | ## Options 166 | wp migrator coupons [--limit] [--cursor] 167 | 168 | OPTIONS 169 | 170 | [--limit] 171 | : Limit the total number of coupons to process. This won't count the sub codes. Default to PHP_INT_MAX. 172 | 173 | [--cursor] 174 | : The cursor of the last discount to start importing from 175 | 176 | Example: 177 | 178 | wp migrator coupons --limit=1 --cursor= 179 | ``` 180 | -------------------------------------------------------------------------------- /includes/class-migrator-cli-utils.php: -------------------------------------------------------------------------------- 1 | array( 61 | 'X-Shopify-Access-Token' => ACCESS_TOKEN, 62 | 'Accept' => 'application/json', 63 | ), 64 | 'body' => array_filter( $body ), 65 | ) 66 | ); 67 | 68 | if ( isset( $response->errors ) ) { 69 | if ( $retrying > 10 ) { 70 | WP_CLI::error( 'Too many api failures. Stopping: ' . wp_json_encode( $response->errors ) ); 71 | return; 72 | } 73 | 74 | WP_CLI::line( WP_CLI::colorize( '%RError:%n ' ) . 'Api error trying again: ' . wp_json_encode( $response->errors ) ); 75 | ++$retrying; 76 | sleep( 10 ); 77 | continue; 78 | } 79 | 80 | $response_data = json_decode( wp_remote_retrieve_body( $response ) ); 81 | 82 | if ( isset( $response_data->errors ) ) { 83 | if ( $retrying > 10 ) { 84 | WP_CLI::error( 'Too many api errors. Stopping: ' . wp_json_encode( $response_data->errors ) ); 85 | return; 86 | } 87 | 88 | WP_CLI::line( WP_CLI::colorize( '%RError:%n ' ) . 'Api error trying again: ' . wp_json_encode( $response_data->errors ) ); 89 | ++$retrying; 90 | sleep( 10 ); 91 | continue; 92 | } 93 | 94 | return ( object ) array( 95 | 'next_link' => self::get_rest_next_link( $response ), 96 | 'data' => $response_data, 97 | ); 98 | } while ( true ); 99 | } 100 | 101 | /** 102 | * Executes a request to the Shopify GraphQL API 103 | * 104 | * @param array $body the request body. 105 | * @return array 106 | */ 107 | public static function graphql_request( $body ) { 108 | return wp_remote_post( 109 | 'https://' . SHOPIFY_DOMAIN . '/admin/api/2023-04/graphql.json', 110 | array( 111 | 'headers' => array( 112 | 'X-Shopify-Access-Token' => ACCESS_TOKEN, 113 | 'Content-Type' => 'application/json', 114 | ), 115 | 'body' => wp_json_encode( $body ), 116 | ) 117 | ); 118 | } 119 | 120 | /** 121 | * Gets the next rest page link. 122 | * 123 | * @param array $response 124 | * @return string 125 | */ 126 | public static function get_rest_next_link( $response ) { 127 | $links = wp_remote_retrieve_header( $response, 'link' ); 128 | 129 | $next_link = ''; 130 | 131 | foreach ( explode( ',', $links ) as $link ) { 132 | if ( strpos( $link, 'rel="next"' ) !== false ) { 133 | $next_link = str_replace( array( '<', '>; rel="next"' ), '', $link ); 134 | break; 135 | } 136 | } 137 | 138 | return $next_link; 139 | } 140 | 141 | /** 142 | * Sets the WP_IMPORTING flag to true to prevent 143 | * sending emails and other communications. 144 | */ 145 | public static function set_importing_const() { 146 | if ( ! defined( 'WP_IMPORTING' ) ) { 147 | define( 'WP_IMPORTING', true ); 148 | } 149 | } 150 | 151 | /** 152 | * Clear in-memory local object cache (global $wp_object_cache) without affecting memcache 153 | * and reset in-memory database query log. 154 | */ 155 | public static function reset_in_memory_cache() { 156 | self::reset_local_object_cache(); 157 | self::reset_db_query_log(); 158 | } 159 | 160 | /** 161 | * Reset the local WordPress object cache 162 | * 163 | * This only cleans the local cache in WP_Object_Cache, without 164 | * affecting memcache 165 | */ 166 | private static function reset_local_object_cache() { 167 | global $wp_object_cache; 168 | 169 | if ( ! is_object( $wp_object_cache ) ) { 170 | return; 171 | } 172 | 173 | $wp_object_cache->group_ops = array(); 174 | $wp_object_cache->memcache_debug = array(); 175 | $wp_object_cache->cache = array(); 176 | 177 | if ( method_exists( $wp_object_cache, '__remoteset' ) ) { 178 | $wp_object_cache->__remoteset(); // important 179 | } 180 | } 181 | 182 | /** 183 | * Reset the WordPress DB query log 184 | */ 185 | private static function reset_db_query_log() { 186 | global $wpdb; 187 | 188 | $wpdb->queries = array(); 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /includes/class-migrator-cli.php: -------------------------------------------------------------------------------- 1 | fix_missing_order_tags( $assoc_args ); 37 | } 38 | 39 | /** 40 | * Migrate products from Shopify to WooCommerce. 41 | * 42 | * ## OPTIONS 43 | * 44 | * [--before] 45 | * : Query Order before this date. ISO 8601 format. 46 | * 47 | * [--after] 48 | * : Query Order after this date. ISO 8601 format. 49 | * 50 | * [--limit] 51 | * : Limit the total number of orders to process. 52 | * 53 | * [--perpage] 54 | * : Limit the number of orders to process each time. 55 | * 56 | * [--next] 57 | * : Next page link from Shopify. 58 | * 59 | * [--status] 60 | * : Product status. 61 | * 62 | * [--ids] 63 | * : Query products by IDs. 64 | * 65 | * [--exclude] 66 | * : Exclude products by IDs or by SKU pattern. 67 | * 68 | * [--handle] 69 | * : Query products by handles 70 | * 71 | * [--product-type] 72 | * : single or variable or all. 73 | * 74 | * [--no-update] 75 | * : Force create new products instead of updating existing one base on the handle. 76 | * 77 | * [--fields] 78 | * : Only migrate/update selected fields. 79 | * 80 | * [--exclude-fields] 81 | * : Exclude selected fields from update. 82 | * 83 | * [--remove-orphans] 84 | * : Remove orphans order items 85 | * 86 | * Example: 87 | * wp migrator products --limit=100 --perpage=10 --status=active --product-type=single --exclude="CANAL_SKU_*" 88 | * 89 | * @when after_wp_load 90 | */ 91 | public function products( $args, $assoc_args ) { 92 | Migrator_CLI_Utils::set_importing_const(); 93 | 94 | $products = new Migrator_CLI_Products(); 95 | $products->migrate_products( $assoc_args ); 96 | } 97 | 98 | /** 99 | * Migrate Shopify orders to WooCommerce. 100 | * 101 | * ## OPTIONS 102 | * 103 | * [--before] 104 | * : Query Order before this date. ISO 8601 format. 105 | * 106 | * [--after] 107 | * : Query Order after this date. ISO 8601 format. 108 | * 109 | * [--limit] 110 | * : Limit the total number of orders to process. 111 | * 112 | * [--perpage] 113 | * : Limit the number of orders to process each time. 114 | * 115 | * [--next] 116 | * : Next page link from Shopify. 117 | * 118 | * [--status] 119 | * : Order status. 120 | * 121 | * [--ids] 122 | * : Query orders by IDs. 123 | * 124 | * [--exclude] 125 | * : Exclude orders by IDs. 126 | * 127 | * [--no-update] 128 | * : Skip existing order without updating. 129 | * 130 | * [--sorting] 131 | * : Sort the response. Default to 'id asc'. 132 | * 133 | * [--remove-orphans] 134 | * : Remove orphans order items 135 | * 136 | * [--mode=] 137 | * : Switching to the 'live' mode will directly copy the email and phone number without any suffix, ensuring they remain intact. In 'test' mode, as a 138 | * precaution to prevent accidental notifications to customers, both the email and phone number will be masked with a suffix. The default setting is 139 | * 'test' 140 | * 141 | * [--send-notifications] 142 | * : If this flag is added, the migrator will send out 'New Account created' email notifications to users, for every new user imported; and 'New 143 | * order' notification for each order to the site admin email. Beware of potential spamming before adding this flag! 144 | * 145 | * @when after_wp_load 146 | */ 147 | public function orders( $args, $assoc_args ) { 148 | Migrator_CLI_Utils::set_importing_const(); 149 | 150 | $this->assoc_args = $assoc_args; 151 | 152 | $orders = new Migrator_CLI_Orders(); 153 | $orders->migrate_orders( $assoc_args ); 154 | } 155 | 156 | /** 157 | * Migrate subscriptions from Skio to WooCommerce. 158 | * This function will import from json files not from the api. 159 | * 160 | * ## OPTIONS 161 | * 162 | * [--subscriptions_export_file] 163 | * : The subscriptions json file exported from Skio dashboard 164 | * 165 | * [--orders_export_file] 166 | * : The orders json file exported from Skio dashboard 167 | * 168 | * Example: 169 | * wp migrator skio_subscriptions --subscriptions_export_file=subscriptions.json --orders_export_file=orders.json 170 | * 171 | * @when after_wp_load 172 | */ 173 | public function skio_subscriptions( $args, $assoc_args ) { 174 | Migrator_CLI_Utils::set_importing_const(); 175 | 176 | $subscriptions = new Migrator_CLI_Subscriptions(); 177 | $subscriptions->import( $assoc_args ); 178 | } 179 | 180 | /** 181 | * Import the customer data from Stripe into WooPayments. 182 | * This function uses the WooPayments plugin connection 183 | * to the WooPayments server. 184 | * So WooPayments needs to be active and linked to the server. 185 | * 186 | * It will not create the site customer it will just update 187 | * one that matches the email in Stripe. 188 | * 189 | * [--limit] 190 | * : Limit the total number of coupons to process. This won't count the sub codes. Default to 1000. 191 | * 192 | * Example: 193 | * wp import_stripe_data_into_woopayments --limit=1 194 | * 195 | * @when after_wp_load 196 | */ 197 | public function import_stripe_data_into_woopayments( $args, $assoc_args ) { 198 | Migrator_CLI_Utils::set_importing_const(); 199 | 200 | $payment_methods = new Migrator_CLI_Payment_Methods(); 201 | $payment_methods->import_stripe_data_into_woopayments( $assoc_args ); 202 | } 203 | 204 | /** 205 | * Reads the stripe csv created after the PAN import and saves info about 206 | * the original `cus_` and `pm_`s to the customer and payment tokens. 207 | * 208 | * ## Options 209 | * 210 | * [--migration_file] 211 | * : The csv file stripe created containing the mapping between old and data. 212 | * 213 | * [--order-ids] 214 | * : A list of Woo order ids to be processed. Limited to 100. 215 | * 216 | * [--subscription-ids] 217 | * : A list of Woo subscription ids to be processed. Limited to 100. 218 | * 219 | * Example: 220 | * 221 | * wp migrator update_payment_methods --migration_file= --order-ids="1,2,3" --subscription-ids="3,4,5" 222 | * 223 | * @when after_wp_load 224 | */ 225 | public function update_payment_methods( $args, $assoc_args ) { 226 | Migrator_CLI_Utils::set_importing_const(); 227 | 228 | $payment_methods = new Migrator_Cli_Payment_Methods(); 229 | $payment_methods->update_orders_and_subscriptions_payment_methods( $assoc_args ); 230 | } 231 | 232 | /** 233 | * Imports the coupons from Shopify. 234 | * Only imports Shipping and discount coupons. 235 | * No support for Buy X get Y yet. 236 | * 237 | * ## Options 238 | * 239 | * [--limit] 240 | * : Limit the total number of coupons to process. This won't count the sub codes. Default to 1000. 241 | * 242 | * [--cursor] 243 | * : The cursor of the last discount to start importing from 244 | * 245 | * Example: 246 | * 247 | * wp migrator coupons --limit=1 --cursor= 248 | * 249 | * @when after_wp_load 250 | */ 251 | public function coupons( $args, $assoc_args ) { 252 | Migrator_CLI_Utils::set_importing_const(); 253 | 254 | $coupons = new Migrator_CLI_Coupons(); 255 | $coupons->import( $assoc_args ); 256 | } 257 | } 258 | -------------------------------------------------------------------------------- /includes/class-migrator-cli-payment-methods.php: -------------------------------------------------------------------------------- 1 | set_per_page( $perpage ); 36 | 37 | if ( $starting_after ) { 38 | $request->set_starting_after( $starting_after ); 39 | } 40 | 41 | $result = $request->send(); 42 | $stripe_customers = $result['data']; 43 | 44 | $this->import_customers_data( $stripe_customers, $limit ); 45 | 46 | $limit -= $perpage; 47 | $starting_after = end( $stripe_customers )['id']; 48 | 49 | Migrator_CLI_Utils::reset_in_memory_cache(); 50 | } while ( $result['has_more'] && $limit > 0 ); 51 | 52 | WP_CLI::line( 'Done' ); 53 | } 54 | 55 | /** 56 | * Imports customer data. Sets the Stripe `cus_` to the Woo customer. 57 | * 58 | * @param array $stripe_customers the customers found in Stripe. 59 | */ 60 | private function import_customers_data( $stripe_customers, $limit ) { 61 | $imported = 0; 62 | 63 | foreach ( $stripe_customers as $stripe_customer ) { 64 | $user = get_user_by( 'email', $stripe_customer['email'] ); 65 | if ( ! $user || is_wp_error( $user ) || 0 === $user->ID ) { 66 | WP_CLI::line( WP_CLI::colorize( '%RError:%n ' ) . 'Customer not found: ' . $stripe_customer['email'] ); 67 | continue; 68 | } 69 | 70 | WP_CLI::line( 'Processing customer : ' . $stripe_customer['email'] . '(' . $user->ID . ')' ); 71 | update_user_option( $user->ID, self::get_customer_id_option(), $stripe_customer['id'] ); 72 | $this->import_payment_methods( $stripe_customer['id'], $user ); 73 | 74 | ++$imported; 75 | 76 | if ( $imported >= $limit ) { 77 | return; 78 | } 79 | } 80 | } 81 | 82 | /** 83 | * Imports payment method data `pm_` from Stripe. 84 | * 85 | * @param string $stripe_customer the Stripe customer `cus_` id. 86 | * @param WP_User $user the Woo Customer. 87 | */ 88 | private function import_payment_methods( $stripe_customer_id, $user ) { 89 | $payments_api_client = WC_Payments::get_payments_api_client(); 90 | $stripe_payment_methods = $payments_api_client->get_payment_methods( $stripe_customer_id, 'card' )['data']; 91 | 92 | $saved_payment_tokens = WC_Payment_Tokens::get_customer_tokens( $user->ID ); 93 | 94 | foreach ( $stripe_payment_methods as $stripe_payment_method ) { 95 | 96 | // Prevents duplication of payment methods. 97 | $token = $this->search_payment_token_by_stripe_id( $saved_payment_tokens, $stripe_payment_method['id'] ); 98 | 99 | if ( ! $token ) { 100 | $token = new WC_Payment_Token_CC(); 101 | } 102 | 103 | $token->set_gateway_id( WC_Payment_Gateway_WCPay::GATEWAY_ID ); 104 | $token->set_expiry_month( $stripe_payment_method['card']['exp_month'] ); 105 | $token->set_expiry_year( $stripe_payment_method['card']['exp_year'] ); 106 | $token->set_card_type( strtolower( $stripe_payment_method['card']['brand'] ) ); 107 | $token->set_last4( $stripe_payment_method['card']['last4'] ); 108 | 109 | $token->set_token( $stripe_payment_method['id'] ); 110 | $token->set_user_id( $user->ID ); 111 | $token->save(); 112 | } 113 | } 114 | 115 | /** 116 | * Searches for a payment token in the array that matches the given `pm_`. 117 | * 118 | * @param array $saved_payment_tokens Woo payment tokens. 119 | * @param string $stripe_payment_method_id stripe payment method id `pm_`. 120 | * @return WC_Payment_Token 121 | */ 122 | private function search_payment_token_by_stripe_id( $saved_payment_tokens, $stripe_payment_method_id ) { 123 | foreach ( $saved_payment_tokens as $saved_payment_token ) { 124 | if ( $stripe_payment_method_id === $saved_payment_token->get_token() ) { 125 | return $saved_payment_token; 126 | } 127 | } 128 | } 129 | 130 | 131 | /** 132 | * Reads the mapping csv generated by Stripe and updates 133 | * the orders and subscriptions to the new methods. 134 | * 135 | * @param array $assoc_args containing the mapping_file absolute path. 136 | */ 137 | public function update_orders_and_subscriptions_payment_methods( $assoc_args ) { 138 | if ( ! class_exists( 'WC_Payments_Customer_Service' ) ) { 139 | WP_CLI::line( WP_CLI::colorize( '%RError:%n ' ) . 'WooPayments is not active' ); 140 | die(); 141 | } 142 | 143 | if ( ! is_file( $assoc_args['migration_file'] ) ) { 144 | WP_CLI::line( WP_CLI::colorize( '%RError:%n ' ) . 'File not found' ); 145 | die(); 146 | } 147 | 148 | $order_ids = isset( $assoc_args['order-ids'] ) ? explode( ',', $assoc_args['order-ids'] ) : null; 149 | $subscription_ids = isset( $assoc_args['subscription-ids'] ) ? explode( ',', $assoc_args['subscription-ids'] ) : null; 150 | 151 | WP_CLI::line( 'Updating customers' ); 152 | $this->add_pan_import_data( $assoc_args['migration_file'] ); 153 | WP_CLI::line( 'Updating orders' ); 154 | $this->update_orders_or_subscriptions( 'shop_order', $order_ids ); 155 | WP_CLI::line( 'Updating subscriptions' ); 156 | $this->update_orders_or_subscriptions( 'shop_subscription', $subscription_ids ); 157 | } 158 | 159 | /** 160 | * Reads the mapping file and sets the mapping info 161 | * to the customer and payment methods. 162 | * 163 | * @param string $migration_file_path the absolute path to the migration file. 164 | */ 165 | private function add_pan_import_data( $migration_file_path ) { 166 | // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents 167 | $contents = file_get_contents( $migration_file_path ); 168 | $lines = explode( "\n", $contents ); 169 | 170 | foreach ( $lines as $i => $line ) { 171 | $line = str_replace( '"', '', $line ); 172 | $data = explode( ',', $line ); 173 | 174 | if ( 0 === $i && ! $this->is_csv_correctly_formatted( $data ) ) { 175 | WP_CLI::line( WP_CLI::colorize( '%RError:%n ' ) . 'CSV not correctly formatted' ); 176 | die(); 177 | } 178 | 179 | if ( 0 === $i ) { 180 | continue; 181 | } 182 | 183 | WP_CLI::line( '' ); 184 | WP_CLI::line( 'Updating customer: ' . $data[ self::NEW_CUSTOMER_ID_POS ] ); 185 | $user = $this->get_user_by_stripe_id( $data[ self::NEW_CUSTOMER_ID_POS ] ); 186 | 187 | if ( ! $user ) { 188 | continue; 189 | } 190 | 191 | update_user_option( $user->ID, '_original_customer_id', $data[ self::OLD_CUSTOMER_ID_POS ] ); 192 | 193 | $token = $this->get_customer_token( $user->ID, $data[ self::NEW_SOURCE_ID_POS ] ); 194 | 195 | if ( ! $token ) { 196 | WP_CLI::line( WP_CLI::colorize( '%RError:%n ' ) . 'Token not found:' . $data[ self::NEW_SOURCE_ID_POS ] ); 197 | continue; 198 | } 199 | 200 | $token->update_meta_data( self::ORIGINAL_PAYMENT_METHOD_ID_KEY, $data[ self::OLD_SOURCE_ID_POS ] ); 201 | $token->save_meta_data(); 202 | 203 | if ( $i % 100 === 0 ) { 204 | Migrator_CLI_Utils::reset_in_memory_cache(); 205 | } 206 | } 207 | } 208 | 209 | /** 210 | * Checks if the mapping csv file is correctly formatted. 211 | * It checks the header to make sure the data is in the correct column. 212 | * 213 | * @param array $data the header data. 214 | * @return bool 215 | */ 216 | private function is_csv_correctly_formatted( $data ) { 217 | return 'customer_id_old' === $data[ self::OLD_CUSTOMER_ID_POS ] && 218 | 'source_id_old' === $data[ self::OLD_SOURCE_ID_POS ] && 219 | 'customer_id_new' === $data[ self::NEW_CUSTOMER_ID_POS ] && 220 | 'source_id_new' === $data[ self::NEW_SOURCE_ID_POS ]; 221 | } 222 | 223 | /** 224 | * Searches a customer token by it's stripe token id. 225 | * 226 | * @param int $user_id 227 | * @param string $stripe_token_id 228 | * @return void|WC_Payment_Token 229 | */ 230 | private function get_customer_token( $user_id, $stripe_token_id ) { 231 | $saved_payment_tokens = WC_Payment_Tokens::get_customer_tokens( $user_id ); 232 | 233 | foreach ( $saved_payment_tokens as $token ) { 234 | if ( $token->get_token() === $stripe_token_id ) { 235 | return $token; 236 | } 237 | } 238 | } 239 | 240 | /** 241 | * Gets the customer token by it's old payment method id. 242 | * 243 | * @param int $user_id the user to be searched. 244 | * @param string $old_payment_method_id the old payment method id. 245 | * @param string $old_payment_method_last_4 the last 4 digits of the old payment method to check if the new one matches. 246 | * @return void|WC_Payment_Token 247 | */ 248 | private function get_customer_token_by_old_payment_method_id( $user_id, $old_payment_method_id, $old_payment_method_last_4 ) { 249 | $tokens = WC_Payment_Tokens::get_customer_tokens( $user_id ); 250 | 251 | foreach ( $tokens as $token ) { 252 | if ( $token->get_meta( self::ORIGINAL_PAYMENT_METHOD_ID_KEY ) === $old_payment_method_id ) { 253 | if ( (int) $old_payment_method_last_4 === (int) $token->get_meta( 'last4' ) ) { 254 | return $token; 255 | } else { 256 | WP_CLI::line( WP_CLI::colorize( '%RError:%n ' ) . 'Mismatch Payment Token last 4:' . $old_payment_method_id ); 257 | } 258 | } 259 | } 260 | } 261 | 262 | /** 263 | * Searches a WP_User by Stripe id. 264 | * 265 | * @param string $stripe_id stripe customer id `cus_`. 266 | * @return WP_User 267 | */ 268 | private function get_user_by_stripe_id( $stripe_id ) { 269 | $users = get_users( 270 | array( 271 | 'meta_key' => 'wp_' . self::get_customer_id_option(), 272 | 'meta_value' => $stripe_id, 273 | ) 274 | ); 275 | 276 | if ( is_wp_error( $users ) || ! $users ) { 277 | WP_CLI::line( WP_CLI::colorize( '%RError: %n ' ) . 'Customer not found' ); 278 | return; 279 | } 280 | 281 | if ( count( $users ) > 1 ) { 282 | WP_CLI::line( WP_CLI::colorize( '%RError:%n ' ) . 'Multiple Customers found' ); 283 | return; 284 | } 285 | 286 | return reset( $users ); 287 | } 288 | 289 | /** 290 | * Sets the new payment method for orders and subscriptions. 291 | * 292 | * @param string $type shop_order|shop_subscription. 293 | */ 294 | private function update_orders_or_subscriptions( $type, $ids ) { 295 | $page = 1; 296 | 297 | do { 298 | $query = array( 299 | 'type' => $type, 300 | 'status' => 'all', 301 | 'limit' => 100, 302 | 'paged' => $page, 303 | ); 304 | 305 | if ( $ids ) { 306 | $query['post__in'] = $ids; 307 | } 308 | 309 | $orders = wc_get_orders( $query ); 310 | 311 | /** @var WC_Order|WC_Subscription $order */ 312 | foreach ( $orders as $order ) { 313 | WP_CLI::line( '' ); 314 | 315 | if ( 'shop_order' === $type ) { 316 | WP_CLI::line( 'Processing shop_order: ' . $order->get_id() . ' ' . $order->get_meta( '_original_order_id' ) ); 317 | } else { 318 | WP_CLI::line( 'Processing shop_subscription: ' . $order->get_id() . ' ' . $order->get_meta( '_skio_subscription_id' ) ); 319 | } 320 | 321 | switch ( $order->get_meta( self::ORIGINAL_PAYMENT_GATEWAY_KEY ) ) { 322 | case 'shopify_payments': 323 | $this->process_shopify_payments( $order ); 324 | break; 325 | case 'paypal': 326 | if ( ! $order->get_meta( Migrator_Cli_Payment_Methods::ORIGINAL_PAYMENT_METHOD_ID_KEY ) && 'shop_subscription' === $type ) { 327 | WP_CLI::line( WP_CLI::colorize( '%RError:%n ' ) . 'Paypal subscription does not have a billing agreement. Renewals will fail' ); 328 | } 329 | break; 330 | case 'manual': 331 | break; 332 | case null: 333 | if ( 'shop_subscription' === $type ) { 334 | WP_CLI::line( WP_CLI::colorize( '%RError:%n ' ) . 'Payment gateway not set' ); 335 | } 336 | break; 337 | default: 338 | if ( 'shop_subscription' === $type ) { 339 | WP_CLI::line( WP_CLI::colorize( '%RError:%n ' ) . 'Unknown payment gateway. Renewal will fail: ' . $order->get_meta( self::ORIGINAL_PAYMENT_GATEWAY_KEY ) ); 340 | } else { 341 | WP_CLI::line( WP_CLI::colorize( '%YWarning:%n ' ) . 'Unknown payment gateway: ' . $order->get_meta( self::ORIGINAL_PAYMENT_GATEWAY_KEY ) ); 342 | } 343 | } 344 | } 345 | 346 | ++$page; 347 | } while ( $orders ); 348 | } 349 | 350 | /** 351 | * Returns the meta_key where the stripe customer_id is stored without the wp_ at the beginning. 352 | * Extracted from https://github.com/Automattic/woocommerce-payments/blob/92525c2a637bf592ec412bb0a979ab91862575d1/includes/class-wc-payments-customer-service.php#L407-L416 353 | * @return string 354 | */ 355 | private static function get_customer_id_option(): string { 356 | return WC_Payments::mode()->is_test() 357 | ? WC_Payments_Customer_Service::WCPAY_TEST_CUSTOMER_ID_OPTION 358 | : WC_Payments_Customer_Service::WCPAY_LIVE_CUSTOMER_ID_OPTION; 359 | } 360 | 361 | /** 362 | * Updates shopify_payments to WooPayments. 363 | * 364 | * @param WC_Order $order the order or subscription to be updated/ 365 | */ 366 | private function process_shopify_payments( WC_Order $order ) { 367 | $old_payment_method_id = $order->get_meta( self::ORIGINAL_PAYMENT_METHOD_ID_KEY ); 368 | $old_payment_method_last_4 = $order->get_meta( self::ORIGINAL_PAYMENT_LAST_4 ); 369 | $customer = new WC_Customer( $order->get_customer_id() ); 370 | 371 | $token = $this->get_customer_token_by_old_payment_method_id( $order->get_customer_id(), $old_payment_method_id, $old_payment_method_last_4 ); 372 | 373 | if ( ! $token ) { 374 | WP_CLI::line( WP_CLI::colorize( '%RError:%n ' ) . 'Payment Token not found: ' . $old_payment_method_id ); 375 | return; 376 | } 377 | 378 | $order->set_payment_method( WC_Payments::get_registered_card_gateway()::GATEWAY_ID ); 379 | $order->update_meta_data( WC_Payments_Order_Service::CUSTOMER_ID_META_KEY, $customer->get_meta( 'wp_' . self::get_customer_id_option() ) ); 380 | $order->add_payment_token( $token ); 381 | $order->save(); 382 | } 383 | } 384 | -------------------------------------------------------------------------------- /includes/class-migrator-cli-subscriptions.php: -------------------------------------------------------------------------------- 1 | Export and export both subscriptions and orders. Then pass the file path to --subscriptions_export_file and --orders_export_file args' ); 17 | return; 18 | } 19 | 20 | $skio_orders = $this->get_data_from_file( $assoc_args['orders_export_file'] ); 21 | $skio_subscriptions = $this->get_data_from_file( $assoc_args['subscriptions_export_file'] ); 22 | 23 | Migrator_CLI_Utils::disable_sequential_orders(); 24 | 25 | WP_CLI::line( 'Adding Subscription ids to orders' ); 26 | $this->add_subscription_id_to_orders( $skio_orders ); 27 | 28 | WP_CLI::line( 'Creating Subscriptions' ); 29 | $this->create_or_update_subscriptions( $skio_subscriptions ); 30 | 31 | Migrator_CLI_Utils::enable_sequential_orders(); 32 | } catch ( \Exception $e ) { 33 | WP_CLI::line( WP_CLI::colorize( '%RError:%n ' . $e->getMessage() ) ); 34 | } 35 | 36 | WP_CLI::line( WP_CLI::colorize( '%GDone%n' ) ); 37 | } 38 | 39 | /** 40 | * Clones the line item and adds it to the subscription. 41 | * 42 | * @param WC_Order_Item $item the item to be cloned. 43 | * @param WC_Subscription $subscription the subscription to add the line item to. 44 | */ 45 | private function clone_item_to_subscription( $item, $subscription ) { 46 | $new_item = clone $item; 47 | $new_item->set_id( 0 ); 48 | $new_item->set_order_id( $subscription->get_id() ); 49 | $new_item->save(); 50 | 51 | $subscription->add_item( $new_item ); 52 | } 53 | 54 | /** 55 | * Gets the json data from a file. 56 | * 57 | * @param string $file the file path. 58 | * @return array 59 | */ 60 | private function get_data_from_file( $file ) { 61 | if ( ! is_file( $file ) ) { 62 | WP_CLI::line( WP_CLI::colorize( '%RError:%n ' ) . 'File not found: ' . $file ); 63 | die(); 64 | } 65 | 66 | return wp_json_file_decode( $file, array( 'associative' => true ) ); 67 | } 68 | 69 | /** 70 | * Add the Shopify subscription ids to Woo orders. 71 | * 72 | * @param array $skio_orders the Skio orders data. 73 | */ 74 | private function add_subscription_id_to_orders( $skio_orders ) { 75 | foreach ( $skio_orders as $key => $skio_order ) { 76 | WP_CLI::line( 'Processing order: ' . $skio_order['orderPlatformNumber'] ); 77 | 78 | $args = array( 79 | 'meta_key' => '_order_number', 80 | 'meta_value' => preg_replace("/[^0-9]/", "", $skio_order['orderPlatformNumber'] ), 81 | 'meta_compare' => '=', 82 | 'numberposts' => 1, 83 | ); 84 | 85 | $skio_orders = wc_get_orders( $args ); 86 | 87 | if ( ! $skio_orders ) { 88 | WP_CLI::line( WP_CLI::colorize( '%RError:%n ' ) . 'Woo Order not found for Shopify Order: ' . $skio_order['orderPlatformNumber'] ); 89 | continue; 90 | } 91 | 92 | /** @var WC_Order $skio_order */ 93 | $order = reset( $skio_orders ); 94 | $order->update_meta_data( '_skio_subscription_id', $skio_order['subscriptionId'] ); 95 | $order->save_meta_data(); 96 | 97 | if ( $key % 100 === 0 ) { 98 | Migrator_CLI_Utils::reset_in_memory_cache(); 99 | } 100 | } 101 | } 102 | 103 | /** 104 | * Creates or updates subscriptions. 105 | * 106 | * @param array $skio_subscriptions the Skio subscriptions array. 107 | */ 108 | private function create_or_update_subscriptions( $skio_subscriptions ) { 109 | foreach ( $skio_subscriptions as $key => $skio_subscription ) { 110 | WP_CLI::line( '' ); 111 | WP_CLI::line( 'Processing subscription: ' . $skio_subscription['subscriptionId'] ); 112 | 113 | // Get all the orders for that subscription. 114 | $args = array( 115 | 'meta_key' => '_skio_subscription_id', 116 | 'meta_value' => $skio_subscription['subscriptionId'], 117 | 'meta_compare' => '=', 118 | 'numberposts' => -1, 119 | 'orderby' => 'date_created', 120 | 'order' => 'ASC', 121 | ); 122 | $existing_orders = wc_get_orders( $args ); 123 | 124 | if ( ! $existing_orders ) { 125 | if ( 'CANCELLED' === $skio_subscription['status'] ) { 126 | WP_CLI::line( WP_CLI::colorize( '%YWarning:%n ' ) . 'Skipping Cancelled subscription without any orders: ' . $skio_subscription['subscriptionId'] ); 127 | } else { 128 | WP_CLI::line( WP_CLI::colorize( '%RError:%n ' ) . 'Woo Order not found for Skio Subscription: ' . $skio_subscription['subscriptionId'] ); 129 | } 130 | 131 | continue; 132 | } 133 | 134 | // Used as the order that originated the subscription. 135 | $oldest_order = reset( $existing_orders ); 136 | // Most up to date order to make sure the subscription is correct. 137 | $latest_order = end( $existing_orders ); 138 | 139 | $subscription = $this->get_or_create_subscription( $skio_subscription, $oldest_order ); 140 | 141 | if ( is_wp_error( $subscription ) ) { 142 | WP_CLI::line( WP_CLI::colorize( '%RError:%n ' ) . 'When creating the subscription: ' . $subscription->get_error_message() ); 143 | continue; 144 | } 145 | 146 | $this->add_line_items( $subscription, $latest_order ); 147 | $this->update_billing_address( $subscription, $latest_order ); 148 | $this->update_shipping_address( $subscription, $latest_order ); 149 | 150 | $subscription->set_requires_manual_renewal( true ); 151 | $subscription->set_payment_method( $latest_order->get_payment_method() ); 152 | $subscription->set_payment_method_title( $latest_order->get_payment_method_title() ); 153 | $subscription->set_shipping_total( $latest_order->get_shipping_total() ); 154 | $subscription->set_discount_total( $latest_order->get_discount_total() ); 155 | $subscription->update_meta_data( '_skio_subscription_id', $skio_subscription['subscriptionId'] ); 156 | 157 | $this->attatch_orders( $subscription, $existing_orders, $oldest_order ); 158 | $this->set_subscription_status( $subscription, $skio_subscription ); 159 | $this->process_payment_method( $subscription, $skio_subscription, $latest_order ); 160 | 161 | $subscription->save(); 162 | $subscription->calculate_totals(); 163 | if ( $subscription->get_total() !== $latest_order->get_total() ) { 164 | WP_CLI::line( WP_CLI::colorize( '%RError:%n ' ) . 'Totals mismatch between last order and subscription. This could mean the coupon is wrong or some item is missing' ); 165 | } 166 | 167 | if ( $key % 100 === 0 ) { 168 | Migrator_CLI_Utils::reset_in_memory_cache(); 169 | } 170 | } 171 | } 172 | 173 | /** 174 | * Gets or creates a new Woo subscription. 175 | * 176 | * @param array $skio_subscription the skio subscription data. 177 | * @param WC_Order $oldest_order the oldest Woo order for this subscription. 178 | * @return WC_Subscription 179 | */ 180 | private function get_or_create_subscription( $skio_subscription, $oldest_order ) { 181 | $args = array( 182 | 'type' => 'shop_subscription', 183 | 'meta_key' => '_skio_subscription_id', 184 | 'meta_value' => $skio_subscription['subscriptionId'], 185 | 'meta_compare' => '=', 186 | 'numberposts' => 1, 187 | 'status' => 'any', 188 | ); 189 | 190 | $existing_subscriptions = wcs_get_orders_with_meta_query( $args ); 191 | 192 | if ( $existing_subscriptions ) { 193 | /** @var WC_Subscription $subscription */ 194 | $subscription = end( $existing_subscriptions ); 195 | 196 | $subscription->update_dates( 197 | array( 198 | 'cancelled' => 0, 199 | 'end' => 0, 200 | 'next_payment' => 0, 201 | 'start' => 0, 202 | 'date_created' => 0, 203 | 'date_modified' => 0, 204 | 'date_paid' => 0, 205 | 'date_completed' => 0, 206 | 'last_order_date_created' => 0, 207 | 'trial_end' => 0, 208 | 'last_order_date_paid' => 0, 209 | 'last_order_date_completed' => 0, 210 | 'payment_retry' => 0, 211 | ) 212 | ); 213 | 214 | $subscription->save(); 215 | 216 | WP_CLI::line( 'Found existing subscription updating it instead' ); 217 | } else { 218 | WP_CLI::line( 'Creating new subscription' ); 219 | 220 | $create_date = date_create( $skio_subscription['createdAt'] ); 221 | $create_date = date_format( $create_date, 'Y-m-d H:i:s' ); 222 | 223 | $subscription = wcs_create_subscription( 224 | array( 225 | 'status' => '', 226 | 'order_id' => $oldest_order->get_id(), 227 | 'customer_id' => $oldest_order->get_customer_id(), 228 | 'date_created' => $create_date, 229 | 'billing_interval' => $skio_subscription['billingPolicyIntervalCount'], 230 | 'billing_period' => mb_strtolower( $skio_subscription['billingPolicyInterval'] ), 231 | ) 232 | ); 233 | } 234 | 235 | WP_CLI::line( 'Woo Subscription id: ' . $subscription->get_id() ); 236 | 237 | return $subscription; 238 | } 239 | 240 | /** 241 | * Copies line items from a order to a subscription. 242 | * 243 | * @param WC_Subscription $subscription the Woo subscription. 244 | * @param WC_Order $latest_order the Woo order. 245 | */ 246 | private function add_line_items( $subscription, $latest_order ) { 247 | 248 | // Prevents duplication on updates. 249 | foreach ( $subscription->get_items( array( 'line_item', 'tax', 'shipping', 'coupon' ) ) as $subscription_item ) { 250 | $subscription->remove_item( $subscription_item->get_id() ); 251 | } 252 | 253 | foreach ( $latest_order->get_items( array( 'line_item', 'tax', 'shipping' ) ) as $item ) { 254 | $this->clone_item_to_subscription( $item, $subscription ); 255 | } 256 | 257 | foreach ( $latest_order->get_items( array( 'coupon' ) ) as $item ) { 258 | 259 | $coupon = new WC_Coupon( $item->get_code() ); 260 | 261 | // Coupons that apply to the first cycle only shouldn't be added to the subscription 262 | if ( 'yes' === $coupon->get_meta( '_apply_to_first_cycle_only' ) || 1 === (int) $coupon->get_meta( '_wcs_number_payments' ) ) { 263 | WP_CLI::line( WP_CLI::colorize( '%YWarning:%n ' ) . 'This coupon should only be used in the first cycle. Not applying to Subscription. Total mismatch may happen. But that should be ok' ); 264 | return; 265 | } 266 | 267 | $result = $subscription->apply_coupon( $item->get_code() ); 268 | if ( ! $result ) { 269 | WP_CLI::line( WP_CLI::colorize( '%RError%n ' ) . 'Could not apply coupon: ' . $item->get_code() ); 270 | } 271 | 272 | if ( is_wp_error( $result ) ) { 273 | WP_CLI::line( WP_CLI::colorize( '%RError%n ' ) . 'Could not apply coupon: ' . $item->get_code() . ' ' . $result->get_error_message() ); 274 | } 275 | } 276 | } 277 | 278 | /** 279 | * Copies the billing address from an order into the subscription. 280 | * 281 | * @param WC_Subscription $subscription the Woo subscription. 282 | * @param WC_Order $latest_order the latest Woo order for that subscription. 283 | */ 284 | private function update_billing_address( $subscription, $latest_order ) { 285 | $subscription->set_billing_first_name( $latest_order->get_billing_first_name() ); 286 | $subscription->set_billing_last_name( $latest_order->get_billing_last_name() ); 287 | $subscription->set_billing_company( $latest_order->get_billing_company() ); 288 | $subscription->set_billing_address_1( $latest_order->get_billing_address_1() ); 289 | $subscription->set_billing_address_2( $latest_order->get_billing_address_2() ); 290 | $subscription->set_billing_city( $latest_order->get_billing_city() ); 291 | $subscription->set_billing_state( $latest_order->get_billing_state() ); 292 | $subscription->set_billing_postcode( $latest_order->get_billing_postcode() ); 293 | $subscription->set_billing_country( $latest_order->get_billing_country() ); 294 | $subscription->set_billing_phone( $latest_order->get_billing_phone() ); 295 | } 296 | 297 | /** 298 | * Copies the shipping address from an order into the subscription. 299 | * 300 | * @param WC_Subscription $subscription the Woo subscription. 301 | * @param WC_Order $latest_order the latest Woo order for that subscription. 302 | */ 303 | private function update_shipping_address( $subscription, $latest_order ) { 304 | $subscription->set_shipping_first_name( $latest_order->get_shipping_first_name() ); 305 | $subscription->set_shipping_last_name( $latest_order->get_shipping_last_name() ); 306 | $subscription->set_shipping_company( $latest_order->get_shipping_company() ); 307 | $subscription->set_shipping_address_1( $latest_order->get_shipping_address_1() ); 308 | $subscription->set_shipping_address_2( $latest_order->get_shipping_address_2() ); 309 | $subscription->set_shipping_city( $latest_order->get_shipping_city() ); 310 | $subscription->set_shipping_state( $latest_order->get_shipping_state() ); 311 | $subscription->set_shipping_postcode( $latest_order->get_shipping_postcode() ); 312 | $subscription->set_shipping_country( $latest_order->get_shipping_country() ); 313 | $subscription->set_shipping_phone( $latest_order->get_shipping_phone() ); 314 | } 315 | 316 | /** 317 | * Attaches the orders to a subscription. 318 | * 319 | * @param WC_Subscription $subscription the Woo subscription. 320 | * @param array $existing_orders the orders that should be attached to that subscription. 321 | * @param WC_Order $oldest_order the oldest order for that subscription. 322 | */ 323 | private function attatch_orders( $subscription, $existing_orders, $oldest_order ) { 324 | foreach ( $existing_orders as $order ) { 325 | // Prevents adding the oldest order twice. 326 | if ( $order->get_id() === $oldest_order->get_id() ) { 327 | continue; 328 | } 329 | 330 | WCS_Related_Order_Store::instance()->add_relation( $order, $subscription, 'renewal' ); 331 | } 332 | } 333 | 334 | /** 335 | * Converts and sets the subscription status 336 | * 337 | * @param WC_Subscription $subscription the Woo subscription. 338 | * @param array $skio_subscription the Skio subscription data. 339 | */ 340 | private function set_subscription_status( $subscription, $skio_subscription ) { 341 | 342 | if ( ! in_array( $skio_subscription['status'], array( 'ACTIVE', 'CANCELLED', 'FAILED' ), true ) ) { 343 | WP_CLI::line( WP_CLI::colorize( '%YWarning:%n ' ) . 'Unknown subscription status: ' . $skio_subscription['status'] ); 344 | } 345 | 346 | $subscription->set_status( mb_strtolower( $skio_subscription['status'] ) ); 347 | 348 | /** 349 | * Skio does not store failed orders. 350 | * Set it as active to try again so the customer can update the payment method. 351 | */ 352 | if ( 'FAILED' === $skio_subscription['status'] ) { 353 | $subscription->set_status( 'active' ); 354 | } 355 | 356 | if ( in_array( $skio_subscription['status'], array( 'ACTIVE', 'FAILED' ), true ) && isset( $skio_subscription['nextBillingDate'] ) ) { 357 | $next_payment = date_create( $skio_subscription['nextBillingDate'] ); 358 | $next_payment = date_format( $next_payment, 'Y-m-d H:i:s' ); 359 | $subscription->update_dates( 360 | array( 361 | 'next_payment' => $next_payment, 362 | ) 363 | ); 364 | } 365 | 366 | if ( 'CANCELLED' === $skio_subscription['status'] && isset( $skio_subscription['cancelledAt'] ) ) { 367 | $cancelled_date = new DateTime( $skio_subscription['cancelledAt'] ); 368 | $cancelled_date = date_format( $cancelled_date, 'Y-m-d H:i:s' ); 369 | $subscription->update_dates( 370 | array( 371 | 'cancelled' => $cancelled_date, 372 | 'end' => $cancelled_date, 373 | ) 374 | ); 375 | } 376 | } 377 | 378 | /** 379 | * Saves the old payment method data into the subscription meta table 380 | * so it can be used during the mapping update by 381 | * Migrator_CLI_Payment_Methods::update_orders_and_subscriptions_payment_methods. 382 | * 383 | * @param WC_Subscription $subscription the subscription to be updated. 384 | * @param array $skio_subscription the skio subscription data. 385 | * @param WC_Order $latest_order the last order to be used to extract data from. 386 | */ 387 | private function process_payment_method( WC_Subscription $subscription, $skio_subscription, WC_Order $latest_order ) { 388 | 389 | $subscription->update_meta_data( '_payment_method_id', $latest_order->get_meta( '_payment_method_id' ) ); 390 | $subscription->update_meta_data( '_payment_tokens', $latest_order->get_meta( '_payment_tokens' ) ); 391 | 392 | // Case matters here. 393 | switch ( $latest_order->get_meta( Migrator_Cli_Payment_Methods::ORIGINAL_PAYMENT_GATEWAY_KEY ) ) { 394 | case 'shopify_payments': 395 | if ( ! class_exists( 'WC_Gateway_PPEC_Plugin' ) ) { 396 | WP_CLI::line( WP_CLI::colorize( '%RError:%n ' ) . 'PayPal Express Plugin not installed. It will be necessary to process payments for this subscription' ); 397 | } 398 | 399 | if ( (int) $latest_order->get_meta( Migrator_Cli_Payment_Methods::ORIGINAL_PAYMENT_LAST_4 ) !== (int) $skio_subscription['paymentMethodLastDigits'] ) { 400 | WP_CLI::line( WP_CLI::colorize( '%RError:%n ' ) . 'Mismatch in subscription payment method last 4' ); 401 | return; 402 | } 403 | 404 | $subscription->update_meta_data( Migrator_Cli_Payment_Methods::ORIGINAL_PAYMENT_GATEWAY_KEY, $latest_order->get_meta( Migrator_Cli_Payment_Methods::ORIGINAL_PAYMENT_GATEWAY_KEY ) ); 405 | $subscription->update_meta_data( Migrator_Cli_Payment_Methods::ORIGINAL_PAYMENT_METHOD_ID_KEY, $latest_order->get_meta( Migrator_Cli_Payment_Methods::ORIGINAL_PAYMENT_METHOD_ID_KEY ) ); 406 | $subscription->update_meta_data( Migrator_Cli_Payment_Methods::ORIGINAL_PAYMENT_LAST_4, $latest_order->get_meta( Migrator_Cli_Payment_Methods::ORIGINAL_PAYMENT_LAST_4 ) ); 407 | break; 408 | // 'paypal' not 'PayPal' they are two different gateways. 409 | case 'paypal': 410 | if ( ! class_exists( 'WC_Gateway_PPEC_Plugin' ) ) { 411 | WP_CLI::line( WP_CLI::colorize( '%RError:%n ' ) . 'PayPal Express Checkout Plugin not installed. It will be necessary to process payments for this subscription' ); 412 | } 413 | 414 | $subscription->update_meta_data( Migrator_Cli_Payment_Methods::ORIGINAL_PAYMENT_GATEWAY_KEY, $latest_order->get_meta( Migrator_Cli_Payment_Methods::ORIGINAL_PAYMENT_GATEWAY_KEY ) ); 415 | $subscription->update_meta_data( Migrator_Cli_Payment_Methods::ORIGINAL_PAYMENT_METHOD_ID_KEY, $latest_order->get_meta( Migrator_Cli_Payment_Methods::ORIGINAL_PAYMENT_METHOD_ID_KEY ) ); 416 | 417 | $subscription->set_payment_method( 'ppec_paypal' ); 418 | $subscription->update_meta_data( '_ppec_billing_agreement_id', $latest_order->get_meta( Migrator_Cli_Payment_Methods::ORIGINAL_PAYMENT_METHOD_ID_KEY ) ); 419 | break; 420 | default: 421 | WP_CLI::line( WP_CLI::colorize( '%RError:%n ' ) . 'Unsupported payment gateway: ' . $latest_order->get_meta( Migrator_Cli_Payment_Methods::ORIGINAL_PAYMENT_GATEWAY_KEY ) ); 422 | } 423 | } 424 | } 425 | -------------------------------------------------------------------------------- /includes/class-migrator-cli-coupons.php: -------------------------------------------------------------------------------- 1 | get_next_discount( $cursor ); 18 | $discount = reset( $response_data->data->codeDiscountNodes->edges ); 19 | $cursor = $discount->cursor; 20 | $discount = $discount->node->codeDiscount; 21 | 22 | if ( ! empty( (array) $discount ) ) { 23 | $this->create_or_update_coupon( $discount ); 24 | } else { 25 | WP_CLI::line( WP_CLI::colorize( '%BInfo:%n ' ) . 'Discount was empty. Probably unsupported type: ' ); 26 | } 27 | 28 | WP_CLI::line( WP_CLI::colorize( '%BInfo:%n ' ) . 'Cursor: ' . $cursor ); 29 | 30 | // Prevents api throttling. 31 | sleep( 1 ); 32 | ++$imported; 33 | 34 | if ( $imported % 100 === 0 ) { 35 | Migrator_CLI_Utils::reset_in_memory_cache(); 36 | } 37 | } while ( $response_data->data->codeDiscountNodes->pageInfo->hasNextPage && $imported < $limit ); 38 | 39 | WP_CLI::line( WP_CLI::colorize( '%GDone%n' ) ); 40 | } 41 | 42 | private function get_next_discount( $cursor ) { 43 | if ( $cursor ) { 44 | $cursor = 'after: "' . $cursor . '"'; 45 | } 46 | 47 | $response = Migrator_CLI_Utils::graphql_request( 48 | array( 49 | 'query' => '{ 50 | codeDiscountNodes( 51 | first: 1 52 | reverse: true 53 | ' . $cursor . ' 54 | ) { 55 | edges { 56 | cursor 57 | node { 58 | id 59 | codeDiscount { 60 | ... on DiscountCodeFreeShipping { 61 | usageLimit 62 | appliesOnOneTimePurchase 63 | appliesOnSubscription 64 | appliesOncePerCustomer 65 | asyncUsageCount 66 | codeCount 67 | codes(first: 200) { 68 | nodes { 69 | asyncUsageCount 70 | code 71 | id 72 | } 73 | } 74 | combinesWith { 75 | orderDiscounts 76 | productDiscounts 77 | shippingDiscounts 78 | } 79 | updatedAt 80 | createdAt 81 | customerSelection { 82 | ... on DiscountCustomers { 83 | customers { 84 | email 85 | } 86 | } 87 | ... on DiscountCustomerSegments { 88 | segments { 89 | id 90 | name 91 | } 92 | } 93 | ... on DiscountCustomerAll { 94 | allCustomers 95 | } 96 | } 97 | destinationSelection { 98 | ... on DiscountCountryAll { 99 | allCountries 100 | } 101 | ... on DiscountCountries { 102 | countries 103 | includeRestOfWorld 104 | } 105 | } 106 | discountClass 107 | endsAt 108 | startsAt 109 | maximumShippingPrice { 110 | amount 111 | } 112 | minimumRequirement { 113 | ... on DiscountMinimumSubtotal { 114 | greaterThanOrEqualToSubtotal { 115 | amount 116 | } 117 | } 118 | ... on DiscountMinimumQuantity { 119 | greaterThanOrEqualToQuantity 120 | } 121 | } 122 | recurringCycleLimit 123 | summary 124 | title 125 | } 126 | ... on DiscountCodeBasic { 127 | appliesOncePerCustomer 128 | asyncUsageCount 129 | createdAt 130 | discountClass 131 | endsAt 132 | hasTimelineComment 133 | minimumRequirement { 134 | ... on DiscountMinimumSubtotal { 135 | greaterThanOrEqualToSubtotal { 136 | amount 137 | currencyCode 138 | } 139 | } 140 | ... on DiscountMinimumQuantity { 141 | greaterThanOrEqualToQuantity 142 | } 143 | } 144 | recurringCycleLimit 145 | shortSummary 146 | startsAt 147 | status 148 | summary 149 | title 150 | updatedAt 151 | usageLimit 152 | codes(first: 200) { 153 | nodes { 154 | asyncUsageCount 155 | code 156 | id 157 | } 158 | } 159 | combinesWith { 160 | orderDiscounts 161 | productDiscounts 162 | shippingDiscounts 163 | } 164 | customerGets { 165 | appliesOnOneTimePurchase 166 | appliesOnSubscription 167 | value { 168 | ... on DiscountPercentage { 169 | percentage 170 | } 171 | ... on DiscountOnQuantity { 172 | effect { 173 | ... on DiscountPercentage { 174 | percentage 175 | } 176 | } 177 | quantity { 178 | quantity 179 | } 180 | } 181 | ... on DiscountAmount { 182 | appliesOnEachItem 183 | amount { 184 | amount 185 | currencyCode 186 | } 187 | } 188 | } 189 | items { 190 | ... on DiscountProducts { 191 | products(first: 100) { 192 | nodes { 193 | id 194 | legacyResourceId 195 | } 196 | } 197 | } 198 | ... on AllDiscountItems { 199 | allItems 200 | } 201 | ... on DiscountCollections { 202 | collections(first: 100) { 203 | nodes { 204 | handle 205 | } 206 | } 207 | } 208 | } 209 | } 210 | customerSelection { 211 | ... on DiscountCustomerAll { 212 | allCustomers 213 | } 214 | ... on DiscountCustomers { 215 | customers { 216 | email 217 | id 218 | } 219 | } 220 | ... on DiscountCustomerSegments { 221 | segments { 222 | id 223 | name 224 | } 225 | } 226 | } 227 | } 228 | } 229 | } 230 | } 231 | pageInfo { 232 | endCursor 233 | hasNextPage 234 | hasPreviousPage 235 | startCursor 236 | } 237 | } 238 | }', 239 | ) 240 | ); 241 | 242 | $response_data = json_decode( wp_remote_retrieve_body( $response ) ); 243 | 244 | if ( isset( $response_data->errors ) ) { 245 | WP_CLI::error( 'API Error: ' . $response_data->errors[0]->message ); 246 | } 247 | 248 | if ( empty( $response_data->data->codeDiscountNodes ) ) { 249 | WP_CLI::error( 'No coupons found.' ); 250 | } 251 | 252 | return $response_data; 253 | } 254 | 255 | /** 256 | * Creates or updates a coupon. 257 | * 258 | * @param object $discount the shopify discount data. 259 | * @param object $child_code the child coupon code. 260 | */ 261 | private function create_or_update_coupon( $discount, $child_code = null ) { 262 | WP_CLI::line( '=========================================================================' ); 263 | 264 | if ( $child_code ) { 265 | $coupon_code = $child_code->code; 266 | $usage = $child_code->asyncUsageCount; 267 | } else { 268 | $coupon_code = $discount->title; 269 | $usage = $discount->asyncUsageCount; 270 | 271 | if ( isset( $discount->codes->nodes[0] ) ) { 272 | $coupon_code = $discount->codes->nodes[0]->code; 273 | unset( $discount->codes->nodes[0] ); 274 | } 275 | } 276 | 277 | WP_CLI::line( WP_CLI::colorize( '%BInfo:%n ' ) . 'Processing coupon: ' . $coupon_code ); 278 | $coupon = new WC_Coupon( $coupon_code ); 279 | 280 | if ( 0 === $coupon->get_id() ) { 281 | WP_CLI::line( "Coupon didn't exist yet creating a new one" ); 282 | } else { 283 | WP_CLI::line( 'Coupon already exists updating' ); 284 | } 285 | 286 | $this->cleanup( $coupon ); 287 | 288 | $coupon->set_code( $coupon_code ); 289 | $coupon->set_description( $discount->summary ); 290 | $coupon->set_date_created( $discount->createdAt ); 291 | $coupon->set_date_expires( $discount->endsAt ); 292 | $coupon->set_date_modified( $discount->updatedAt ); 293 | $coupon->set_usage_limit( $discount->usageLimit ); 294 | $coupon->set_usage_count( $usage ); 295 | $coupon->set_usage_limit_per_user( true === $discount->appliesOncePerCustomer ? 1 : null ); 296 | 297 | $this->check_unsupported_rules( $discount ); 298 | $this->set_restrictions( $coupon, $discount ); 299 | $this->set_limits( $coupon, $discount ); 300 | $this->set_discount_type( $coupon, $discount ); 301 | 302 | if ( 'SHIPPING' === $discount->discountClass ) { 303 | $coupon->set_free_shipping( 1 ); 304 | } 305 | 306 | $coupon->save(); 307 | 308 | if ( ! $child_code ) { 309 | if ( count( $discount->codes->nodes ) >= 199 ) { 310 | WP_CLI::line( WP_CLI::colorize( '%YWarning:%n ' ) . 'Only the first 200 codes will be processed for this coupon.' ); 311 | } 312 | 313 | foreach ( $discount->codes->nodes as $code ) { 314 | $this->create_or_update_coupon( $discount, $code ); 315 | } 316 | } 317 | } 318 | 319 | /** 320 | * Cleans up a coupon before it can be updated 321 | * to prevent the wrong data from being saved. 322 | * 323 | * @param WC_Coupon $coupon the Woo coupon. 324 | */ 325 | private function cleanup( $coupon ) { 326 | $coupon->set_minimum_amount( null ); 327 | $coupon->set_email_restrictions( null ); 328 | $coupon->set_free_shipping( null ); 329 | $coupon->set_discount_type( 'fixed_cart' ); 330 | $coupon->set_individual_use( true ); 331 | $coupon->update_meta_data( '_apply_to_first_cycle_only', 'no' ); 332 | $coupon->update_meta_data( '_allow_subscriptions', 'no' ); 333 | } 334 | 335 | /** 336 | * Checks rules that Woo does not support. 337 | * 338 | * @param object $discount the shopify discount data. 339 | */ 340 | private function check_unsupported_rules( $discount ) { 341 | 342 | if ( new DateTime( $discount->startsAt ) > ( new DateTime( 'now' ) ) ) { 343 | WP_CLI::line( WP_CLI::colorize( '%RError:%n ' ) . 'Woo does not support coupons with a start date in the future.' ); 344 | } 345 | 346 | if ( isset( $discount->minimumRequirement->greaterThanOrEqualToQuantity ) && ! defined( 'WEBTOFFEE_SMARTCOUPON_VERSION' ) ) { 347 | WP_CLI::line( WP_CLI::colorize( '%YWarning:%n ' ) . 'Minimum product quantity not supported by Woo. Install the free WebTofee`s Smart Coupon For WooCommerce Coupon plugin' ); 348 | } 349 | 350 | if ( isset( $discount->customerSelection->segments ) ) { 351 | WP_CLI::line( WP_CLI::colorize( '%YWarning:%n ' ) . 'Coupon customer segmentation not supported by Woo.' ); 352 | } 353 | 354 | if ( true === $discount->combinesWith->orderDiscounts || true === $discount->combinesWith->productDiscounts || true === $discount->combinesWith->shippingDiscounts ) { 355 | WP_CLI::line( WP_CLI::colorize( '%YWarning:%n ' ) . 'The importer does not handle combining discounts at the moment.' ); 356 | } 357 | 358 | if ( 'SHIPPING' === $discount->discountClass ) { 359 | if ( ! isset( $discount->destinationSelection->allCountries ) || true !== $discount->destinationSelection->allCountries ) { 360 | WP_CLI::line( WP_CLI::colorize( '%YWarning:%n ' ) . 'Woo does not support free shipping coupons for specific countries only. This coupon will apply to all countries.' ); 361 | } 362 | 363 | if ( null !== $discount->maximumShippingPrice ) { 364 | WP_CLI::line( WP_CLI::colorize( '%YWarning:%n ' ) . 'Woo does not support limiting free shipping for a maximum shipping value.' ); 365 | } 366 | } 367 | } 368 | 369 | /** 370 | * Sets the coupon restrictions like minimum total, 371 | * Products that the coupon applies. 372 | * 373 | * @param WC_Coupon $coupon the Woo coupon. 374 | * @param object $discount the Shopify discount data. 375 | */ 376 | private function set_restrictions( $coupon, $discount ) { 377 | // Minimum total 378 | if ( isset( $discount->minimumRequirement->greaterThanOrEqualToSubtotal ) ) { 379 | $coupon->set_minimum_amount( $discount->minimumRequirement->greaterThanOrEqualToSubtotal->amount ); 380 | } 381 | 382 | // Products 383 | if ( isset( $discount->customerGets->items ) && true !== isset( $discount->customerGets->items->allItems ) ) { 384 | $meta_values = array(); 385 | 386 | if ( isset( $discount->customerGets->items->productVariants ) && count( $discount->customerGets->items->productVariants ) ) { 387 | WP_CLI::line( WP_CLI::colorize( '%RError:%n ' ) . 'Product variants not supported yet' ); 388 | } 389 | 390 | if ( isset( $discount->customerGets->items->collections ) ) { 391 | WP_CLI::line( WP_CLI::colorize( '%RError:%n ' ) . 'Product collections not supported yet' ); 392 | } 393 | 394 | if ( isset( $discount->customerGets->items->products ) ) { 395 | foreach ( $discount->customerGets->items->products->nodes as $shopify_product ) { 396 | $meta_values[] = $shopify_product->legacyResourceId; 397 | } 398 | 399 | add_filter( 'woocommerce_product_data_store_cpt_get_products_query', array( $this, 'handle_custom_query_var' ), 10, 2 ); 400 | 401 | $products = wc_get_products( 402 | array( 403 | 'limit' => -1, 404 | '_original_product_id' => $meta_values, 405 | ) 406 | ); 407 | 408 | $product_ids = array(); 409 | foreach ( $products as $product ) { 410 | $product_ids[] = $product->get_id(); 411 | } 412 | 413 | $coupon->set_product_ids( $product_ids ); 414 | } 415 | } 416 | 417 | // Rules Supported by WebTofee`s Smart Coupons for WooCommerce. 418 | if ( defined( 'WEBTOFFEE_SMARTCOUPON_VERSION' ) ) { 419 | // Minimum product quantity. 420 | if ( isset( $discount->minimumRequirement->greaterThanOrEqualToQuantity ) ) { 421 | $coupon->update_meta_data( '_wt_min_matching_product_qty', $discount->minimumRequirement->greaterThanOrEqualToQuantity ); 422 | } 423 | } 424 | } 425 | 426 | /** 427 | * Adds a customer query var to search for products with one of the shopify products ids. 428 | * 429 | * @param array $query the query. 430 | * @param array $query_vars the query vars. 431 | * @return array 432 | */ 433 | public function handle_custom_query_var( $query, $query_vars ) { 434 | if ( ! empty( $query_vars['_original_product_id'] ) ) { 435 | $query['meta_query'][] = array( 436 | 'key' => '_original_product_id', 437 | 'value' => $query_vars['_original_product_id'], 438 | 'compare' => 'IN', 439 | ); 440 | } 441 | 442 | return $query; 443 | } 444 | 445 | 446 | /** 447 | * Sets coupons limits like user emails and maximum number of cycles. 448 | * 449 | * @param WC_Coupon $coupon the Woo coupon. 450 | * @param object $discount the shopify discount data. 451 | */ 452 | private function set_limits( $coupon, $discount ) { 453 | // Limited to specific user emails 454 | if ( isset( $discount->customerSelection->customers ) ) { 455 | $emails = array(); 456 | foreach ( $discount->customerSelection->customers as $customer ) { 457 | $emails[] = $customer->email; 458 | } 459 | 460 | $coupon->set_email_restrictions( $emails ); 461 | } 462 | 463 | // Cycle limits, key from https://github.com/woocommerce/woocommerce-subscriptions/blob/b7e2a57ab730ab8a146b7a12e55efefa7f6fee1d/includes/class-wcs-limited-recurring-coupon-manager.php#L18 464 | $coupon->update_meta_data( '_wcs_number_payments', $discount->recurringCycleLimit ); 465 | } 466 | 467 | /** 468 | * Sets the discount type. Shopify discount types are not a perfect match 469 | * so they need to be loosely converted. 470 | * When a discount is set to be used in both subscriptions and one time purchases 471 | * converts them to subscriptions only. 472 | * 473 | * @param WC_Coupon $coupon the Woo coupon. 474 | * @param object $discount the Shopify discount data. 475 | */ 476 | private function set_discount_type( WC_Coupon $coupon, $discount ) { 477 | $is_subscription_and_one_time = false; 478 | 479 | // Coupons used in both subscriptions and one time purchases. 480 | if ( isset( $discount->customerGets ) && true === $discount->customerGets->appliesOnSubscription && true === $discount->customerGets->appliesOnOneTimePurchase ) { 481 | if ( class_exists( 'Mixed_Coupons' ) ) { 482 | $is_subscription_and_one_time = true; 483 | $coupon->update_meta_data( '_allow_subscriptions', 'yes' ); 484 | 485 | if ( $discount->recurringCycleLimit > 1 ) { 486 | WP_CLI::line( WP_CLI::colorize( '%RError:%n ' ) . 'Coupons need to be limited to one cycle only or be unlimited. Setting it up to unlimited' ); 487 | $coupon->update_meta_data( '_apply_to_first_cycle_only', 'no' ); 488 | } elseif ( 1 === $discount->recurringCycleLimit ) { 489 | $coupon->update_meta_data( '_apply_to_first_cycle_only', 'yes' ); 490 | } 491 | } else { 492 | /** 493 | * When both appliesOnOneTimePurchase and appliesOnSubscription 494 | * are true Shopify coupons can be applied to both one time purchases and subscriptions 495 | * but Woo only supports one or the other. 496 | * As coupons are used to renewal subscriptions we set them as subscription coupons 497 | * if appliesOnSubscription is set to true. 498 | * This will cause problems for people that try to use those coupons for one time 499 | * purchases after the migration. 500 | */ 501 | WP_CLI::line( WP_CLI::colorize( '%YWarning:%n ' ) . 'This coupon is set to be used both in one time purchases and subscriptions in Shopify but Woo does not support that. Setting it up as a subscriptions only coupon.' ); 502 | WP_CLI::line( WP_CLI::colorize( '%BInfo:%n ' ) . 'If you want to support both install: https://github.com/woocommerce/woo-mixed-coupons' ); 503 | } 504 | } 505 | 506 | // Percent discount. 507 | if ( isset( $discount->customerGets->value->percentage ) ) { 508 | $coupon->set_amount( $discount->customerGets->value->percentage * 100 ); 509 | $coupon->set_discount_type( 'percent' ); 510 | 511 | if ( ! $is_subscription_and_one_time && true === $discount->customerGets->appliesOnSubscription ) { 512 | $coupon->set_discount_type( 'recurring_percent' ); 513 | } 514 | } 515 | 516 | // Fixed amount. 517 | if ( isset( $discount->customerGets->value->amount ) ) { 518 | $coupon->set_amount( $discount->customerGets->value->amount->amount ); 519 | 520 | $coupon->set_discount_type( 'fixed_cart' ); 521 | 522 | if ( true === $discount->customerGets->value->appliesOnEachItem ) { 523 | $coupon->set_discount_type( 'fixed_product' ); 524 | } 525 | 526 | if ( ! $is_subscription_and_one_time && true === $discount->customerGets->appliesOnSubscription ) { 527 | $coupon->set_discount_type( 'recurring_fee' ); 528 | } 529 | } 530 | } 531 | } 532 | 533 | // phpcs:enable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase 534 | -------------------------------------------------------------------------------- /includes/class-migrator-cli-products.php: -------------------------------------------------------------------------------- 1 | fields = explode( ',', $assoc_args['fields'] ); 18 | WP_CLI::line( WP_CLI::colorize( '%BInfo:%n ' ) . sprintf( 'Only migrate/update selected fields: %s', implode( ', ', $this->fields ) ) ); 19 | } else { 20 | $this->fields = $this->get_product_fields(); 21 | } 22 | 23 | if ( isset( $assoc_args['exclude-fields'] ) ) { 24 | $exclude_fields = explode( ',', $assoc_args['exclude-fields'] ); 25 | $this->fields = array_diff( $this->fields, $exclude_fields ); 26 | WP_CLI::line( WP_CLI::colorize( '%BInfo:%n ' ) . sprintf( 'Excluding these fields: %s', implode( ', ', $exclude_fields ) ) ); 27 | } 28 | 29 | $before = isset( $assoc_args['before'] ) ? $assoc_args['before'] : null; 30 | $after = isset( $assoc_args['after'] ) ? $assoc_args['after'] : null; 31 | $limit = isset( $assoc_args['limit'] ) ? $assoc_args['limit'] : PHP_INT_MAX; 32 | $perpage = isset( $assoc_args['perpage'] ) ? $assoc_args['perpage'] : 50; 33 | $perpage = min( $perpage, $limit ); 34 | $next_link = isset( $assoc_args['next'] ) ? $assoc_args['next'] : ''; 35 | $status = isset( $assoc_args['status'] ) ? $assoc_args['status'] : 'active'; 36 | $ids = isset( $assoc_args['ids'] ) ? $assoc_args['ids'] : null; 37 | $exclude = isset( $assoc_args['exclude'] ) ? explode( ',', $assoc_args['exclude'] ) : array(); 38 | $handle = isset( $assoc_args['handle'] ) ? $assoc_args['handle'] : null; 39 | $product_type = isset( $assoc_args['product-type'] ) ? $assoc_args['product-type'] : 'all'; 40 | $no_update = isset( $assoc_args['no-update'] ) ? true : false; 41 | 42 | if ( $next_link ) { 43 | $response_data = Migrator_CLI_Utils::rest_request( $next_link ); 44 | } else { 45 | $response_data = Migrator_CLI_Utils::rest_request( 46 | 'products.json', 47 | array( 48 | 'limit' => $perpage, 49 | 'created_at_max' => $before, 50 | 'created_at_min' => $after, 51 | 'status' => $status, 52 | 'ids' => $ids, 53 | 'handle' => $handle, 54 | ) 55 | ); 56 | } 57 | 58 | if ( ! $response_data || empty( $response_data->data->products ) ) { 59 | WP_CLI::error( 'No Shopify products found.' ); 60 | } 61 | 62 | WP_CLI::line( sprintf( 'Found %d products in Shopify. Processing %d products.', count( $response_data->data->products ), min( $limit, $perpage, count( $response_data->data->products ) ) ) ); 63 | 64 | foreach ( $response_data->data->products as $shopify_product ) { 65 | 66 | if ( in_array( $shopify_product->id, $exclude, true ) || $this->preg_match_array( $shopify_product->variants[0]->sku, $exclude ) ) { 67 | WP_CLI::line( sprintf( 'Product %s is excluded. Skipping...', $shopify_product->handle ) ); 68 | continue; 69 | } 70 | 71 | WP_CLI::line( 'Fetching additional product data...' ); 72 | $this->fetch_additional_shopify_product_data( $shopify_product ); 73 | 74 | // Check if product is single or variable by checking how many 75 | // variants it has. 76 | $curent_product_type = 'single'; 77 | if ( $this->is_variable_product( $shopify_product ) ) { 78 | $curent_product_type = 'variable'; 79 | } 80 | 81 | if ( 'all' !== $product_type && $product_type !== $curent_product_type ) { 82 | WP_CLI::line( sprintf( 'Product %s is %s. Skipping...', $shopify_product->handle, $curent_product_type ) ); 83 | continue; 84 | } 85 | 86 | // Check if the product already exists in Woo by handle. 87 | $woo_product = $this->get_corresponding_woo_product( $shopify_product ); 88 | 89 | if ( $woo_product ) { 90 | WP_CLI::line( sprintf( 'Product %s already exists (%s). %s...', $shopify_product->handle, $woo_product->get_id(), $no_update ? 'Skipping' : 'Updating' ) ); 91 | 92 | if ( $no_update ) { 93 | continue; 94 | } 95 | } else { 96 | WP_CLI::line( sprintf( 'Product %s does not exist. Creating...', $shopify_product->handle ) ); 97 | } 98 | 99 | $this->create_or_update_woo_product( $shopify_product, $woo_product ); 100 | } 101 | 102 | WP_CLI::line( '===============================' ); 103 | 104 | $next_link = $response_data->next_link; 105 | if ( $next_link && $limit > $perpage ) { 106 | Migrator_CLI_Utils::reset_in_memory_cache(); 107 | WP_CLI::line( WP_CLI::colorize( '%BInfo:%n ' ) . 'There are more products to process.' ); 108 | $this->migrate_products( 109 | array( 110 | 'next' => $next_link, 111 | 'limit' => $limit - $perpage, 112 | 'exclude' => implode( ',', $exclude ), 113 | ) 114 | ); 115 | } else { 116 | WP_CLI::success( 'All products have been processed.' ); 117 | } 118 | } 119 | 120 | private function get_product_fields() { 121 | return array( 122 | 'title', 123 | 'slug', 124 | 'description', 125 | 'status', 126 | 'date_created', 127 | 'catalog_visibility', 128 | 'category', 129 | 'tag', 130 | 'price', 131 | 'sku', 132 | 'stock', 133 | 'weight', 134 | 'brand', 135 | 'images', 136 | 'seo', 137 | 'attributes', 138 | ); 139 | } 140 | 141 | /** 142 | * Supports matching against an array of regular expressions, and will do a glob match so things like CANAL_* will match every product that starts with CANAL_. 143 | * 144 | * @param string $subject Product SKU. 145 | * @param array $patterns Array of patterns to match against. 146 | * @return bool 147 | */ 148 | private function preg_match_array( $subject, $patterns ) { 149 | if ( ! $subject ) { 150 | return false; 151 | } 152 | foreach ( $patterns as $pattern ) { 153 | if ( strpos( $pattern, '*' ) !== false ) { 154 | $pattern = str_replace( '*', '.*', $pattern ); 155 | } 156 | if ( preg_match( "/^$pattern$/i", $subject ) ) { 157 | return true; 158 | } 159 | } 160 | return false; 161 | } 162 | 163 | /** 164 | * Fetches additional product data from Shopify and saves it to $this->additional_product_data. 165 | * 166 | * @param object $shopify_product the Shopify product data. 167 | */ 168 | private function fetch_additional_shopify_product_data( $shopify_product ) { 169 | $response = Migrator_CLI_Utils::graphql_request( 170 | array( 171 | 'query' => 'query { 172 | product(id: "gid://shopify/Product/' . $shopify_product->id . '") { 173 | id 174 | handle 175 | onlineStoreUrl 176 | collections(first: 100) { 177 | edges { 178 | node { 179 | id 180 | title 181 | handle 182 | } 183 | } 184 | } 185 | metafields(first: 100) { 186 | edges { 187 | node { 188 | key 189 | namespace 190 | value 191 | } 192 | } 193 | } 194 | } 195 | }', 196 | ) 197 | ); 198 | 199 | $response_data = json_decode( wp_remote_retrieve_body( $response ) ); 200 | 201 | $this->additional_product_data = $response_data->data->product; 202 | sleep( 1 ); // Pause the execution for 1 second to avoid rate limit. 203 | } 204 | 205 | /** 206 | * Checks if the product contains variants. 207 | * 208 | * @param object $shopify_product the Shopify product data. 209 | * @return bool 210 | */ 211 | private function is_variable_product( $shopify_product ) { 212 | return count( $shopify_product->variants ) > 1; 213 | } 214 | 215 | /** 216 | * Gets the Woo product that matches the Shopify product id. 217 | * 218 | * @param object $shopify_product the Shopify product data. 219 | * @return WC_Product|null 220 | */ 221 | private function get_corresponding_woo_product( $shopify_product ) { 222 | // Try finding the product by original Shopify product ID. 223 | $woo_products = wc_get_products( 224 | array( 225 | 'limit' => 1, 226 | 'meta_key' => '_original_product_id', 227 | 'meta_value' => $shopify_product->id, 228 | ) 229 | ); 230 | 231 | if ( count( $woo_products ) === 1 && is_a( $woo_products[0], 'WC_Product' ) ) { 232 | return wc_get_product( $woo_products[0] ); 233 | } 234 | } 235 | 236 | /** 237 | * Creates or updates the Woo product. 238 | * 239 | * @param object $shopify_product the Shopify product data. 240 | * @param WC_Product $woo_product the Woo product. 241 | */ 242 | private function create_or_update_woo_product( $shopify_product, $woo_product = null ) { 243 | $this->migration_data = array( 244 | 'product_id' => $shopify_product->id, 245 | 'original_url' => '', 246 | 'images_mapping' => array(), 247 | 'metafields' => array(), 248 | 'variations_mapping' => array(), 249 | ); 250 | 251 | if ( $woo_product ) { 252 | $saved_migration_data = $woo_product->get_meta( '_migration_data' ); 253 | if ( $saved_migration_data ) { 254 | $this->migration_data = array_merge( $this->migration_data, $saved_migration_data ); 255 | } 256 | } 257 | 258 | if ( $this->is_variable_product( $shopify_product ) ) { 259 | $product = new WC_Product_Variable( $woo_product ); 260 | } else { 261 | $product = new WC_Product_Simple( $woo_product ); 262 | } 263 | 264 | if ( $this->should_process( 'title' ) ) { 265 | $product->set_name( $shopify_product->title ); 266 | } 267 | 268 | if ( $this->should_process( 'slug' ) ) { 269 | $product->set_slug( $shopify_product->handle ); 270 | } 271 | 272 | if ( $this->should_process( 'description' ) ) { 273 | $product->set_description( $this->sanitize_product_description( $shopify_product->body_html ) ); 274 | } 275 | 276 | if ( $this->should_process( 'status' ) ) { 277 | $product->set_status( $this->get_woo_product_status( $shopify_product ) ); 278 | } 279 | 280 | if ( $this->should_process( 'date_created' ) ) { 281 | $product->set_date_created( $shopify_product->created_at ); 282 | } 283 | 284 | if ( $this->should_process( 'catalog_visibility' ) ) { 285 | if ( $this->additional_product_data && property_exists( $this->additional_product_data, 'onlineStoreUrl' ) ) { 286 | if ( null === $this->additional_product_data->onlineStoreUrl ) { 287 | $product->set_catalog_visibility( 'hidden' ); 288 | } else { 289 | $this->migration_data['original_url'] = $this->additional_product_data->onlineStoreUrl; 290 | } 291 | } 292 | } 293 | 294 | if ( $this->should_process( 'category' ) ) { 295 | $product->set_category_ids( $this->get_woo_product_category_ids() ); 296 | } 297 | 298 | if ( $this->should_process( 'tag' ) ) { 299 | $product->set_tag_ids( $this->get_woo_product_tag_ids( $shopify_product ) ); 300 | } 301 | 302 | // Simple product. 303 | if ( ! $this->is_variable_product( $shopify_product ) ) { 304 | if ( $this->should_process( 'price' ) ) { 305 | if ( $shopify_product->variants[0]->compare_at_price && $shopify_product->variants[0]->compare_at_price > $shopify_product->variants[0]->price ) { 306 | $product->set_sale_price( $shopify_product->variants[0]->price ); 307 | $product->set_regular_price( $shopify_product->variants[0]->compare_at_price ); 308 | } else { 309 | $product->set_sale_price( '' ); 310 | $product->set_regular_price( $shopify_product->variants[0]->price ); 311 | } 312 | } 313 | if ( $this->should_process( 'sku' ) ) { 314 | // Prevents errors when there are products with duplicated SKUs. 315 | add_filter( 'wc_product_has_unique_sku', '__return_false', 10, 3 ); 316 | $product->set_sku( $shopify_product->variants[0]->sku ); 317 | remove_filter( 'wc_product_has_unique_sku', '__return_false' ); 318 | } 319 | if ( $this->should_process( 'stock' ) ) { 320 | $product->set_manage_stock( 'shopify' === $shopify_product->variants[0]->inventory_management ); 321 | $product->set_stock_status( 'deny' === $shopify_product->variants[0]->inventory_quantity ? 'outofstock' : 'instock' ); 322 | $product->set_stock_quantity( $shopify_product->variants[0]->inventory_quantity ); 323 | } 324 | if ( $this->should_process( 'weight' ) ) { 325 | $product->set_weight( $this->get_converted_weight( $shopify_product->variants[0]->weight, $shopify_product->variants[0]->weight_unit ) ); 326 | } 327 | $product->update_meta_data( '_original_variant_id', $shopify_product->variants[0]->id ); 328 | } else { 329 | $product->set_sku( '' ); 330 | } 331 | 332 | // The operations below require product id, so we need to save the 333 | // product first. 334 | $product->save(); 335 | 336 | // Product brand 337 | if ( $this->should_process( 'brand' ) ) { 338 | $this->set_woo_product_brand( $shopify_product, $product ); 339 | } 340 | 341 | // Process images. 342 | if ( $this->should_process( 'images' ) ) { 343 | $this->upload_images( $shopify_product, $product ); 344 | $product->set_image_id( $this->get_woo_product_image_id( $shopify_product ) ); 345 | $product->set_gallery_image_ids( $this->get_woo_product_gallery_image_ids( $shopify_product ) ); 346 | } 347 | 348 | $product->save(); 349 | 350 | // Variations. 351 | if ( $this->is_variable_product( $shopify_product ) ) { 352 | $this->create_or_update_woo_product_variations( $shopify_product, $product ); 353 | } 354 | 355 | if ( $this->should_process( 'seo' ) ) { 356 | $this->update_seo_title_description( $shopify_product, $product ); 357 | } 358 | 359 | // Migration metas. 360 | foreach ( $this->additional_product_data->metafields->edges as $field ) { 361 | $key = sprintf( '%s_%s', $field->node->namespace, $field->node->key ); 362 | $this->migration_data['metafields'][ $key ] = $field->node->value; 363 | } 364 | 365 | $product->update_meta_data( '_migration_data', $this->migration_data ); 366 | $product->update_meta_data( '_original_product_id', $shopify_product->id ); // For searching later 367 | 368 | $product->save(); 369 | WP_CLI::line( 'Woo Product ID: ' . $product->get_id() ); 370 | } 371 | 372 | /** 373 | * Checks if the field is contained in the $this->fields array. 374 | * 375 | * @param string $field the field to be checked. 376 | * @return bool 377 | */ 378 | private function should_process( $field ) { 379 | return in_array( $field, $this->fields, true ); 380 | } 381 | 382 | /** 383 | * Sanitizes the product description html. 384 | * 385 | * @param string $html the product description html. 386 | * @return string sanitized description. 387 | */ 388 | private function sanitize_product_description( $html ) { 389 | $html = mb_convert_encoding( $html, 'HTML-ENTITIES', 'UTF-8' ); 390 | 391 | if ( ! $html ) { 392 | return ''; 393 | } 394 | 395 | $html = preg_replace( '~~Usi', '', $html ); 396 | $html = preg_replace( '~~Usi', '', $html ); 397 | $html = wp_kses_post( $html ); 398 | $html = trim( $html ); 399 | 400 | return $html; 401 | } 402 | 403 | /** 404 | * Converts the Shopify product status into Woo product status. 405 | * 406 | * @param object $shopify_product the Shopify product data. 407 | * @return string the Woo product status. 408 | */ 409 | private function get_woo_product_status( $shopify_product ) { 410 | $woo_product_status = 'draft'; 411 | 412 | if ( 'active' === $shopify_product->status ) { 413 | $woo_product_status = 'publish'; 414 | } 415 | 416 | return $woo_product_status; 417 | } 418 | 419 | /** 420 | * Gets the Woo product category ids that match the collection handle in 421 | * $this->additional_product_data->collections->edges[collection]->node->handle 422 | * 423 | * @return array 424 | */ 425 | private function get_woo_product_category_ids() { 426 | $category_ids = array(); 427 | $collections = $this->additional_product_data->collections->edges; 428 | 429 | foreach ( $collections as $collection ) { 430 | // Check if the category exists in WooCommerce. 431 | $woo_product_category = get_term_by( 'slug', $collection->node->handle, 'product_cat', ARRAY_A ); 432 | 433 | // If the category doesn't exist, create it. 434 | if ( ! $woo_product_category ) { 435 | $woo_product_category = wp_insert_term( 436 | $collection->node->title, 437 | 'product_cat', 438 | array( 439 | 'slug' => $collection->node->handle, 440 | ) 441 | ); 442 | } 443 | 444 | $category_ids[] = $woo_product_category['term_id']; 445 | } 446 | 447 | if ( empty( $category_ids ) ) { 448 | $category_ids[] = get_option( 'default_product_cat' ); 449 | } 450 | 451 | return $category_ids; 452 | } 453 | 454 | /** 455 | * Gets the Woo product tags ids that match the Shopify product tags. 456 | * 457 | * @param object $shopify_product the Shopify product data. 458 | * @return array 459 | */ 460 | private function get_woo_product_tag_ids( $shopify_product ) { 461 | $tag_ids = array(); 462 | 463 | $tags = $shopify_product->tags; 464 | 465 | if ( ! $tags ) { 466 | return $tag_ids; 467 | } 468 | 469 | $tags = explode( ',', $tags ); 470 | $tags = array_map( 'trim', $tags ); 471 | 472 | foreach ( $tags as $tag ) { 473 | // Check if the tag exists in WooCommerce. 474 | $woo_product_tag = get_term_by( 'slug', $tag, 'product_tag', ARRAY_A ); 475 | 476 | // If the tag doesn't exist, create it. 477 | if ( ! $woo_product_tag ) { 478 | $woo_product_tag = wp_insert_term( 479 | $tag, 480 | 'product_tag', 481 | array( 482 | 'slug' => $tag, 483 | ) 484 | ); 485 | } 486 | 487 | $tag_ids[] = $woo_product_tag['term_id']; 488 | } 489 | 490 | return $tag_ids; 491 | } 492 | 493 | /** 494 | * Returns a conversion table for a given weight. 495 | * 496 | * @param float $weight the old weight. 497 | * @param string $weight_unit the original unit. 498 | * @return float 499 | */ 500 | private function get_converted_weight( $weight, $weight_unit ) { 501 | $store_weight_unit = get_option( 'woocommerce_weight_unit' ); 502 | if ( 'lbs' === $store_weight_unit ) { 503 | $store_weight_unit = 'lb'; 504 | } 505 | 506 | $conversion = array( 507 | 'kg' => array( 508 | 'kg' => 1, 509 | 'g' => 1000, 510 | 'lb' => 2.20462, 511 | 'oz' => 35.274, 512 | ), 513 | 'g' => array( 514 | 'kg' => 0.001, 515 | 'g' => 1, 516 | 'lb' => 0.00220462, 517 | 'oz' => 0.035274, 518 | ), 519 | 'lb' => array( 520 | 'kg' => 0.453592, 521 | 'g' => 453.592, 522 | 'lb' => 1, 523 | 'oz' => 16, 524 | ), 525 | 'oz' => array( 526 | 'kg' => 0.0283495, 527 | 'g' => 28.3495, 528 | 'lb' => 0.0625, 529 | 'oz' => 1, 530 | ), 531 | ); 532 | 533 | return $weight * $conversion[ $weight_unit ][ $store_weight_unit ]; 534 | } 535 | 536 | /** 537 | * Sets the Woo product brand. 538 | * 539 | * @param object $shopify_product the Shopify product data. 540 | * @param WC_Product $product the Woo product. 541 | */ 542 | private function set_woo_product_brand( $shopify_product, $product ) { 543 | if ( ! taxonomy_exists( 'product_brand' ) ) { 544 | return; 545 | } 546 | 547 | $brand = $shopify_product->vendor; 548 | 549 | if ( ! $brand ) { 550 | return; 551 | } 552 | 553 | // Check if the brand exists in WooCommerce. 554 | $woo_product_brand = get_term_by( 'name', $brand, 'product_brand', ARRAY_A ); 555 | 556 | // If the brand doesn't exist, create it. 557 | if ( ! $woo_product_brand ) { 558 | $woo_product_brand = wp_insert_term( 559 | $brand, 560 | 'product_brand' 561 | ); 562 | } 563 | 564 | // Assign the brand to the product. 565 | wp_set_object_terms( $product->get_id(), $woo_product_brand['term_id'], 'product_brand' ); 566 | } 567 | 568 | /** 569 | * Saves product images. 570 | * 571 | * @param object $shopify_product the Shopify product data. 572 | * @param WC_Product $product the Woo product. 573 | */ 574 | private function upload_images( $shopify_product, $product ) { 575 | foreach ( $shopify_product->images as $image ) { 576 | // Check if the image has already been uploaded. 577 | if ( isset( $this->migration_data['images_mapping'][ $image->id ] ) && wp_attachment_is_image( $this->migration_data['images_mapping'][ $image->id ] ) ) { 578 | continue; 579 | } 580 | 581 | // Upload the image to the media library. 582 | $image_id = media_sideload_image( $image->src, $product->get_id(), '', 'id' ); 583 | 584 | if ( is_wp_error( $image_id ) ) { 585 | WP_CLI::line( sprintf( 'Error uploading %s: %s', $image->src, $image_id->get_error_message() ) ); 586 | } 587 | 588 | // Save the mapping. 589 | $this->migration_data['images_mapping'][ $image->id ] = $image_id; 590 | } 591 | 592 | $this->migration_data['images_mapping'] = $this->migration_data['images_mapping']; 593 | } 594 | 595 | /** 596 | * Gets the Woo product image id that matches the first Shopify product image id. 597 | * 598 | * @param object $shopify_product the Shopify product data. 599 | * @return int 600 | */ 601 | private function get_woo_product_image_id( $shopify_product ) { 602 | if ( empty( $shopify_product->images ) ) { 603 | return 0; 604 | } 605 | 606 | // Get the image ID from mapping. 607 | return $this->migration_data['images_mapping'][ $shopify_product->images[0]->id ]; 608 | } 609 | 610 | /** 611 | * Gets the Woo product gallery image ids 612 | * 613 | * @param object $shopify_product the Shopify product data. 614 | * @return array 615 | */ 616 | private function get_woo_product_gallery_image_ids( $shopify_product ) { 617 | if ( count( $this->migration_data['images_mapping'] ) < 2 ) { 618 | return array(); 619 | } 620 | 621 | return array_diff( array_values( $this->migration_data['images_mapping'] ), array( $this->get_woo_product_image_id( $shopify_product, $this->migration_data['images_mapping'] ) ) ); 622 | } 623 | 624 | /** 625 | * Creates or updates Woo product variations. 626 | * 627 | * @param object $shopify_product the Shopify product data. 628 | * @param WC_Product $product the Woo product. 629 | */ 630 | private function create_or_update_woo_product_variations( $shopify_product, $product ) { 631 | $attribute_taxonomy_mapping = array(); 632 | 633 | if ( $this->should_process( 'attributes' ) ) { 634 | // Create attribute taxonomies if needed. 635 | foreach ( $shopify_product->options as $option ) { 636 | // Check if the attribute taxonomy exists in WooCommerce. 637 | if ( ! taxonomy_exists( 'pa_' . sanitize_title( $option->name ) ) ) { 638 | wc_create_attribute( 639 | array( 640 | 'name' => $option->name, 641 | 'slug' => sanitize_title( $option->name ), 642 | 'type' => 'select', 643 | 'order_by' => 'menu_order', 644 | ) 645 | ); 646 | } 647 | 648 | $attribute_taxonomy_mapping[ 'option' . $option->position ] = 'pa_' . sanitize_title( $option->name ); 649 | } 650 | 651 | $attributes_data = array(); 652 | 653 | // Force update the taxonomies registration. 654 | unregister_taxonomy( 'product_type' ); 655 | WC_Post_Types::register_taxonomies(); 656 | 657 | foreach ( $attribute_taxonomy_mapping as $attribute_taxonomy ) { 658 | $attributes_data[ $attribute_taxonomy ] = array(); 659 | } 660 | 661 | // Get available value from variants option 662 | foreach ( $shopify_product->variants as $variant ) { 663 | foreach ( $attribute_taxonomy_mapping as $option_key => $attribute_taxonomy ) { 664 | // Create the attribute term if it doesn't exist. 665 | $slug = sanitize_title( $variant->$option_key ); 666 | $term = get_term_by( 'slug', $slug, $attribute_taxonomy, ARRAY_A ); 667 | if ( ! $term ) { 668 | $term = wp_insert_term( 669 | $variant->$option_key, 670 | $attribute_taxonomy, 671 | array( 672 | 'slug' => $slug, 673 | ) 674 | ); 675 | } 676 | $attributes_data[ $attribute_taxonomy ][] = $term['term_id']; 677 | } 678 | } 679 | 680 | $attributes = array_map( 681 | function ( $taxonomy, $value ) { 682 | $attribute = new WC_Product_Attribute(); 683 | $attribute->set_name( $taxonomy ); 684 | $attribute->set_id( wc_attribute_taxonomy_id_by_name( $taxonomy ) ); 685 | $attribute->set_options( $value ); 686 | $attribute->set_visible( true ); 687 | $attribute->set_variation( true ); 688 | return $attribute; 689 | }, 690 | array_keys( $attributes_data ), 691 | array_values( $attributes_data ) 692 | ); 693 | 694 | $product->set_attributes( $attributes ); 695 | $product->save(); 696 | } 697 | 698 | foreach ( $shopify_product->variants as $variant ) { 699 | WP_CLI::line( 'Processing variant ' . $variant->id ); 700 | $variation = new WC_Product_Variation(); 701 | 702 | // Check if the variant has been handled by our migrator before. 703 | if ( in_array( $variant->id, array_keys( $this->migration_data['variations_mapping'] ), true ) ) { 704 | $_variation = wc_get_product( $this->migration_data['variations_mapping'][ $variant->id ] ); 705 | if ( is_a( $_variation, 'WC_Product_Variation' ) ) { 706 | $variation = $_variation; 707 | WP_CLI::line( 'Found existing variation (ID: ' . $variation->get_id() . '). Updating.' ); 708 | } 709 | } else { 710 | $check_product = wc_get_products( 711 | array( 712 | 'limit' => 1, 713 | 'meta_key' => '_original_variant_id', 714 | 'meta_value' => $variant->id, 715 | ) 716 | ); 717 | 718 | if ( is_a( $check_product, 'WC_Product_Variation' ) ) { 719 | // The product is already a variation. 720 | $variation = new WC_Product_Variation( $check_product ); 721 | WP_CLI::line( 'Found existing variation (id: ' . $variation->get_id() . '). Updating.' ); 722 | } elseif ( is_a( $check_product, 'WC_Product' ) ) { 723 | WP_CLI::error( 'A product variation id was set to a Simple Product. This should not happen. Variation id: ' . $variant->id ); 724 | } 725 | } 726 | 727 | $variation->set_parent_id( $product->get_id() ); 728 | $variation->set_menu_order( $variant->position ); 729 | $variation->set_status( 'publish' ); 730 | 731 | if ( $this->should_process( 'stock' ) ) { 732 | $variation->set_manage_stock( 'shopify' === $variant->inventory_management ); 733 | $variation->set_stock_quantity( $variant->inventory_quantity ); 734 | $variation->set_stock_status( 'deny' === $variant->inventory_quantity ? 'outofstock' : 'instock' ); 735 | } 736 | 737 | if ( $this->should_process( 'weight' ) ) { 738 | $variation->set_weight( $this->get_converted_weight( $variant->weight, $variant->weight_unit ) ); 739 | } 740 | 741 | if ( $this->should_process( 'images' ) ) { 742 | if ( $variant->image_id ) { 743 | $variation->set_image_id( $this->migration_data['images_mapping'][ $variant->image_id ] ); 744 | } 745 | } 746 | 747 | if ( $this->should_process( 'price' ) ) { 748 | if ( $variant->compare_at_price && $variant->compare_at_price > $variant->price ) { 749 | $variation->set_regular_price( $variant->compare_at_price ); 750 | $variation->set_sale_price( $variant->price ); 751 | } else { 752 | $variation->set_regular_price( $variant->price ); 753 | $variation->set_sale_price( '' ); 754 | } 755 | } 756 | 757 | if ( $this->should_process( 'sku' ) ) { 758 | if ( $variant->sku ) { 759 | // Prevents errors when there are products with duplicated SKUs. 760 | add_filter( 'wc_product_has_unique_sku', '__return_false', 10, 3 ); 761 | $variation->set_sku( $variant->sku ); 762 | remove_filter( 'wc_product_has_unique_sku', '__return_false' ); 763 | } 764 | } 765 | 766 | if ( $this->should_process( 'attributes' ) ) { 767 | $variation_attributes = array(); 768 | foreach ( $attribute_taxonomy_mapping as $option_key => $attribute_taxonomy ) { 769 | $attribute = get_term_by( 'name', $variant->$option_key, $attribute_taxonomy ); 770 | $variation_attributes[ $attribute_taxonomy ] = $attribute->slug; 771 | } 772 | 773 | $variation->set_attributes( $variation_attributes ); 774 | } 775 | 776 | // Save the variant ID to the variation meta data. 777 | $variation->update_meta_data( '_original_variant_id', $variant->id ); 778 | $variation->update_meta_data( '_original_product_id', $variant->product_id ); 779 | 780 | $variation->save(); 781 | 782 | $this->migration_data['variations_mapping'][ $variant->id ] = $variation->get_id(); 783 | } 784 | 785 | $this->clean_up_orphan_variations( $product ); 786 | } 787 | 788 | /** 789 | * Updates the SEO tittle description for a product. 790 | * 791 | * @param object $shopify_product the Shopify product data. 792 | * @param WC_Product $product the Woo product. 793 | */ 794 | private function update_seo_title_description( $shopify_product, WC_Product $product ) { 795 | $current_seo_title = $product->get_meta( '_yoast_wpseo_title' ); 796 | $current_seo_description = $product->get_meta( '_yoast_wpseo_metadesc' ); 797 | 798 | $title = $product->get_title(); 799 | $description = $product->get_short_description() ? $product->get_short_description() : get_the_excerpt( $product->get_id() ); 800 | 801 | foreach ( $this->additional_product_data->metafields->edges as $field ) { 802 | if ( 'global' === $field->node->namespace && 'title_tag' === $field->node->key ) { 803 | $title = $field->node->value; 804 | } 805 | 806 | if ( 'global' === $field->node->namespace && 'description_tag' === $field->node->key ) { 807 | $description = $field->node->value; 808 | } 809 | } 810 | 811 | if ( $current_seo_title !== $title ) { 812 | $product->update_meta_data( '_yoast_wpseo_title', $title ); 813 | } 814 | 815 | if ( $current_seo_description !== $description ) { 816 | $product->update_meta_data( '_yoast_wpseo_metadesc', $description ); 817 | } 818 | 819 | $product->save(); 820 | } 821 | 822 | /** 823 | * Removes variations that were not added during this run. 824 | * 825 | * @param WC_Product $product the Woo product. 826 | */ 827 | private function clean_up_orphan_variations( $product ) { 828 | if ( ! isset( $this->assoc_args['remove-orphans'] ) ) { 829 | return; 830 | } 831 | 832 | $variations = $product->get_children(); 833 | 834 | foreach ( $variations as $variation_id ) { 835 | if ( ! in_array( $variation_id, array_values( $this->migration_data['variations_mapping'] ), true ) ) { 836 | WP_CLI::line( 'Deleting orphan variation (ID: ' . $variation_id . ')' ); 837 | wp_delete_post( $variation_id, true ); 838 | } 839 | } 840 | } 841 | } 842 | -------------------------------------------------------------------------------- /includes/class-migrator-cli-orders.php: -------------------------------------------------------------------------------- 1 | assoc_args = $assoc_args; 19 | 20 | Migrator_CLI_Utils::health_check(); 21 | Migrator_CLI_Utils::disable_sequential_orders(); 22 | 23 | $before = isset( $assoc_args['before'] ) ? $assoc_args['before'] : null; 24 | $after = isset( $assoc_args['after'] ) ? $assoc_args['after'] : null; 25 | $limit = isset( $assoc_args['limit'] ) ? $assoc_args['limit'] : PHP_INT_MAX; 26 | $perpage = isset( $assoc_args['perpage'] ) ? $assoc_args['perpage'] : 250; 27 | $perpage = min( $perpage, $limit ); 28 | $next_link = isset( $assoc_args['next'] ) ? $assoc_args['next'] : ''; 29 | $status = isset( $assoc_args['status'] ) ? $assoc_args['status'] : 'any'; 30 | $ids = isset( $assoc_args['ids'] ) ? $assoc_args['ids'] : null; 31 | $exclude = isset( $assoc_args['exclude'] ) ? explode( ',', $assoc_args['exclude'] ) : array(); 32 | $no_update = isset( $assoc_args['no-update'] ) ? true : false; 33 | $sorting = isset( $assoc_args['sorting'] ) ? $assoc_args['sorting'] : 'id asc'; 34 | $mode = isset( $assoc_args['mode'] ) ? $assoc_args['mode'] : 'test'; 35 | $send_notifications = isset( $assoc_args['send-notifications'] ) ? true : false; 36 | 37 | do { 38 | if ( $next_link ) { 39 | $response_data = Migrator_CLI_Utils::rest_request( $next_link ); 40 | } else { 41 | $response_data = Migrator_CLI_Utils::rest_request( 42 | 'orders.json', 43 | array( 44 | 'limit' => $perpage, 45 | 'created_at_max' => $before, 46 | 'created_at_min' => $after, 47 | 'status' => $status, 48 | 'ids' => $ids, 49 | 'order' => $sorting, 50 | ) 51 | ); 52 | } 53 | 54 | if ( ! $response_data || empty( $response_data->data->orders ) ) { 55 | WP_CLI::error( 'No Shopify orders found.' ); 56 | } 57 | 58 | // Disable WP emails before migration starts, unless --send-notifications flag is added. 59 | if ( ! $send_notifications ) { 60 | add_filter( 'pre_wp_mail', '__return_false', PHP_INT_MAX ); 61 | } else { 62 | WP_CLI::confirm( 'Are you sure you want to send out email notifications to users? This could potentially spam your users.' ); 63 | } 64 | 65 | WP_CLI::line( sprintf( 'Found %d orders in Shopify. Processing %d orders.', count( $response_data->data->orders ), min( $limit, $perpage, count( $response_data->data->orders ) ) ) ); 66 | 67 | foreach ( $response_data->data->orders as $shopify_order ) { 68 | 69 | if ( in_array( $shopify_order->id, $exclude, true ) ) { 70 | WP_CLI::line( sprintf( 'Order %s is excluded. Skipping...', $shopify_order->order_number ) ); 71 | continue; 72 | } 73 | 74 | // Mask phone number in test mode. 75 | if ( 'test' === $mode ) { 76 | if ( isset( $shopify_order->shipping_address->phone ) ) { 77 | $shopify_order->shipping_address->phone = '9999999999'; 78 | } 79 | 80 | if ( isset( $shopify_order->billing_address->phone ) ) { 81 | $shopify_order->billing_address->phone = '9999999999'; 82 | } 83 | } 84 | 85 | // Check if the order exists in WooCommerce. 86 | $woo_order = $this->get_corresponding_woo_order( $shopify_order ); 87 | 88 | WP_CLI::line( '' ); 89 | 90 | if ( $woo_order ) { 91 | WP_CLI::line( sprintf( 'Order %s already exists (%s). %s...', $shopify_order->id, $woo_order->get_id(), $no_update ? 'Skipping' : 'Updating' ) ); 92 | 93 | if ( $no_update ) { 94 | continue; 95 | } 96 | } else { 97 | WP_CLI::line( sprintf( 'Order %s does not exist. Creating...', $shopify_order->id ) ); 98 | } 99 | 100 | $this->create_or_update_woo_order( $shopify_order, $woo_order, $mode ); 101 | } 102 | 103 | WP_CLI::line( '===============================' ); 104 | 105 | 106 | $limit -= $perpage; 107 | 108 | $next_link = $response_data->next_link; 109 | if ( $next_link && $limit > 0 ) { 110 | Migrator_CLI_Utils::reset_in_memory_cache(); 111 | WP_CLI::line( WP_CLI::colorize( '%BInfo:%n ' ) . 'There are more orders to process.' ); 112 | WP_CLI::line( 'Next: ' . $next_link ); 113 | sleep( 1 ); 114 | } 115 | } while ( ( $next_link && $limit > 0 ) ); 116 | 117 | WP_CLI::success( 'All orders have been processed.' ); 118 | 119 | // Enable WP emails after order migration completes. 120 | if ( ! $send_notifications ) { 121 | remove_filter( 'pre_wp_mail', '__return_false', PHP_INT_MAX ); 122 | } 123 | 124 | Migrator_CLI_Utils::enable_sequential_orders(); 125 | } 126 | 127 | /** 128 | * Gets the corresponding Woo order using the Shopify order id. 129 | * 130 | * @param object $shopify_order the Shopify order data. 131 | * @return WC_Order|null 132 | */ 133 | private function get_corresponding_woo_order( $shopify_order ) { 134 | 135 | // Prevents duplicated Faire orders. 136 | if ( 'faire' === $shopify_order->source_name && $shopify_order->source_url ) { 137 | $faire_order_id = $this->get_faire_order_id( $shopify_order ); 138 | 139 | $orders = wc_get_orders( 140 | array( 141 | 'meta_key' => '_faire_order_id', 142 | 'meta_value' => $faire_order_id, 143 | ) 144 | ); 145 | 146 | if ( ! empty( $orders ) ) { 147 | return $orders[0]; 148 | } 149 | } 150 | 151 | $orders = wc_get_orders( 152 | array( 153 | 'meta_key' => '_original_order_id', 154 | 'meta_value' => $shopify_order->id, 155 | ) 156 | ); 157 | 158 | if ( ! empty( $orders ) ) { 159 | return $orders[0]; 160 | } 161 | 162 | // Find the order by Shopify order number. 163 | $orders = wc_get_orders( 164 | array( 165 | 'meta_key' => '_order_number', 166 | 'meta_value' => $shopify_order->order_number, 167 | ) 168 | ); 169 | 170 | if ( ! empty( $orders ) ) { 171 | return $orders[0]; 172 | } 173 | 174 | return null; 175 | } 176 | 177 | /** 178 | * Creates or updates a Woo order with the given Shopify data. 179 | * 180 | * @param object $shopify_order the Shopify order data. 181 | * @param WC_Order|null $woo_order the existing Woo Order to be updated or null to create a new one. 182 | * @param string $mode test or prod. When running in test mode emails and phone numbers will be masked. 183 | */ 184 | private function create_or_update_woo_order( $shopify_order, $woo_order, $mode = 'test' ) { 185 | $order = new WC_Order( $woo_order ); 186 | $order->save(); 187 | 188 | if ( ! $woo_order ) { 189 | WP_CLI::line( 'Order created: ' . $order->get_id() ); 190 | } 191 | 192 | $this->order_items_mapping = $order->get_meta( '_order_items_mapping', true ) ? $order->get_meta( '_order_items_mapping', true ) : array(); 193 | 194 | // Store the original order id for future reference. 195 | $order->update_meta_data( '_original_order_id', $shopify_order->id ); 196 | $order->update_meta_data( '_order_number', $shopify_order->order_number ); 197 | 198 | // Prevent Points and Rewards add order notes. 199 | $order->update_meta_data( '_wc_points_earned', true ); 200 | 201 | // Prevents duplicated Faire orders. 202 | if ( 'faire' === $shopify_order->source_name && $shopify_order->source_url ) { 203 | $faire_order_id = $this->get_faire_order_id( $shopify_order ); 204 | $order->update_meta_data( '_faire_order_id', $faire_order_id ); 205 | } 206 | 207 | if ( $shopify_order->source_name ) { 208 | $order->update_meta_data( '_original_source_name', $shopify_order->source_name ); 209 | } 210 | 211 | if ( $shopify_order->source_url ) { 212 | $order->update_meta_data( '_original_source_url', $shopify_order->source_url ); 213 | } 214 | 215 | // Update order status. 216 | $order->update_status( $this->get_woo_order_status( $shopify_order->financial_status, $shopify_order->fulfillment_status ) ); 217 | $order->set_order_stock_reduced( true ); 218 | 219 | // Update order dates. 220 | $order->set_date_created( $shopify_order->created_at ); 221 | $order->set_date_modified( $shopify_order->updated_at ); 222 | $order->set_date_paid( $shopify_order->processed_at ); 223 | $order->set_date_completed( $shopify_order->closed_at ); 224 | 225 | // Update order totals 226 | $order->set_total( $shopify_order->total_price ); 227 | $order->set_shipping_total( $shopify_order->total_shipping_price_set->shop_money->amount ); 228 | $order->set_discount_total( $shopify_order->total_discounts ); 229 | 230 | $this->process_order_tags( $order, $shopify_order ); 231 | $this->process_order_addresses( $order, $shopify_order ); 232 | 233 | if ( $shopify_order->email ) { 234 | // Mask email in test mode. 235 | if ( 'test' === $mode ) { 236 | $shopify_order->email .= '.masked'; 237 | } 238 | $this->create_or_assign_customer( $order, $shopify_order ); 239 | } else { 240 | $this->set_placeholder_billing_email( $order ); 241 | } 242 | 243 | // Tax must be processed before line items. 244 | $this->process_tax_lines( $order, $shopify_order ); 245 | $this->maybe_remove_orphan_items( $order ); 246 | $this->process_line_items( $order, $shopify_order ); 247 | $this->process_shipping_lines( $order, $shopify_order ); 248 | $this->process_discount_lines( $order, $shopify_order ); 249 | $this->process_shipment_tracking( $order, $shopify_order ); 250 | $this->process_payment_data( $order, $shopify_order ); 251 | 252 | $order->update_meta_data( '_order_items_mapping', $this->order_items_mapping ); 253 | $order->save(); 254 | 255 | // Refunds 256 | $this->process_order_refunds( $order, $shopify_order ); 257 | } 258 | 259 | /** 260 | * Converts a Shopify order status into a Woo orders status. 261 | * 262 | * @param string $financial_status the Shopify financial status. 263 | * @param string $fulfillment_status the Shopify fulfillment status. 264 | * @return string the Woo equivalent status 265 | */ 266 | private function get_woo_order_status( $financial_status, $fulfillment_status ) { 267 | $financial_mapping = array( 268 | 'pending' => 'pending', 269 | 'authorized' => 'processing', 270 | 'partially_paid' => 'processing', 271 | 'paid' => 'processing', 272 | 'partially_refunded' => 'processing', 273 | 'refunded' => 'refunded', 274 | 'voided' => 'cancelled', 275 | ); 276 | 277 | // Define the mapping arrays for Shopify fulfillment status to WooCommerce order status 278 | $fulfillment_mapping = array( 279 | 'fulfilled' => 'completed', 280 | 'partial' => 'processing', 281 | 'pending' => 'processing', 282 | ); 283 | 284 | $woo_status = 'pending'; // Default WooCommerce order status if no mapping found 285 | 286 | // Map financial status 287 | if ( isset( $financial_mapping[ $financial_status ] ) ) { 288 | $woo_status = $financial_mapping[ $financial_status ]; 289 | } 290 | 291 | // Map fulfillment status 292 | if ( isset( $fulfillment_mapping[ $fulfillment_status ] ) ) { 293 | $woo_status = $fulfillment_mapping[ $fulfillment_status ]; 294 | } 295 | 296 | return $woo_status; 297 | } 298 | 299 | /** 300 | * Imports the order tags and sets them to the order. 301 | * 302 | * @param WC_Order $order the Woo order. 303 | * @param object $shopify_order the Shopify order data. 304 | */ 305 | private function process_order_tags( $order, $shopify_order ) { 306 | if ( ! taxonomy_exists( 'wcot_order_tag' ) ) { 307 | return; 308 | } 309 | 310 | $tags = explode( ',', $shopify_order->tags ); 311 | 312 | foreach ( $tags as $tag ) { 313 | $tag = trim( $tag ); 314 | 315 | if ( ! $tag ) { 316 | WP_CLI::line( 'Invalid tag, skipping.' ); 317 | continue; 318 | } 319 | 320 | WP_CLI::line( sprintf( '- processing tag: "%s"', $tag, $order->get_id() ) ); 321 | 322 | // Find the term id if it exists. 323 | $term = get_term_by( 'name', $tag, 'wcot_order_tag', ARRAY_A ); 324 | 325 | if ( ! $term ) { 326 | $term = wp_insert_term( $tag, 'wcot_order_tag' ); 327 | } 328 | 329 | wp_set_post_terms( $order->get_id(), $term['term_id'], 'wcot_order_tag', true ); 330 | } 331 | } 332 | 333 | /** 334 | * Process billing and shipping addresses. 335 | * 336 | * @param WC_Order $order the Woo order. 337 | * @param object $shopify_order the Shopify order data. 338 | */ 339 | private function process_order_addresses( WC_Order $order, $shopify_order ) { 340 | // Update order billing address. 341 | if ( null !== $shopify_order->billing_address ) { 342 | $order->set_billing_first_name( $shopify_order->billing_address->first_name ); 343 | $order->set_billing_last_name( $shopify_order->billing_address->last_name ); 344 | $order->set_billing_company( $shopify_order->billing_address->company ); 345 | $order->set_billing_address_1( $shopify_order->billing_address->address1 ); 346 | $order->set_billing_address_2( $shopify_order->billing_address->address2 ); 347 | $order->set_billing_city( $shopify_order->billing_address->city ); 348 | $order->set_billing_state( $shopify_order->billing_address->province_code ); 349 | $order->set_billing_postcode( $shopify_order->billing_address->zip ); 350 | $order->set_billing_country( $shopify_order->billing_address->country_code ); 351 | $order->set_billing_phone( $shopify_order->billing_address->phone ); 352 | } 353 | 354 | if ( null !== $shopify_order->shipping_address ) { 355 | // Update order shipping address. 356 | $order->set_shipping_first_name($shopify_order->shipping_address->first_name); 357 | $order->set_shipping_last_name($shopify_order->shipping_address->last_name); 358 | $order->set_shipping_company($shopify_order->shipping_address->company); 359 | $order->set_shipping_address_1($shopify_order->shipping_address->address1); 360 | $order->set_shipping_address_2($shopify_order->shipping_address->address2); 361 | $order->set_shipping_city($shopify_order->shipping_address->city); 362 | $order->set_shipping_state($shopify_order->shipping_address->province_code); 363 | $order->set_shipping_postcode($shopify_order->shipping_address->zip); 364 | $order->set_shipping_country($shopify_order->shipping_address->country_code); 365 | $order->set_shipping_phone($shopify_order->shipping_address->phone); 366 | } 367 | 368 | $order->save(); 369 | } 370 | 371 | /** 372 | * Creates a customer if it does not exist yet and then assign it to the order. 373 | * Will not update the customer if it already exists. 374 | * Will search for the customer by it's email. 375 | * 376 | * @param WC_Order $order the Woo order. 377 | * @param object $shopify_order the Shopify order data. 378 | */ 379 | private function create_or_assign_customer( $order, $shopify_order ) { 380 | 381 | // Check if the customer exists in WooCommerce. 382 | $customer = get_user_by( 'email', $shopify_order->email ); 383 | 384 | if ( ! $customer ) { 385 | WP_CLI::line( sprintf( 'Customer %s does not exist. Creating...', $shopify_order->email ) ); 386 | 387 | // Prevents anti spam checks from running. 388 | remove_all_filters( 'wp_pre_insert_user_data' ); 389 | 390 | // Create a new customer using Woo functions. 391 | $customer_id = wc_create_new_customer( 392 | mb_strtolower( $shopify_order->email ), 393 | wc_create_new_customer_username( mb_strtolower( $shopify_order->email ) ), 394 | wp_generate_password() 395 | ); 396 | 397 | if ( is_wp_error( $customer_id ) ) { 398 | WP_CLI::error( sprintf( 'Error creating customer %s: %s', $shopify_order->email, $customer_id->get_error_message() ) ); 399 | } 400 | 401 | $customer = new WC_Customer( $customer_id ); 402 | $customer->set_first_name( $shopify_order->customer->first_name ); 403 | $customer->set_last_name( $shopify_order->customer->last_name ); 404 | 405 | if ( isset( $shopify_order->billing_address ) ) { 406 | $customer->set_billing_first_name( $shopify_order->billing_address->first_name ); 407 | $customer->set_billing_last_name( $shopify_order->billing_address->last_name ); 408 | $customer->set_billing_company( $shopify_order->billing_address->company ); 409 | $customer->set_billing_address_1( $shopify_order->billing_address->address1 ); 410 | $customer->set_billing_address_2( $shopify_order->billing_address->address2 ); 411 | $customer->set_billing_city( $shopify_order->billing_address->city ); 412 | $customer->set_billing_state( $shopify_order->billing_address->province_code ); 413 | $customer->set_billing_postcode( $shopify_order->billing_address->zip ); 414 | $customer->set_billing_country( $shopify_order->billing_address->country ); 415 | $customer->set_billing_phone( $shopify_order->billing_address->phone ); 416 | $customer->set_billing_email( $shopify_order->email ); 417 | } 418 | 419 | if ( isset( $shopify_order->shipping_address ) ) { 420 | $customer->set_shipping_first_name($shopify_order->shipping_address->first_name); 421 | $customer->set_shipping_last_name($shopify_order->shipping_address->last_name); 422 | $customer->set_shipping_company($shopify_order->shipping_address->company); 423 | $customer->set_shipping_address_1($shopify_order->shipping_address->address1); 424 | $customer->set_shipping_address_2($shopify_order->shipping_address->address2); 425 | $customer->set_shipping_city($shopify_order->shipping_address->city); 426 | $customer->set_shipping_state($shopify_order->shipping_address->province_code); 427 | $customer->set_shipping_postcode($shopify_order->shipping_address->zip); 428 | $customer->set_shipping_country($shopify_order->shipping_address->country); 429 | $customer->set_shipping_phone($shopify_order->shipping_address->phone); 430 | } 431 | 432 | $customer->save(); 433 | } else { 434 | WP_CLI::line( sprintf( 'Customer %s exists. Assigning...', $shopify_order->email ) ); 435 | $customer_id = $customer->ID; 436 | } 437 | 438 | $order->set_customer_id( $customer_id ); 439 | $order->save(); 440 | } 441 | 442 | /** 443 | * Sets the placeholder billing email. 444 | * Will use the username to create a @example.com.invalid email. 445 | * Can be used when the Shopify order does not have an email attached to it. 446 | * 447 | * @param WC_Order $order the Woo order. 448 | */ 449 | private function set_placeholder_billing_email( $order ) { 450 | // Create username from first name, last name, and phone number. 451 | $username = $order->get_billing_first_name() ?? $order->get_shipping_first_name(); 452 | $username .= $order->get_billing_last_name() ?? $order->get_shipping_last_name(); 453 | $username .= substr( $order->get_billing_phone() ?? $order->get_shipping_phone(), -3 ); 454 | $username = preg_replace( '/[^a-zA-Z0-9]/', '', $username ); 455 | $username = strtolower( $username ); 456 | $username = str_replace( ' ', '', $username ); 457 | 458 | if ( $username ) { 459 | $email = $username . '@example.com.invalid'; 460 | 461 | $order->set_billing_email( $email ); 462 | } 463 | 464 | $order->set_customer_id( 0 ); 465 | $order->save(); 466 | } 467 | 468 | /** 469 | * Process the tax lines. 470 | * 471 | * @param WC_Order $order the Woo order. 472 | * @param object $shopify_order the Shopify order data. 473 | */ 474 | private function process_tax_lines( WC_Order $order, $shopify_order ) { 475 | $order->remove_order_items( 'tax' ); 476 | $this->order_tax_rate_ids_mapping = array(); 477 | 478 | foreach ( $shopify_order->tax_lines as $index => $tax_line ) { 479 | $item = new WC_Order_Item_Tax(); 480 | $item->set_rate_id( $index ); 481 | $item->set_label( $tax_line->title ); 482 | $item->set_tax_total( $tax_line->price ); 483 | $item->set_rate_percent( $tax_line->rate ); 484 | 485 | $order->add_item( $item ); 486 | $this->order_tax_rate_ids_mapping[ $tax_line->title ] = $index; 487 | } 488 | 489 | $order->save(); 490 | } 491 | 492 | /** 493 | * Will remove order items that were not added during this execution if the 'remove-orphans' cli arg is set. 494 | * 495 | * @param WC_Order $order the Woo order. 496 | */ 497 | private function maybe_remove_orphan_items( $order ) { 498 | if ( ! isset( $this->assoc_args['remove-orphans'] ) ) { 499 | return; 500 | } 501 | 502 | foreach ( $order->get_items( array( 'line_item', 'shipping' ) ) as $item ) { 503 | if ( ! in_array( $item->get_id(), $this->order_items_mapping, true ) ) { 504 | WP_CLI::line( sprintf( 'Removing orphan item %d', $item->get_id() ) ); 505 | $order->remove_item( $item->get_id() ); 506 | } 507 | } 508 | 509 | $order->save(); 510 | } 511 | 512 | /** 513 | * Adds the line items to the Woo order. 514 | * 515 | * @param WC_Order $order the Woo order. 516 | * @param object $shopify_order the Shopify order data. 517 | */ 518 | private function process_line_items( $order, $shopify_order ) { 519 | foreach ( $shopify_order->line_items as $line_item ) { 520 | $line_item_id = 0; 521 | if ( isset( $this->order_items_mapping[ $line_item->id ] ) ) { 522 | WP_CLI::line( sprintf( 'Line item %d already exists (%d). Updating.', $line_item->id, $this->order_items_mapping[ $line_item->id ] ) ); 523 | $line_item_id = $this->order_items_mapping[ $line_item->id ]; 524 | } else { 525 | WP_CLI::line( sprintf( 'Creating line item %d', $line_item->id ) ); 526 | } 527 | 528 | // Create a product line item 529 | $item = new WC_Order_Item_Product( $line_item_id ); 530 | 531 | list( $product_id, $variation_id ) = $this->find_line_item_product( $line_item ); 532 | 533 | if ( $product_id ) { 534 | $item->set_product_id( $product_id ); 535 | } 536 | 537 | if ( $variation_id ) { 538 | $item->set_variation_id( $variation_id ); 539 | } 540 | 541 | $item->set_quantity( $line_item->quantity ); 542 | $item->set_subtotal( $line_item->price * $line_item->quantity ); 543 | $item->set_total( $line_item->price * $line_item->quantity - $line_item->total_discount ); 544 | $item->set_name( $line_item->title ); 545 | 546 | // Taxes 547 | $this->set_line_item_taxes( $item, $line_item ); 548 | 549 | $item->save(); 550 | 551 | $order->add_item( $item ); 552 | $this->order_items_mapping[ $line_item->id ] = $item->get_id(); 553 | } 554 | 555 | $order->save(); 556 | } 557 | 558 | /** 559 | * Adds the shipping line items to the Woo order. 560 | * 561 | * @param WC_Order $order the Woo order. 562 | * @param object $shopify_order the Shopify order data. 563 | */ 564 | private function process_shipping_lines( $order, $shopify_order ) { 565 | foreach ( $shopify_order->shipping_lines as $shipping_line ) { 566 | $line_item_id = 0; 567 | if ( isset( $this->order_items_mapping[ $shipping_line->id ] ) ) { 568 | WP_CLI::line( sprintf( 'Shipping line item %d already exists (%d). Updating.', $shipping_line->id, $this->order_items_mapping[ $shipping_line->id ] ) ); 569 | $line_item_id = $this->order_items_mapping[ $shipping_line->id ]; 570 | } else { 571 | WP_CLI::line( sprintf( 'Creating shipping line item %d', $shipping_line->id ) ); 572 | } 573 | 574 | $item = new WC_Order_Item_Shipping( $line_item_id ); 575 | $item->set_method_title( $shipping_line->title ); 576 | $item->set_total( $shipping_line->price ); 577 | $this->set_line_item_taxes( $item, $shipping_line ); 578 | $item->save(); 579 | $order->add_item( $item ); 580 | $this->order_items_mapping[ $shipping_line->id ] = $item->get_id(); 581 | } 582 | 583 | $order->save(); 584 | } 585 | 586 | /** 587 | * Adds discount lines to the Woo order. 588 | * 589 | * @param WC_Order $order the Woo order. 590 | * @param object $shopify_order the Shopify order data. 591 | */ 592 | private function process_discount_lines( $order, $shopify_order ) { 593 | $order->remove_order_items( 'coupon' ); 594 | 595 | foreach ( $shopify_order->discount_applications as $discount ) { 596 | $item = new WC_Order_Item_Coupon(); 597 | $item->set_discount( $discount->value ); 598 | if ( 'discount_code' === $discount->type ) { 599 | $item->set_code( $discount->code ); 600 | } else { 601 | $item->set_code( $discount->title ); 602 | } 603 | 604 | $order->add_item( $item ); 605 | } 606 | 607 | $order->save(); 608 | } 609 | 610 | /** 611 | * Adds the shipment tracking to the Woo order. 612 | * 613 | * @param WC_Order $order the Woo order. 614 | * @param object $shopify_order the Shopify order data. 615 | */ 616 | private function process_shipment_tracking( $order, $shopify_order ) { 617 | if ( ! class_exists( 'WC_Shipment_Tracking_Actions' ) ) { 618 | return; 619 | } 620 | 621 | if ( ! isset( $shopify_order->fulfillments ) ) { 622 | return; 623 | } 624 | 625 | WP_CLI::line( 'Processing shipment tracking.' ); 626 | 627 | $st = WC_Shipment_Tracking_Actions::get_instance(); 628 | $st->save_tracking_items( $order->get_id(), array() ); 629 | 630 | foreach ( $shopify_order->fulfillments as $fulfillment ) { 631 | foreach ( $fulfillment->tracking_numbers as $index => $tracking_number ) { 632 | if ( ! isset( $fulfillment->tracking_urls[ $index ] ) ) { 633 | continue; 634 | } 635 | 636 | $st->add_tracking_item( 637 | $order->get_id(), 638 | array( 639 | 'tracking_provider' => '', 640 | 'tracking_number' => $tracking_number, 641 | 'custom_tracking_link' => $fulfillment->tracking_urls[ $index ], 642 | 'custom_tracking_provider' => $fulfillment->tracking_company, 643 | 'date_shipped' => $fulfillment->created_at, 644 | ) 645 | ); 646 | } 647 | } 648 | } 649 | 650 | /** 651 | * Process order refunds. 652 | * 653 | * @param WC_Order $order the Woo order. 654 | * @param object $shopify_order the Shopify order data. 655 | */ 656 | private function process_order_refunds( $order, $shopify_order ) { 657 | foreach ( $shopify_order->refunds as $shopify_refund ) { 658 | 659 | // Check if the refund exists 660 | $refunds = $order->get_refunds(); 661 | 662 | if ( count( $refunds ) > 0 ) { 663 | // Deleting the refund then create a new one so we can reuse the logic in wc_create_refund. 664 | WP_CLI::line( sprintf( 'Refund %d already exists (%d). Deleting to create a new one.', $shopify_refund->id, $refunds[0]->get_id() ) ); 665 | 666 | foreach ( $refunds as $refund ) { 667 | $refund->delete(); 668 | } 669 | } 670 | 671 | // Refunded line items 672 | $refunded_line_items = array(); 673 | foreach ( $shopify_refund->refund_line_items as $refund_line_item ) { 674 | $refunded_line_items[ $this->order_items_mapping[ $refund_line_item->line_item_id ] ] = array( 675 | 'qty' => $refund_line_item->quantity, 676 | 'refund_total' => $refund_line_item->subtotal, 677 | ); 678 | } 679 | 680 | $refund_total = 0; 681 | foreach ( $shopify_refund->transactions as $transaction ) { 682 | if ( 'success' === $transaction->status ) { 683 | $refund_total += $transaction->amount; 684 | } 685 | } 686 | 687 | // Create a refund 688 | $refund = wc_create_refund( 689 | array( 690 | 'amount' => $refund_total, 691 | 'reason' => $shopify_refund->note, 692 | 'order_id' => $order->get_id(), 693 | 'line_items' => $refunded_line_items, 694 | 'date_created' => $shopify_refund->created_at, 695 | ) 696 | ); 697 | 698 | // Update refund date 699 | $refund->update_meta_data( '_refund_completed_date', $shopify_refund->processed_at ); 700 | 701 | // Update refund transaction ID 702 | if ( count( $shopify_refund->transactions ) > 0 && property_exists( $shopify_refund->transactions[0], 'receipt' ) && property_exists( $shopify_refund->transactions[0]->receipt, 'refund_transaction_id' ) ) { 703 | $refund->update_meta_data( '_transaction_id', $shopify_refund->transactions[0]->receipt->refund_transaction_id ); 704 | } 705 | 706 | // Store the Shopify refund ID 707 | $refund->update_meta_data( '_original_refund_id', $shopify_refund->id ); 708 | 709 | $refund->save(); 710 | } 711 | } 712 | 713 | /** 714 | * Gets a Woo line item product and variation ids by Shopify line item sku or product_id. 715 | * 716 | * @param object $line_item the Shopify line item data. 717 | * @return array containing the Woo product and variation ids. 718 | */ 719 | private function find_line_item_product( $line_item ) { 720 | $product_id = 0; 721 | $variation_id = 0; 722 | 723 | if ( $line_item->product_exists && $line_item->product_id ) { 724 | $_products = wc_get_products( 725 | array( 726 | 'limit' => 1, 727 | 'meta_key' => '_original_product_id', 728 | 'meta_value' => $line_item->product_id, 729 | ) 730 | ); 731 | 732 | if ( count( $_products ) === 1 ) { 733 | $product_id = $_products[0]->get_id(); 734 | if ( $_products[0]->is_type( 'variable' ) ) { 735 | $migration_data = $_products[0]->get_meta( '_migration_data', true ) ? $_products[0]->get_meta( '_migration_data', true ) : array(); 736 | if ( isset( $migration_data['variations_mapping'][ $line_item->variant_id ] ) ) { 737 | $variation_id = $migration_data['variations_mapping'][ $line_item->variant_id ]; 738 | } 739 | } 740 | } 741 | } 742 | 743 | if ( ! $product_id && $line_item->sku ) { 744 | $_id = wc_get_product_id_by_sku( $line_item->sku ); 745 | if ( $_id ) { 746 | $product_id = $_id; 747 | $_product = wc_get_product( $_id ); 748 | if ( is_a( $_product, 'WC_Product' ) && $_product->is_type( 'variation' ) ) { 749 | $product_id = $_product->get_parent_id(); 750 | $variation_id = $_product->get_id(); 751 | } 752 | } 753 | } 754 | 755 | return array( $product_id, $variation_id ); 756 | } 757 | 758 | /** 759 | * Sets the taxes for line items. 760 | * 761 | * @param WC_Order_Item_Product $line_item the Woo line item. 762 | * @param object $shopify_line_item the Shopify line item data. 763 | */ 764 | private function set_line_item_taxes( $line_item, $shopify_line_item ) { 765 | if ( empty( $this->order_tax_rate_ids_mapping ) || empty( $shopify_line_item->tax_lines ) ) { 766 | return; 767 | } 768 | 769 | $taxes = array( 770 | 'subtotal' => array(), 771 | 'total' => array(), 772 | ); 773 | 774 | foreach ( $shopify_line_item->tax_lines as $tax_line ) { 775 | if ( ! isset( $this->order_tax_rate_ids_mapping[ $tax_line->title ] ) ) { 776 | continue; 777 | } 778 | $tax_rate_id = $this->order_tax_rate_ids_mapping[ $tax_line->title ]; 779 | $taxes['subtotal'][ $tax_rate_id ] = $tax_line->price; 780 | $taxes['total'][ $tax_rate_id ] = $tax_line->price; 781 | } 782 | 783 | $line_item->set_taxes( $taxes ); 784 | } 785 | 786 | /** 787 | * Saves the old payment method data into the subscription meta table 788 | * so it can be used during the mapping update by 789 | * Migrator_CLI_Payment_Methods::update_orders_and_subscriptions_payment_methods. 790 | * 791 | * @param WC_Order $order the order to be updated. 792 | * @param object $shopify_order the shopify order data. 793 | */ 794 | private function process_payment_data( $order, $shopify_order ) { 795 | $transactions = Migrator_CLI_Utils::rest_request( 'orders/' . $shopify_order->id . '/transactions.json' ); 796 | 797 | if ( ! $transactions || ! $transactions->data->transactions ) { 798 | WP_CLI::line( 'No transactions to import for this order.' ); 799 | return; 800 | } 801 | 802 | $transaction = $this->get_transaction_with_payment_method_data( $transactions->data->transactions ); 803 | 804 | if ( ! $transaction ) { 805 | WP_CLI::line( WP_CLI::colorize( '%YWarning:%n ' ) . 'Capture transaction not found. Not going to import the transaction data for this order.' ); 806 | return; 807 | } 808 | 809 | // Case matters here. 810 | switch ( $transaction->gateway ) { 811 | case 'shopify_payments': 812 | $order->update_meta_data( Migrator_Cli_Payment_Methods::ORIGINAL_PAYMENT_GATEWAY_KEY, $transaction->gateway ); 813 | $order->update_meta_data( Migrator_Cli_Payment_Methods::ORIGINAL_PAYMENT_LAST_4, substr( $transaction->payment_details->credit_card_number, -4 ) ); 814 | 815 | if ( isset( $transaction->receipt->payment_method ) ) { 816 | $order->update_meta_data( Migrator_Cli_Payment_Methods::ORIGINAL_PAYMENT_METHOD_ID_KEY, $transaction->receipt->payment_method ); 817 | } elseif ( isset( $transaction->receipt->source->id ) ) { 818 | $order->update_meta_data( Migrator_Cli_Payment_Methods::ORIGINAL_PAYMENT_METHOD_ID_KEY, $transaction->receipt->source->id ); 819 | } else { 820 | WP_CLI::line( WP_CLI::colorize( '%RError:%n ' ) . 'Payment method not found in transaction' ); 821 | } 822 | 823 | break; 824 | // 'paypal' not 'PayPal' they are two different gateways. 825 | case 'paypal': 826 | $order->update_meta_data( Migrator_Cli_Payment_Methods::ORIGINAL_PAYMENT_GATEWAY_KEY, $transaction->gateway ); 827 | $order->update_meta_data( Migrator_Cli_Payment_Methods::ORIGINAL_PAYMENT_METHOD_ID_KEY, $transaction->receipt->billing_agreement_id ); 828 | 829 | break; 830 | case '': 831 | case 'manual': 832 | break; 833 | default: 834 | $order->update_meta_data( Migrator_Cli_Payment_Methods::ORIGINAL_PAYMENT_GATEWAY_KEY, $transaction->gateway ); 835 | WP_CLI::line( WP_CLI::colorize( '%YWarning:%n ' ) . 'Unknown payment gateway: ' . $transaction->gateway ); 836 | } 837 | } 838 | 839 | /** 840 | * Searches for the first successful transaction with payment method data 841 | * that can be used for subscription renewals in the transactions array. 842 | * 843 | * @param array $transactions of shopify transactions. 844 | * @return array|void 845 | */ 846 | private function get_transaction_with_payment_method_data( $transactions ) { 847 | $transactions = array_reverse( (array) $transactions ); 848 | foreach ( $transactions as $transaction ) { 849 | if ( in_array( $transaction->kind, array( 'sale', 'capture', 'authorization' ), true ) && 'failure' !== $transaction->status ) { 850 | if ( 'paypal' === $transaction->gateway && ( ! isset( $transaction->receipt->billing_agreement_id ) || empty( $transaction->receipt->billing_agreement_id ) ) ) { 851 | continue; 852 | } 853 | 854 | return $transaction; 855 | } 856 | } 857 | } 858 | 859 | /** 860 | * Gets the faire order id from a shopify order. 861 | * 862 | * @param object $shopify_order the shopify order. 863 | * @return string the faire order id. 864 | */ 865 | private function get_faire_order_id( $shopify_order ) { 866 | $faire_id = $shopify_order->source_url; 867 | $faire_id = explode( '/', $faire_id ); 868 | return end( $faire_id ); 869 | } 870 | } 871 | --------------------------------------------------------------------------------