The Curious Case of WooCommerce Shipping and the Hackiest Code I’ve Ever Written

We all have those moments when sizing up a programming problem. It usually starts with this:

But really, how hard can that be?

Turns out, it can be pretty damn hard.

A Tale of Recalculating Shipping

Currently on a client project one of my tasks included recalculating shipping costs for the renewal orders of subscriptions. That means that when a renewal order happens, instead of grabbing whatever shipping cost the subscription had on it, I should try to find the latest, freshest cost of that shipping method, and use that instead. I mean that’s what happens in the cart / checkout, so Really, How Hard Can That Be®?

I made a plan: I’ll look at what happens at cart / checkout, and do the same when renewing.

How does this even work?

Shipping methods and zones

Shipping in WooCommerce is divided up into zones. Zones are basically a bunch of destinations that are grouped together. Will you be shipping things differently to Canada, continental US, and everywhere else? That’s three zones then.

Shipping methods are the things you can use to ship WITH. USPS, Fedex, Flat rate shipping, Local pickup are all shipping methods.

Shipping methods can be assigned to shipping zones. You’ll ship stuff to the USA with USPS, stuff to Canada with Flat rate (same shipping cost regardless WHERE in Canada it ships to), everywhere else with Fedex, and if they happen to live in Seattle, where you have an office, they can come in and pick up the things.

It’s important that in this setup, every shipping method lives in the database twice:

  • once as a general “available” shipping method that is not assigned to anything, and
  • once as a shipping method assigned to a shipping zone.

Shipping methods that are assigned to zones have something called an instance ID. This will be important.

So that’s one half of it.

Shipping packages

Your cart can be broken up into packages. A package is a collection of items that go to the same place. By default there’s only one package, because you’re ordering that sweet coffee mug for yourself.

If however you’re doing your Christmas shopping, and you’re buying a bunch of things at once, and sending some of it to grandma in Canada, some of it to your sister in New York, some of it to your best friend who’s currently living as an expat in Kyoto, then your one cart suddenly has three packages.

And now to get shipping rates

When WooCommerce decides to start figuring out just how much shipping you’re going to end up paying, it will look at the shipping packages one by one, and give you a rate (or a selection of rates) for each and every one of them.

That bit goes like this:

  1. WooCommerce looks at the destination of the package, and figures out which zone the destination belongs to.
  2. It grabs all the available shipping methods assigned to that zone, and identifies them with their instance ID.
  3. It queries the available shipping zones for rates based on the items that are in the package. For flat rate this is easy. For USPS, it will take into account the size / weight of the packages and talk to their API to get a variety of rates.
  4. It presents you with a choice of rates. For example: you can send that coffee mug to New York as normal Priority Mail, Expedited mail, signed and tracked, etc, and all of those will cost something. Which one do you want?
  5. You make a selection, and hit checkout, your order will be generated, card charged, and everyone’s happy.

Seems simple enough, right? We’ve now figured out how this works, so let’s move on!

Let’s do it at renewal!

This will have code and database things in it. Nothing else to do besides being aware of it.

The good thing about a renewal order is that the choice of which shipping method and instance to use has already been made, so we won’t need to make a decision for the user, which would be problematic. Few people would be happy if they asked for a thing, and you gave them a different thing.

Little background: an order is a post with post_type = shop_order. The order details are in postmeta, such as address, totals, card tokens. Order items are stored in the woocommerce_order_items table. They can have different item_types, such as fee, line_item, tax, and shipping. The details of each order item are stored in the woocommerce_order_itemmeta table. Those details differ by what the item type is, but for shipping, they’re at least method_id, cost, and taxes.

One of the orders has this as their shipping meta:

shipping item meta details

Okay, so we know the method_id and the instance_id from this. In USPS’s case it’s the flat_rate_box_priority rate.

Looking at how shipping is calculated in the cart, I knew that I needed to first instantiate a new WC_Shipping class, and load the shipping method for that package.

Hurdle 1: Packages

Hold on a second though, we don’t have packages. We have order items. Packages are returned by this method on WC_Cart:

get shipping packages code

I can turn my order items into a cart. I know all the details. To replace get_cart I had to mangle some data from $order->get_items(). Some of the details the get_items returned I didn’t need.

Hurdle 2: Rates

Once I had the packages, I could summon the shipping methods. I then looped over the shipping line items on the order, and tried to find out whether the shipping line item is the same as one of the methods I got for the “packages”. If I had a match for the method, I could then query for the rates.

To do that, all I had to do is call $method_to_use->get_rates_for_package( $packages[0] );. Seems simple enough. Ideally I’d get a bunch of rates.

But all I got was a fatal error :(.

[11-Mar-2017 02:36:19 UTC] PHP Fatal error:  Uncaught Error: Call to a member function is_vat_exempt() on null in /Users/javorszky/Sites/omg/wp-content/plugins/woocommerce/includes/abstracts/abstract-wc-shipping-method.php:148

Um... What’s there?
fatal error in abstract wc shipping method

Hurdle 3: WC_Customer

Okay, fair enough, let’s make WC()->customer happen. However, this is in woocommerce.php:

customer only declared on wc object if it’s a frontend request

Well, that’s not great.

I guess I have to declare that. Wonder what the content of it is:

constructor function of WC_Customer gets its content from a session or if that’s empty, loads the default

Right, um, can’t I just pass in the ID of the customer user? I know it... please? Sigh... What’s default_data though?

default data for WC_Customer is based on currently logged in user

Noo! Are you kidding me? It will get the data from the currently logged in user?! Which, in my case, means the shop administrator. Regardless of who the customer on the order is. There’s no way for me to pass in WHO I want the customer to be?!

Oh wait, it all depends on a session. So if the session is there, then WC_Customer will be based on it instead of the currently logged in user. That also means that I need to grab the user details, complete with address and all, and stuff it in the session before the code gets to a point where it would actually use it.

I made a turn_user_id_into_customer_data method, which is quite literally the mirror of the set_default_data, except instead of get_current_user_id(), it uses the customer’s ID that I passed in.

But also we need to have access to the WC_Customer class. Before I recalculate shipping on the order and subscription, let’s call this:

method to store customer data in session and instantiate a new WC_Customer instance

Okay, that should work.

Nope:

[11-Mar-2017 02:51:41 UTC] PHP Fatal error:  Uncaught Error: Call to a member function set() on null in /Users/javorszky/Sites/omg/wp-content/plugins/<clientproject>/includes/AbstractShipping.php:188

Hurdle 4: WC_Session_Handler

What, we’re missing sessions... A short search into where THAT one is defined leads me back to the main woocommerce.php file:

sessions are defined only on front end
Okay, all I need is to have these two lines in my code prior to everything happening:

$session_class  = apply_filters( 'woocommerce_session_handler', 'WC_Session_Handler' );
WC()->session  = new $session_class();

Nope:

[11-Mar-2017 02:57:44 UTC] PHP Fatal error:  Uncaught Error: Call to undefined function wc_get_chosen_shipping_method_ids() in /Users/javorszky/Sites/omg/wp-content/plugins/woocommerce/includes/class-wc-customer.php:333

That function is in the file includes/wc-cart-functions.php, which is included in a method that’s called frontend_includes (are you serious?!), which is, not surprisingly, only called if the request is on the front end. Which this is not...

For the nth time, let’s just then include that file in our code. Luckily at least there’s a helper method so I don’t need to tinker with relative paths and getting the folder wrong, and I can just call it like this:

include_once( WC()->plugin_path() . '/includes/wc-cart-functions.php' );

Okay, SURELY that’s going to be done.
chrome displays a sad face as yet again there is an error
(yes, I do call my dev site omg.dev)

[11-Mar-2017 03:01:47 UTC] PHP Fatal error:  Uncaught Error: Call to a member function get_cart_item_tax_classes() on null in /Users/javorszky/Sites/omg/wp-content/plugins/woocommerce/includes/class-wc-tax.php:473

apparently tax calculations are dependant on the cart
Why... why do I need a cart? I don’t have a cart... I just want to calculate the shipping for my items... :’(

Please enjoy this gif that perfectly describes how I’m feeling at the moment:

looney tunes wolf opening a door only to find another door under it until the end of times

Hurdle 5: WC_Cart

Okay, how do I make a cart happen on the admin side? I can’t use an ACTUAL cart because no one is going to check out with that, but apparently I need to have it because WooCommerce says so.

Therefore let’s create a cart, put it on the WC() object, and then put the order items into it, and hope for the best. In WooCommerce core they also have some code to select the chosen shipping method, which seems important, so let’s add that too for good measure.

After I’ve created a new cart, and emptied it, I’m calling this for every new renewal order before I even attempt to calculate shipping:

setting up a cart by adding order items into the cart

Whoop! There are no more errors! It renewed, and adjusted the cost of the existing shipping on it!

Hurdle 6: Taxes, totals

This, however, does not look right:

tax and total calculations are off

The price was $9.00 with a 20% tax, so $1.80 was correct. I changed the price of shipping to be $11.00 + $1.00 for that specific shipping class which makes a total $12. The tax on that should be $2.40, and not $1.80. The total of $16.80 is also wrong as it doesn’t account for the changed price either.

Of course, because I need to call calculate_totals on both the subscription and the new order. As an added bonus, calculate_totals has code to calculate taxes AND shipping. An absolute win.

Trying it again with calculations added:

almost, but not quite. shipping line item still has wrong tax

The order total and tax are now correct, as they were calculated with shipping’s $2.40 tax, except the shipping line item still displays $1.80. If I click on the calculate taxes button, everything will be fine and dandy.

Hurlde 7: Calculate taxes

The method that gets called when I click the calculate taxes button is different to the one that happens when I just call calculate_totals. They overlap by a LOT, but they’re different.

Notably calculate_totals is missing the part that updates the line item’s tax display as well.

Which I had to solve with some additional hackery for flat rate (adjusted for the corrections):

how to recalculate taxes the good way

As an aside, I had to change elements from being integers to strings, because if you store a:1:{i:1;d:0;} as a line item’s tax, even though you would think the data is there and will be displayed, it actually won’t. You need to have a:1:{i:1;s:1:"0";}. The only difference is that the 0 tax for a free line item is now a string, instead of a float. Strings are denoted with s, floats are with d.

¯¯\_(ツ)_/¯¯

CORRECTION (13/03/2017): It was I who didn’t know something. Turns out the taxes array, that looks like this: a:1:{i:1;s:1:"0";} needs to match up with the rate ID already on the order. That is the tax line items’ rate ids. So the number 1 in bold below has to be equal to the rate_id meta of the tax line item:

a:1:{i:1;s:1:"0";}

Today I learned.

Oh, and it doesn’t matter if it’s a d or an s, so the above could just as well be

a:1:{i:1;d:0;}

CORRECTION 2 (13/03/2017): So turns out my earlier hunch about having to use s instead of d is entirely valid because occasionally PHP has the best bugs features. And by that I'm referring to type juggling. Consider the following lovely reduced demonstration:

the number zero equals empty string. The string zero does not equal empty string

When WooCommerce decides to display the tax line item to be the number stored on the item, or a –, it looks at whether the value stored is an empty string. Apparently the number 0 is an empty string. See the loose comparison on that link. Lovely.

Conclusion

Shipping in WooCommerce is a big ball of spaghetti code. Notable questions:

  • why does the code assume that I don’t want to set WC_Customer to whoever I like?
  • why does shipping calculation depend on the WC()->customer object when the package has the customer’s ID in it anyway?
  • why does WC_Customer depend on the session handling? Which is only available on front end?
  • why is session handling only on the front end?
  • why does shipping calculation dependent on the cart? Why can’t I just pass an order into it and see what happens? All relevant information that shipping calculation needs is there: customer, destination, previously chosen shipping method
  • why doesn’t calculate_totals, which calls calculate_shipping and calculate_taxes too, update the line items’s taxes?
  • why does WC_Ajax::calc_line_taxes update the line item’s taxes however?

And suggestions:

  • Shipping should be standalone, not dependent on neither cart, nor sessions, nor the WC()->customer object
  • Shipping should be able to figure out who the customer is from the package
  • tax calculations should not depend on the cart (ajax seems to have solved that)

There will be an addendum to this

Because I keep figuring things out. Stay tuned! And follow me on twitter.

Hire me

I’m up for hire. If you have a store that needs a bit more heavy lifting than usual, get in touch via twitter @javorszky, or via email! You’ll find the address in the footer.

At the moment I am super busy with work and freelance work, and I do not have availability, but can absolutely refer you to folks I trust.