diff --git a/includes/api/class-wc-api-taxes.php b/includes/api/class-wc-api-taxes.php index af1063f9db3..9cf60c2682f 100644 --- a/includes/api/class-wc-api-taxes.php +++ b/includes/api/class-wc-api-taxes.php @@ -228,7 +228,7 @@ class WC_API_Taxes extends WC_API_Resource { WC_Tax::_update_tax_rate_cities( $id, wc_clean( $data['city'] ) ); } - do_action( 'woocommerce_api_create_tax_rate', $id, $data ); + do_action( 'woocommerce_api_create_tax', $id, $data ); $this->server->send_status( 201 ); @@ -317,10 +317,7 @@ class WC_API_Taxes extends WC_API_Resource { WC_Tax::_update_tax_rate_cities( $id, wc_clean( $data['city'] ) ); } - do_action( 'woocommerce_api_edit_product', $id, $data ); - - // Clear cache/transients - wc_delete_product_transients( $id ); + do_action( 'woocommerce_api_edit_tax_rate', $id, $data ); return $this->get_tax( $id ); } catch ( WC_API_Exception $e ) { @@ -394,6 +391,7 @@ class WC_API_Taxes extends WC_API_Resource { * @since 2.5.0 * * @param array $args + * @param bool $count_only * * @return array */ @@ -552,7 +550,7 @@ class WC_API_Taxes extends WC_API_Resource { } /** - * Create a tax class + * Create a tax class. * * @since 2.5.0 * @@ -582,7 +580,7 @@ class WC_API_Taxes extends WC_API_Resource { $classes = WC_Tax::get_tax_classes(); $exists = false; - // Check if class exists + // Check if class exists. foreach ( $classes as $key => $class ) { if ( sanitize_title( $class ) === $slug ) { $exists = true; @@ -590,12 +588,12 @@ class WC_API_Taxes extends WC_API_Resource { } } - // Return error if tax class already exists + // Return error if tax class already exists. if ( $exists ) { throw new WC_API_Exception( 'woocommerce_api_cannot_create_tax_class', __( 'Tax class already exists', 'woocommerce' ), 401 ); } - // Add the new class + // Add the new class. $classes[] = $name; update_option( 'woocommerce_tax_classes', implode( "\n", $classes ) ); diff --git a/includes/class-wc-cli.php b/includes/class-wc-cli.php index ae744406201..8cdd3831e07 100644 --- a/includes/class-wc-cli.php +++ b/includes/class-wc-cli.php @@ -19,4 +19,5 @@ WP_CLI::add_command( 'wc order', 'WC_CLI_Order' ); WP_CLI::add_command( 'wc product', 'WC_CLI_Product' ); WP_CLI::add_command( 'wc product category', 'WC_CLI_Product_Category' ); WP_CLI::add_command( 'wc report', 'WC_CLI_Report' ); +WP_CLI::add_command( 'wc tax', 'WC_CLI_Tax' ); WP_CLI::add_command( 'wc tool', 'WC_CLI_Tool' ); diff --git a/includes/cli/class-wc-cli-tax.php b/includes/cli/class-wc-cli-tax.php new file mode 100644 index 00000000000..886b646f5ab --- /dev/null +++ b/includes/cli/class-wc-cli-tax.php @@ -0,0 +1,685 @@ +=] + * : Associative args for the new tax rate. + * + * [--porcelain] + * : Outputs just the new tax rate id. + * + * ## AVAILABLE FIELDS + * + * These fields are available for create command: + * + * * country + * * state + * * postcode + * * city + * * rate + * * name + * * priority + * * compound + * * shipping + * * class + * * order + * + * ## EXAMPLES + * + * wp wc tax create --country=US --rate=5 --class=standard --type=percent + * + * @since 2.5.0 + */ + public function create( $__, $assoc_args ) { + $porcelain = isset( $assoc_args['porcelain'] ); + unset( $assoc_args['porcelain'] ); + + $assoc_args = apply_filters( 'woocommerce_cli_create_tax_rate_data', $assoc_args ); + + $tax_data = array( + 'tax_rate_country' => '', + 'tax_rate_state' => '', + 'tax_rate' => '', + 'tax_rate_name' => '', + 'tax_rate_priority' => 1, + 'tax_rate_compound' => 0, + 'tax_rate_shipping' => 1, + 'tax_rate_order' => 0, + 'tax_rate_class' => '', + ); + + foreach ( $tax_data as $key => $value ) { + $new_key = str_replace( 'tax_rate_', '', $key ); + $new_key = 'tax_rate' === $new_key ? 'rate' : $new_key; + + if ( isset( $assoc_args[ $new_key ] ) ) { + if ( in_array( $new_key, array( 'compound', 'shipping' ) ) ) { + $tax_data[ $key ] = $assoc_args[ $new_key ] ? 1 : 0; + } else { + $tax_data[ $key ] = $assoc_args[ $new_key ]; + } + } + } + + // Create tax rate. + $id = WC_Tax::_insert_tax_rate( $tax_data ); + + // Add locales. + if ( ! empty( $assoc_args['postcode'] ) ) { + WC_Tax::_update_tax_rate_postcodes( $id, wc_clean( $assoc_args['postcode'] ) ); + } + + if ( ! empty( $assoc_args['city'] ) ) { + WC_Tax::_update_tax_rate_cities( $id, wc_clean( $assoc_args['city'] ) ); + } + + do_action( 'woocommerce_cli_create_tax_rate', $id, $tax_data ); + + if ( $porcelain ) { + WP_CLI::line( $id ); + } else { + WP_CLI::success( "Created tax rate $id." ); + } + } + + /** + * Create a tax class. + * + * ## OPTIONS + * + * [--=] + * : Associative args for the new tax class. + * + * [--porcelain] + * : Outputs just the new tax class slug. + * + * ## AVAILABLE FIELDS + * + * These fields are available for create command: + * + * * name + * + * ## EXAMPLES + * + * wp wc tax create_class --name="Reduced Rate" + * + * @since 2.5.0 + */ + public function create_class( $__, $assoc_args ) { + try { + $porcelain = isset( $assoc_args['porcelain'] ); + unset( $assoc_args['porcelain'] ); + + $assoc_args = apply_filters( 'woocommerce_cli_create_tax_class_data', $assoc_args ); + + // Check if name is specified. + if ( ! isset( $assoc_args['name'] ) ) { + throw new WC_CLI_Exception( 'woocommerce_cli_missing_name', sprintf( __( 'Missing parameter %s', 'woocommerce' ), 'name' ) ); + } + + $name = sanitize_text_field( $assoc_args['name'] ); + $slug = sanitize_title( $name ); + $classes = WC_Tax::get_tax_classes(); + $exists = false; + + // Check if class exists. + foreach ( $classes as $key => $class ) { + if ( sanitize_title( $class ) === $slug ) { + $exists = true; + break; + } + } + + // Return error if tax class already exists. + if ( $exists ) { + throw new WC_CLI_Exception( 'woocommerce_cli_cannot_create_tax_class', __( 'Tax class already exists', 'woocommerce' ) ); + } + + // Add the new class + $classes[] = $name; + + update_option( 'woocommerce_tax_classes', implode( "\n", $classes ) ); + + do_action( 'woocommerce_cli_create_tax_class', $slug, array( 'name' => $name ) ); + + if ( $porcelain ) { + WP_CLI::line( $slug ); + } else { + WP_CLI::success( "Created tax class $slug." ); + } + } catch ( WC_CLI_Exception $e ) { + WP_CLI::error( $e->getMessage() ); + } + } + + /** + * Delete one or more tax rates. + * + * ## OPTIONS + * + * ... + * : The tax rate ID to delete. + * + * ## EXAMPLES + * + * wp wc tax delete 123 + * + * wp wc tax delete $(wp wc tax list --format=ids) + * + * @since 2.5.0 + */ + public function delete( $args, $assoc_args ) { + $exit_code = 0; + + foreach ( $args as $tax_id ) { + $tax_id = absint( $tax_id ); + $tax = WC_Tax::_get_tax_rate( $tax_id ); + + if ( is_null( $tax ) ) { + $exit_code += 1; + WP_CLI::warning( "Failed deleting tax rate {$tax_id}." ); + continue; + } + + do_action( 'woocommerce_cli_delete_tax_rate', $tax_id ); + + WC_Tax::_delete_tax_rate( $tax_id ); + WP_CLI::success( "Deleted tax rate {$tax_id}." ); + } + exit( $exit_code ? 1 : 0 ); + } + + /** + * Delete one or more tax classes. + * + * ## OPTIONS + * + * ... + * : The tax class slug to delete. + * + * ## EXAMPLES + * + * wp wc tax delete_class reduced-rate + * + * wp wc tax delete_class $(wp wc tax list_class --format=ids) + * + * @since 2.5.0 + */ + public function delete_class( $args, $assoc_args ) { + $classes = WC_Tax::get_tax_classes(); + $exit_code = 0; + + foreach ( $args as $slug ) { + $slug = sanitize_title( $slug ); + $deleted = false; + + foreach ( $classes as $key => $class ) { + if ( sanitize_title( $class ) === $slug ) { + unset( $classes[ $key ] ); + $deleted = true; + break; + } + } + + if ( $deleted ) { + WP_CLI::success( "Deleted tax class {$slug}." ); + } else { + $exit_code += 1; + WP_CLI::warning( "Failed deleting tax class {$slug}." ); + } + } + + update_option( 'woocommerce_tax_classes', implode( "\n", $classes ) ); + + exit( $exit_code ? 1 : 0 ); + } + + /** + * Get a tax rate. + * + * ## OPTIONS + * + * + * : Tax rate ID + * + * [--field=] + * : Instead of returning the whole tax rate fields, returns the value of a single fields. + * + * [--fields=] + * : Get a specific subset of the tax rates fields. + * + * [--format=] + * : Accepted values: table, json, csv. Default: table. + * + * ## AVAILABLE FIELDS + * + * These fields are available for get command: + * + * * id + * * country + * * state + * * postcode + * * city + * * rate + * * name + * * priority + * * compound + * * shipping + * * order + * * class + * + * ## EXAMPLES + * + * wp wc tax get 123 --field=rate + * + * wp wc tax get 321 --format=json > rate321.json + * + * @since 2.5.0 + */ + public function get( $args, $assoc_args ) { + global $wpdb; + + try { + + $tax_data = $this->format_taxes_to_items( array( $args[0] ) ); + + if ( empty( $tax_data ) ) { + throw new WC_CLI_Exception( 'woocommerce_cli_invalid_tax_rate', sprintf( __( 'Invalid tax rate ID: %s', 'woocommerce' ), $args[0] ) ); + } + + $tax_data = apply_filters( 'woocommerce_cli_get_tax_rate', $tax_data[0] ); + + if ( empty( $assoc_args['fields'] ) ) { + $assoc_args['fields'] = array_keys( $tax_data ); + } + + $formatter = $this->get_formatter( $assoc_args ); + $formatter->display_item( $tax_data ); + } catch ( WC_CLI_Exception $e ) { + WP_CLI::error( $e->getMessage() ); + } + } + + /** + * List taxes. + * + * ## OPTIONS + * + * [--=] + * : Filter tax based on tax property. + * + * [--field=] + * : Prints the value of a single field for each tax. + * + * [--fields=] + * : Limit the output to specific tax fields. + * + * [--format=] + * : Acceptec values: table, csv, json, count, ids. Default: table. + * + * ## AVAILABLE FIELDS + * + * These fields will be displayed by default for each tax: + * + * * id + * * country + * * state + * * postcode + * * city + * * rate + * * name + * * priority + * * compound + * * shipping + * * class + * + * These fields are optionally available: + * + * * order + * + * Fields for filtering query result also available: + * + * * class Sort by tax class. + * * page Page number. + * + * ## EXAMPLES + * + * wp wc tax list + * + * wp wc tax list --field=id + * + * wp wc tax list --fields=id,rate,class --format=json + * + * @since 2.5.0 + * @subcommand list + */ + public function list_( $__, $assoc_args ) { + $query_args = $this->merge_tax_query_args( array(), $assoc_args ); + $formatter = $this->get_formatter( $assoc_args ); + + $taxes = $this->query_tax_rates( $query_args ); + + if ( 'ids' === $formatter->format ) { + $_taxes = array(); + foreach ( $taxes as $tax ) { + $_taxes[] = $tax->tax_rate_id; + } + echo implode( ' ', $_taxes ); + } else { + $items = $this->format_taxes_to_items( $taxes ); + $formatter->display_items( $items ); + } + } + + /** + * List tax classes. + * + * ## OPTIONS + * + * [--=] + * : Filter tax class based on tax class property. + * + * [--field=] + * : Prints the value of a single field for each tax class. + * + * [--fields=] + * : Limit the output to specific tax class fields. + * + * [--format=] + * : Acceptec values: table, csv, json, count, ids. Default: table. + * + * ## AVAILABLE FIELDS + * + * These fields will be displayed by default for each tax class: + * + * * slug + * * name + * + * ## EXAMPLES + * + * wp wc tax list_class + * + * wp wc tax list_class --field=slug + * + * wp wc tax list_class --format=json + * + * @since 2.5.0 + * @subcommand list_class + */ + public function list_class( $__, $assoc_args ) { + // Set default fields for tax classes + if ( empty( $assoc_args['fields'] ) ) { + $assoc_args['fields'] = 'slug,name'; + } + + $formatter = $this->get_formatter( $assoc_args ); + $items = array(); + + // Add standard class + $items[] = array( + 'slug' => 'standard', + 'name' => __( 'Standard Rate', 'woocommerce' ) + ); + + $classes = WC_Tax::get_tax_classes(); + + foreach ( $classes as $class ) { + $items[] = apply_filters( 'woocommerce_cli_tax_class_response', array( + 'slug' => sanitize_title( $class ), + 'name' => $class + ), $class, $assoc_args, $this ); + } + + if ( 'ids' === $formatter->format ) { + $_slugs = array(); + foreach ( $items as $item ) { + $_slugs[] = $item['slug']; + } + echo implode( ' ', $_slugs ); + } else { + $formatter->display_items( $items ); + } + } + + /** + * Update a tax rate. + * + * ## OPTIONS + * + * + * : The ID of the tax rate to update. + * + * --= + * : One or more fields to update. + * + * ## AVAILABLE FIELDS + * + * These fields are available for update command: + * + * * country + * * state + * * postcode + * * city + * * rate + * * name + * * priority + * * compound + * * shipping + * * class + * + * ## EXAMPLES + * + * wp wc tax update 123 --rate=5 + * + * @since 2.5.0 + */ + public function update( $args, $assoc_args ) { + try { + // Get current tax rate data + $tax_data = $this->format_taxes_to_items( array( $args[0] ) ); + + if ( empty( $tax_data ) ) { + throw new WC_CLI_Exception( 'woocommerce_cli_invalid_tax_rate', sprintf( __( 'Invalid tax rate ID: %s', 'woocommerce' ), $args[0] ) ); + } + + $current_data = $tax_data[0]; + $id = $current_data['id']; + $data = apply_filters( 'woocommerce_cli_update_tax_rate_data', $assoc_args, $id ); + $new_data = array(); + $default_fields = array( + 'tax_rate_country', + 'tax_rate_state', + 'tax_rate', + 'tax_rate_name', + 'tax_rate_priority', + 'tax_rate_compound', + 'tax_rate_shipping', + 'tax_rate_order', + 'tax_rate_class' + ); + + foreach ( $data as $key => $value ) { + $new_key = 'rate' === $key ? 'tax_rate' : 'tax_rate_' . $key; + + // Check if the key is valid + if ( ! in_array( $new_key, $default_fields ) ) { + continue; + } + + // Test new data against current data + if ( $value === $current_data[ $key ] ) { + continue; + } + + // Fix compund and shipping values + if ( in_array( $key, array( 'compound', 'shipping' ) ) ) { + $value = $value ? 1 : 0; + } + + $new_data[ $new_key ] = $value; + } + + // Update tax rate + WC_Tax::_update_tax_rate( $id, $new_data ); + + // Update locales + if ( ! empty( $data['postcode'] ) && $current_data['postcode'] != $data['postcode'] ) { + WC_Tax::_update_tax_rate_postcodes( $id, wc_clean( $data['postcode'] ) ); + } + + if ( ! empty( $data['city'] ) && $current_data['city'] != $data['city'] ) { + WC_Tax::_update_tax_rate_cities( $id, wc_clean( $data['city'] ) ); + } + + do_action( 'woocommerce_cli_update_tax_rate', $id, $data ); + + WP_CLI::success( "Updated tax rate $id." ); + } catch ( WC_CLI_Exception $e ) { + WP_CLI::error( $e->getMessage() ); + } + } + + /** + * Add common cli arguments to argument list before $wpdb->get_results() is run. + * + * @since 2.5.0 + * @param array $base_args Required arguments for the query (e.g. `limit`, etc) + * @param array $assoc_args Arguments provided in when invoking the command + * @return array + */ + protected function merge_tax_query_args( $base_args, $assoc_args ) { + $args = array(); + + if ( ! empty( $assoc_args['class'] ) ) { + $args['class'] = $assoc_args['class']; + } + + // Number of post to show per page. + if ( ! empty( $assoc_args['limit'] ) ) { + $args['posts_per_page'] = $assoc_args['limit']; + } + + // posts page. + $args['paged'] = ( isset( $assoc_args['page'] ) ) ? absint( $assoc_args['page'] ) : 1; + + $args = apply_filters( 'woocommerce_cli_tax_query_args', $args, $assoc_args ); + + return array_merge( $base_args, $args ); + } + + /** + * Helper method to get tax rates objects + * + * @since 2.5.0 + * + * @param array $args + * + * @return array + */ + protected function query_tax_rates( $args ) { + global $wpdb; + + $query = " + SELECT tax_rate_id + FROM {$wpdb->prefix}woocommerce_tax_rates + WHERE 1 = 1 + "; + + // Filter by tax class + if ( ! empty( $args['class'] ) ) { + $class = 'standard' !== $args['class'] ? sanitize_title( $args['class'] ) : ''; + $query .= " AND tax_rate_class = '$class'"; + } + + // Order tax rates + $order_by = ' ORDER BY tax_rate_order'; + + // Pagination + $per_page = isset( $args['posts_per_page'] ) ? $args['posts_per_page'] : get_option( 'posts_per_page' ); + $offset = 1 < $args['paged'] ? ( $args['paged'] - 1 ) * $per_page : 0; + $pagination = sprintf( ' LIMIT %d, %d', $offset, $per_page ); + + return $wpdb->get_results( $query . $order_by . $pagination ); + } + + /** + * Get default format fields that will be used in `list` and `get` subcommands. + * + * @since 2.5.0 + * @return string + */ + protected function get_default_format_fields() { + return 'id,country,state,postcode,city,rate,name,priority,compound,shipping,class'; + } + + /** + * Format taxes from query result to items in which each item contain + * common properties of item, for instance `tax_rate_id` will be `id`. + * + * @since 2.5.0 + * @param array $taxes Array of tax rate. + * @return array Items + */ + protected function format_taxes_to_items( $taxes ) { + global $wpdb; + + $items = array(); + + foreach ( $taxes as $tax_id ) { + $id = is_object( $tax_id ) ? $tax_id->tax_rate_id : $tax_id; + $id = absint( $id ); + + // Get tax rate details + $tax = WC_Tax::_get_tax_rate( $id ); + + if ( is_wp_error( $tax ) || empty( $tax ) ) { + continue; + } + + $tax_data = array( + 'id' => $tax['tax_rate_id'], + 'country' => $tax['tax_rate_country'], + 'state' => $tax['tax_rate_state'], + 'postcode' => '', + 'city' => '', + 'rate' => $tax['tax_rate'], + 'name' => $tax['tax_rate_name'], + 'priority' => (int) $tax['tax_rate_priority'], + 'compound' => (bool) $tax['tax_rate_compound'], + 'shipping' => (bool) $tax['tax_rate_shipping'], + 'order' => (int) $tax['tax_rate_order'], + 'class' => $tax['tax_rate_class'] ? $tax['tax_rate_class'] : 'standard' + ); + + // Get locales from a tax rate + $locales = $wpdb->get_results( $wpdb->prepare( " + SELECT location_code, location_type + FROM {$wpdb->prefix}woocommerce_tax_rate_locations + WHERE tax_rate_id = %d + ", $id ) ); + + if ( ! is_wp_error( $tax ) && ! is_null( $tax ) ) { + foreach ( $locales as $locale ) { + $tax_data[ $locale->location_type ] = $locale->location_code; + } + } + + $items[] = $tax_data; + } + + return $items; + } +}