Skip to content

Conversation

@Konamiman
Copy link
Contributor

@Konamiman Konamiman commented Oct 16, 2024

Changes proposed in this Pull Request:

Following #51675 this pull request adds programmatic and REST API support for the orders and order items related part of the Cost of Goods Sold feature.

Changes in code API

  • The following methods are added to WC_Order_Item:

    • has_cogs(): bool - A boolean that by default returns false. This method must be overridden in derived classes representing line items that manage a COGS value.
    • calculate_cogs_value(): bool - If the calculation succeeds it sets the resulting value with set_cogs_value and returns true.
    • calculate_cogs_value_core(): ?float - It simply throws an exception. This method must be overridden whenever has_cogs returns true.
    • get_cogs_value(): float - Returns the value that was set with set_cogs_value.
    • set_cogs_value(float): void - Marked as @internal, intended for use only by calculate_cogs_value and when loading the object from the database.
  • The following methods are added to WC_Order_Item_Product (overridding the methods in WC_Order_Item):

    • has_cogs(): bool - Returns true.
    • calculate_cogs_value_core(): ?float - Calculates the COGS value by multiplying the product value by the quantity. If the product no longer exists it returns null and then calculate_cogs_value won't change the current value.
  • The following methods are added to WC_Abstract_Order:

    • has_cogs(): bool - Same meaning as in WC_Order_Item.
    • calculate_cogs_total_value(): float - Invokes calculate_cogs_total_value_core and sets the result with set_cogs_total_value.
    • calculate_cogs_total_value_core(): float - Calculates the value by looping through all the order items for which has_cogs returns true, invoking their calculate_cogs_value methods, and adding up the value returned by their get_cogs_value methods.
    • get_cogs_total_value(): float - Returns the value that was set with set_cogs_value.
    • set_cogs_total_value(float): void - Marked as @internal, intended for use only by calculate_cogs_total_value and when loading the object from the database.
  • The following methods are added to WC_Order:

    • has_cogs(): bool - Overridden to always return true.

The order's calculate_cogs_total_value method is invoked in two places: inside calculate_totals in the same object, and in WC_Checkout::create_order.

This design allows to add new line item types and new order types in the future, with or without COGS management.

Storage

The COGS values are stored as follows:

  • For order items: a _cogs_value meta entry in the wp_woocommerce_order_itemmeta table.
  • For orders: a _cogs_total_value meta entry in the wp_wc_orders_meta table.

Important! For now, order meta items are stored only when HPOS is enabled. If support for CPT tables is added (undecided yet), this will be done in a separate pull request.

These values are stored on save (and retrieved on load) only when the Cost of Goods Sold feature is enabled, when the corresponding order or order item's has_cogs method returns true, and when the value to store is not zero.

Changes in REST API

The following is added to the REST API schema for the get order(s) endpoint (/wp-json/wc/v3/orders and /wp-json/wc/v3/orders/<order_id>, GET only):

{
    "cost_of_goods_sold": {
        "total_value": <float>
    },
    "line_items": [
        {
            "cost_of_goods_sold": {
                "value": <float>
            }
        }
    ]
}

These new items appear only when the feature is enabled.

There's no explicit support for altering order and order items COGS values in the REST API, but any request that triggers a order's calculate_totals will cause a recalculation of the COGS values as well.

Hooks

The following filters are introduced:

  • woocommerce_calculated_order_cogs_value: to customize the value that gets calculated for an order before it's set.
  • woocommerce_calculated_order_item_cogs_value: to modify the value that gets calculated for an order item before it's set.
  • woocommerce_load_order_item_cogs_value: to modify the value that gets saved to the database for a given order item, or to suppress the value saving altogether.
  • woocommerce_save_order_item_cogs_value: to modify the value that gets saved to the database for a given order item, or to suppress the value saving altogether.
  • woocommerce_load_order_cogs_value: to modify the value that gets saved to the database for a given order, or to suppress the value saving altogether.
  • woocommerce_save_order_cogs_value: to modify the value that gets saved to the database for a given order, or to suppress the value saving altogether.

This pull request also modifies the following hook names (all added in #51675) to make them less ambiguous:

  • woocommerce_load_cogs_value --> woocommerce_load_product_cogs_value
  • woocommerce_save_cogs_value --> woocommerce_save_product_cogs_value
  • woocommerce_get_cogs_total_value --> woocommerce_get_product_cogs_total_value
  • woocommerce_load_cogs_overrides_parent_value_flag --> woocommerce_load_product_cogs_overrides_parent_value_flag
  • woocommerce_save_cogs_overrides_parent_value_flag --> woocommerce_save_product_cogs_overrides_parent_value_flag

How to test the changes in this Pull Request:

Reference: #51675. As with that other pull request, testing will mostly be done using wp shell.

Preparation

  1. Enable the feature: Go to WooCommerce - Settings - Advanced - Features, check the box for "Cost of Goods Sold" and save.
  2. Enable HPOS if it isn't enabled already: wp wc hpos sync && wp wc hpos enable --ignore-plugin-compatibility

Cost calculation and data storage

  1. Create two products (or you can use already existing products) and take note of their ids, let's assume they are $product_1_id and $product_2_id.
  2. Assign COGS values to them:
$product_1 = wc_get_product($product_1_id);
$product_1->set_cogs_value(11.22);
$product_1->save();

$product_2 = wc_get_product($product_2_id);
$product_2->set_cogs_value(33.44);
$product_2->save();
  1. Create an order and assign two units of product 1 and three units of product 2 to it:
$order = new WC_Order();

$item_1 = new WC_Order_Item_Product();
$item_1->set_product( $product_1 );
$item_1->set_quantity( 2 );
$item_1->save();
$order->add_item( $item_1 );

$item_2 = new WC_Order_Item_Product();
$item_2->set_product( $product_2 );
$item_2->set_quantity( 3 );
$item_2->save();
$order->add_item( $item_2 );

$order->calculate_cogs_total_value();
$order->save();
  1. Verify that the line items and the order have got the expected COGS values, and that these have been saved to the database:
$order2 = wc_get_order($order->get_id());
echo $order2->get_cogs_total_value(); //122.76

$items = array_values($order2->get_items());
echo $items[0]->get_cogs_value(); //22.44
echo $items[1]->get_cogs_value(); //100.32

global $wpdb;
echo $wpdb->get_var("SELECT meta_value FROM {$wpdb->prefix}woocommerce_order_itemmeta WHERE meta_key='_cogs_value' AND order_item_id=" . $items[0]->get_id()); //22.44
echo $wpdb->get_var("SELECT meta_value FROM {$wpdb->prefix}woocommerce_order_itemmeta WHERE meta_key='_cogs_value' AND order_item_id=" . $items[1]->get_id()); //100.32
echo $wpdb->get_var("SELECT meta_value FROM {$wpdb->prefix}wc_orders_meta WHERE meta_key='_cogs_total_value' AND order_id=" . $order->get_id()); //122.76
  1. Change the COGS value of the first product and recalculate totals, verify that the line item and order values get updated:
$product_1->set_cogs_value(99.99);
$product_1->save();

$order3 = wc_get_order($order->get_id());
$order3->calculate_totals();

echo $order3->get_cogs_total_value(); //300.3
$items = array_values($order3->get_items());
echo $items[0]->get_cogs_value(); //199.98
  1. Change the product COGS values to zero, recalculate, and verify that the database entries have disappeared:
$product_1->set_cogs_value(0);
$product_1->save();
$product_2->set_cogs_value(0);
$product_2->save();

$order4 = wc_get_order($order->get_id());
$order4->calculate_totals();
$order4->save();

echo $wpdb->get_var("SELECT COUNT(1) FROM {$wpdb->prefix}woocommerce_order_itemmeta WHERE meta_key='_cogs_value' AND order_item_id=" . $items[0]->get_id()); //0
echo $wpdb->get_var("SELECT COUNT(1) FROM {$wpdb->prefix}woocommerce_order_itemmeta WHERE meta_key='_cogs_value' AND order_item_id=" . $items[0]->get_id()); //0
echo $wpdb->get_var("SELECT COUNT(1) FROM {$wpdb->prefix}wc_orders_meta WHERE meta_key='_cogs_total_value' AND order_id=" . $order4->get_id()); //0
  1. Now try using the UI. Create and modify orders, and verify that the COGS values get created/updated accordingly (using the above commands):
  • Create an order from the shop page, using the same products and quantities.
  • Same, but from the admin area.
  • Add/remove/modify product line items from the order editor. Remember to recalculate totals each time.

Testing the REST API

After setting the product COGS values to the original values (step 4, minus the products creation) and recalculating the order totals again (second block of commands in step 8), perform a GET requests to /wp-json/wc/v3/orders/<order id> and verify that you get the following additions to the returned data:

{
    "cost_of_goods_sold": {
        "total_value": 122.76
    },
    "line_items": [
        {
            "cost_of_goods_sold": {
                "value": 22.44
            }
        },
        {
            "cost_of_goods_sold": {
                "value": 100.32
            }
        }
    ]
}

Try also an OPTIONS requests on the endpoint and verify that the extended schema information returned is accurate.

Testing the filters

Here's scaffolding code to test the filters, you can add it at the end of woocommerce.php or as a code snippet. Try changing the return value of one filter at a time, and verifying via code API or REST API that the result is as expected:

// Caclulate the order value and run $order->get_cogs_total_value(), you'll get what this filter returns.
add_filter('woocommerce_calculated_order_cogs_value', function($cogs_value, $order) {
	return $cogs_value;
}, 10, 2);

// Same as above, but this applies to individual order items.
add_filter('woocommerce_calculated_order_item_cogs_value', function($cogs_value, $order_item) {
	return $cogs_value;
}, 10, 2);

// Caclulate the order value and save, then load the order again, its COGS value will be what this filter returns.
add_filter('woocommerce_load_order_cogs_value', function($cogs_value, $order) {
	return $cogs_value;
}, 10, 2);

// Same as above, but this applies to individual order items.
add_filter('woocommerce_load_order_item_cogs_value', function($cogs_value, $order_item) {
	return $cogs_value;
}, 10, 2);

// Calculate the order value and save. What you return here is what gets saved in the database
// (f you return null nothing gets saved).
add_filter('woocommerce_save_order_cogs_value', function($cogs_value, $order) {
	return $cogs_value;
}, 10, 2);

// Same as above, but this applies to individual order items.
add_filter('woocommerce_save_order_item_cogs_value', function($cogs_value, $order_item) {
	return $cogs_value;
}, 10, 2);

Testing with the feature disabled

Disable the feature in the features settings page and verify that:

  • You can still invoke the new methods in order and order item objects, but values aren't loaded from, nor persisted to the database (and you get "doing it wrong" errors).
  • Orders retrieved via REST API don't contain a cost_of_goods_sold key (and the same for order line items).
  • The REST API OPTIONS requests don't include a cost_of_goods_sold key in the returned schema.

Changelog entry

  • Automatically create a changelog entry from the details below.
  • This Pull Request does not require a changelog entry. (Comment required below)
Changelog Entry Details

Significance

  • Patch
  • Minor
  • Major

Type

  • Fix - Fixes an existing bug
  • Add - Adds functionality
  • Update - Update existing functionality
  • Dev - Development related task
  • Tweak - A minor adjustment to the codebase
  • Performance - Address performance issues
  • Enhancement - Improvement to existing functionality

Message

Changelog Entry Comment

Comment

The previous names were too generic, the renamed hooks are:

woocommerce_get_cogs_total_value -> woocommerce_get_product_cogs_total_value
woocommerce_load_cogs_value -> woocommerce_load_product_cogs_value
woocommerce_save_cogs_value -> woocommerce_save_product_cogs_value

And the renamed methods:

add_cogs_info_to_returned_data -> add_cogs_info_to_returned_product_data
add_cogs_related_schema -> add_cogs_related_product_schema
- WC_Order_Item class gets these methods:
  - has_cogs - defaulting to false, derived classes must override
  - calculate_cogs_value
  - calculate_cogs_value_core - derived classes must override
  - get_cogs_value
  - set_cogs_value - for internal usage only

- WC_Order_Item_Product overrides calculate_cogs_value_core

- Abstract_WC_Order_Item_Type_Data_Store
  - Stores COGS values in a '_cogs_value' order item meta entry
- WC_Abstract_Order gets these methods:
  - has_cogs - defaulting to false, derived classes must override
  - calculate_cogs_total_value
  - calculate_cogs_total_value_core - derived classes can override
  - get_cogs_total_value
  - set_cogs_total_value - for internal usage only
  - calculate_totals - modified to invoke calculate_cogs_total_value

- WC_Order overrides has_cogs to return true

- OrdersTableDataStore
  - Stores COGS values in '_cogs_total_value' order meta keys
- "cost_of_goods_sold" object with "total_value" key for the order
- "cost_of_goods_sold" object with "value" key for the product line items
@github-actions github-actions bot added the plugin: woocommerce Issues related to the WooCommerce Core plugin. label Oct 16, 2024
@github-actions
Copy link
Contributor

github-actions bot commented Oct 16, 2024

Test using WordPress Playground

The changes in this pull request can be previewed and tested using a WordPress Playground instance.
WordPress Playground is an experimental project that creates a full WordPress instance entirely within the browser.

Test this pull request with WordPress Playground.

Note that this URL is valid for 30 days from when this comment was last updated. You can update it by closing/reopening the PR or pushing a new commit.

@Konamiman Konamiman marked this pull request as ready for review October 21, 2024 09:33
@Konamiman Konamiman requested a review from barryhughes October 21, 2024 09:33
@github-actions
Copy link
Contributor

Hi @barryhughes,

Apart from reviewing the code changes, please make sure to review the testing instructions and verify that relevant tests (E2E, Unit, Integration, etc.) have been added or updated as needed.

You can follow this guide to find out what good testing instructions should look like:
https://github.com/woocommerce/woocommerce/wiki/Writing-high-quality-testing-instructions

Copy link
Member

@barryhughes barryhughes left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, this looks good! I do still need to test, but it looks good on initial read. Left some questions and comments, but all are minor.

throw new Exception(
sprintf(
// translators: %1$s = class and method name.
__( 'Method %1$s is not implemented. Classes overriding has_cogs must override this method too.', 'woocommerce' ),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not escape (vs disabling the ExceptionNotEscaped rule)?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This exception signals a "design time" error: if you get it it means that your code is wrong (you did override has_cogs to return true but didn't override calculate_cogs_value_core) and needs fixing. You are never going to get this in production, so it doesn't make much sense to add escaping.

Copy link
Member

@barryhughes barryhughes left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tests great (thanks for the great instructions!). Works as described and, when the feature is disabled, the relevant properties disappear from REST API responses.

I don't have anything else to add: approving but not merging (so you can review some earlier notes I left—though none are blocking, so also feel free to move ahead with a merge) 👍🏼

@barryhughes
Copy link
Member

1) Automattic\WooCommerce\Tests\Internal\DataStores\Orders\OrdersTableDataStoreTests::test_loading_order_loads_cogs_value_if_cogs_enabled with data set #2 (true, true)
Failed asserting that 0.0 matches expected 12.34.

I think we might need to poke at this one; some of the other CI fails seem unrelated (the PHP linting check is just failing to run, which I was hitting on some other PRs this week).

@Konamiman
Copy link
Contributor Author

1) Automattic\WooCommerce\Tests\Internal\DataStores\Orders\OrdersTableDataStoreTests::test_loading_order_loads_cogs_value_if_cogs_enabled with data set #2 (true, true)
Failed asserting that 0.0 matches expected 12.34.

The good news is that what was wrong here was the test itself, not the tested code. It's fixed now.

@barryhughes
Copy link
Member

Re-running checks again; same problem with CI / Lint as before and some other fails (that seem unrelated to the actual change).

@Konamiman Konamiman merged commit 9ac4823 into trunk Oct 29, 2024
24 checks passed
@Konamiman Konamiman deleted the cogs/code-api-for-orders branch October 29, 2024 16:22
@github-actions github-actions bot added this to the 9.5.0 milestone Oct 29, 2024
@github-actions github-actions bot added the needs: analysis Indicates if the PR requires a PR testing scrub session. label Oct 29, 2024
@Stojdza Stojdza added needs: internal testing Indicates if the PR requires further testing conducted by Solaris status: analysis complete Indicates if a PR has been analysed by Solaris and removed needs: analysis Indicates if the PR requires a PR testing scrub session. labels Oct 30, 2024
@moory-se
Copy link
Contributor

moory-se commented Oct 14, 2025

This might the wrong place for this comment, but I'll place it anyway. I think parts of this - namely the option to make order line level cogs read-only, combined with #61432 is the wrong approach. Let me give an example:

  1. Item A is purchased (purchased, not sold) with a item cost at $10 (rendering in product.cogs = 10)
  2. Item A is purchased with an item cost at $12 (rendering in product.cogs = $11, since all ERP store average value - (10+12)/2)
  3. Item A is sold in order X - COGS attached to order is $11 (from product.cogs)
  4. Item A is updated from ERP since only 1 item are now in stock, and the COGS are set to $12 (the first item at $10 is no longer available)
  5. Order X is completed, which re-triggers calculation and sets the order.lines.[0].cogs = $12 (from the product)
  6. Item A is sold in order Y - COGS attached to order is $12 (from product.cogs)

Now, we have 2 orders which COGS totals to $24, while in reality the COGS should totaling to $22.

This happens because ERPs (rightfully) stores averages on product levels and actuals (based on FIFO, most often) on order line level. This means that we need to:

  1. be able to update order line cogs, since they are rarely the same as the product cogs
  2. stop updating on completed, or at least make it optional

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs: internal testing Indicates if the PR requires further testing conducted by Solaris plugin: woocommerce Issues related to the WooCommerce Core plugin. status: analysis complete Indicates if a PR has been analysed by Solaris

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants