Fact: In Magento, an order cannot be edited after the invoice is created. It can only be canceled and then a new order can be created, which is based on that previous order and the new modifications. But, in that case, a new "order_id" is created.
Challenge: Some clients that work with third party ERPs want to keep the order id and just edit that particular order. Easier said than done.
I have started working on this for quite some time now and it wasn’t easy for me to complete the whole puzzle from the beginning. I hope that sharing my experience will make at least your life a bit easier.
So, where do we start?
Quote vs. Order database tables
In Magento, we have a quote object that, after the order is placed, it’s transformed into an order object. You would have thought that the tables in the database responsible for the quote will be the same for the order, but it’s not that accurate.
So, for the Quote we have :
and for the Order we have:
(or how to break consistency :D)
Let’s focus on the address tables. Quote has 2 address tables: "address" and "address_item", while Order has only the address table.
Moving forward, we can see that "quote_address" has a bunch of other fields that "order_address" doesn’t.
While both have an "address_type" populated either with billing or with shipping type, we can also see that "quote_address" has fields like: "subtotal, base_subtotal", "tax_amount, shipping_amount" etc., both on billing and shipping address tables. In other words, Single responsibility broken even in the database layer. An even dumber translation - bye bye S.O.L.I.D.
I don’t think that an address table should be responsible for holding info about totals. And half of the table has null entries because there are billing addresses that don’t need these values.
Moving forward and getting our heads out of the theory part of data normalization, allow me to indulge myself with a short moment of meditation. I imagine the Magento team probably did it this way because they were in a hurry to implement the multiple shipping functionality. Let’s be honest, we’ve all tasted at some point the bitterness of being hassled by a product owner. But as a programmer, my strong belief is that this type of compromises don’t play well in the long term. So programmers out there, here’s my humble advise: stand by your principles and don’t compromise in this way, pretty please.
Updating the Quote
Our journey continues with what happens when a quote is created/updated in the frontend.
On the Quote model, "Mage_Sales_Model_Quote", we can see that it has a "collectTotals()" method, which first resets the address totals, then goes through each address and collects all the address model totals like: "Mage_Sales_Quote_Address_Total_(Nominal, subtotal, shipping, subtotal etc.)", which can be found in "Mage_Sales/etc. config.xml" under sales/quote/totals/ nodes.
If you ask me, it’s not very intuitive to create all the Quote totals in the "address->collectTotals()" method, which is the core method that propagates all the totals in the Quote object and in its related objects.
Updating the Order/ Order edit
If we want to update/edit an order and also to play by the “when in Rome, do what Romans do” rule, just to be a little consistent, we will have to create a "collectTotal()" method on the "Mage_Sales_Order_Address", which will reiterate all the total Models to keep everything in the right place.
But first, we have to collect all the data, right? This is what we have to do:
rewrite the "admin.html" view responsible with the order, located in "template/sales/order/view/info.phtml".
provide an input for each edited element in each area (Account Information, Billing Address, Shipping Address, Shipping Rates, Items).
Here, we will need the power of a front-end developer to master this template, or otherwise, we will end up doing a jquery spaghetti code there.
Additionally, some pieces of advice from somebody that struggled all alone in the dark, dark night :D:
(First, grab a mug of coffee, you’re not even halfway through this article)
Be careful with the custom options when editing the order items. I have provided form fields to edit only for type text and type dropdown renderers. Here, I suggest that you cover only the one that you use, not all the renderers.
Don’t forget to choose a big "SaveOrderEdit" button for the new form that you put in the "infoedit.phtml" template, which replaced "info.phtml".
After collecting all the data, we then have to create an "OrderEditController", which will handle the "editAction". In this method, we will trigger a "Mage_Sales_Model_Order" method - let’s name it "edit()".
When we hit the "SaveOrderEdit" button, we’ll get into the controller that we mentioned above and we’ll eventually reach the "edit()" method in the "Mage_Sales_Model_Order". Here, we are going to have all the new fields gathered from the "infoedit.phtml". You can group them however you like it. I suggest you take each area and create a model for it (AccountInfo, BillingAddress, ShippingAddress, ShippingMethod, Items) and you can also implement an "editOrderInterface" with a "collect()" method contract in each of the above models.
Now, in the "edit()" method from the new "Mage_Sales_Model_Order" you for each these models and hit the "collect()" method that I just mentioned above. There you update each of the order objects and you can add them as "addRelatedObject()" for the order. The order model has this feature and what it does is to add these objects and save them on order->save().
- (Coffee break)
- We have collected the data.
- We have all the models which will handle the new info
- "collectTotals"- overwrite the "Mage_Sales_Model_Order_Address" to have a "collectTotals()" method. (You can choose to do this in a more intuitive Model - I only chose to collect the totals in the address model to keep a consistency in the Magento way, but you don’t really need to be as nice as me :D).
Apart from working with what Magento provides, I also had to add three new totalModels for the shipments, creditmemo and invoices, and that solved their totals as well.
"Warning! The flows described below only applied to the business logic I had to implement and you might have to create your own magic. :D"
The business logic in the order edit flow in my case was:
if an "orderEdit" triggers a "grandTotal" bigger than the previous one, a new invoice is created; its status will be Pending, as the invoice is not yet paid;
if an "orderEdit" triggers a "grandTotal" smaller than the previous one, a creditmemo is created.
Let’s get into business logic details for each of the two cases.
The "orderEdit" implementation meets one of the payment gateway types:
one which allows online capture (payment):
Capture online in the admin panel, if the payment provider has an "OrderByPhone" policy (Authorize.net);
Capture online in the My Account section of the customer (PayPal);
one which doesn’t provide this option: silent Capture (which only resolves the order totals; the capturing itself is done through a third-party tool).
The "orderEdit" implementation meets one of the payment gateway types:
one which allows online refund:
automatically refunds the money;
creditmemo in status Refund;
creates new payment transaction with Refund status (be careful here to see if the payment gateway provider has a partially refund feature);
one which doesn’t provide this option:
creates a creditmemo in status Pending;
adds a Refund button on the creditmemos, which mimes the fact that the creditmemo was refunded (it only solves the refunded totals on that order, without actually refunding the amount).
Well, this is the main idea. When you do it at the code level, you will reach a small dose of insanity due to the big number of fields that need to be updated on each object that works with the order object. You’ll have to work with fields like "base_total_invoiced_cost", "base_total_paid", "store_to_base_rate", "shipping_tax_refunded" and you don’t have a clue what they do, when they are updated and with what purpose.
In the beginning, I started to look for each of them to see where they were used. Eight hours of debugging later, I started wondering if I was really going to use this info at all and I was trying to bypass this madness.
As a solution, I managed to keep a sane overview by comparing the same info gathered through two different types of actions: order edit and checkout (they both create a reliable state of the order). So, what I did was to play with two orders - the last two in the database order desc by the "entity_id":
one was a regular order, with a certain amount of products and certain billing, shipping and payment info;
the other one edited by me, which at the end had to contain the exact same info as the simple initially created order.
This helped me run some queries, such as the following:
SELECT entity_id, total_qty_ordered, base_total_qty_ordered, subtotal, grand_total, base_total_paid, total_paid, subtotal_incl_tax, base_subtotal, total_due, base_subtotal_incl_tax, base_subtotal, base_total_due, base_grand_total, base_shipping_refunded, base_subtotal_refunded, base_tax_amount , base_total_online_refunded, base_total_refunded, subtotal_refunded, tax_amount, total_online_refunded, total_refunded, base_shipping_amount, base_shipping_invoiced, base_shipping_tax_amount, base_shipping_tax_refunded, base_shipping_refunded, shipping_amount, shipping_invoiced, shipping_refunded, updated_at FROM sales_flat_order ORDER BY entity_id DESC LIMIT 0, 2 SELECT item_id, order_id, qty_ordered, qty_invoiced,sku, price, base_price, tax_amount, tax_percent, base_tax_amount, tax_invoiced, base_tax_invoiced, row_total, base_row_total, row_invoiced, base_row_invoiced, row_total_incl_tax, base_row_total_incl_tax, price_incl_tax, base_price_incl_tax FROM sales_flat_order_item ORDER BY order_id DESC LIMIT 0, 2 SELECT entity_id, total_qty, order_id, base_grand_total, tax_amount, base_tax_amount, grand_total, subtotal_incl_tax, total_qty, subtotal, base_subtotal, shipping_amount, base_shipping_amount, shipping_incl_tax, base_shipping_incl_tax FROM sales_flat_invoice ORDER BY entity_id DESC LIMIT 0, 2 SELECT base_price, qty, tax_amount, base_row_total, row_total, price_incl_tax, base_tax_amount, price, base_row_total_incl_tax, row_total_incl_tax, order_item_id FROM sales_flat_invoice_item ORDER BY entity_id DESC LIMIT 0,2 SELECT total_weight, total_qty, order_id, base_total_value, total_value, base_tax_amount, base_total_value, total_value, base_shipping_amount, shipping_amount, base_tax_amount, udropship_method FROM sales_flat_shipment ORDER BY order_id DESC LIMIT 0, 2 SELECT order_item_id, qty, price, weight, order_item_id, product_id FROM sales_flat_shipment_item ORDER BY parent_id DESC LIMIT 0, 2 SELECT order_id, grand_total, base_grand_total, shipping_amount, base_shipping_amount, subtotal, base_subtotal, email_sent, adjustment_positive, adjustment, base_adjustment_positive FROM sales_flat_creditmemo ORDER BY entity_id DESC LIMIT 0,2
And just check in your favorite mysql tool if they match. If not, patch it up in the appropriate model.
In the end, after the edit is done and the collect totals is finished, we just save the order. When this is done, the related objects will be saved as well. Don’t forget to add a history comment with each "orderEdit" to specify which field has been edited, as well as from what value to what value and by whom. Just to have a clean history in the "orderView".
Easy. Right? :)