Skip to content

Conversation

@Konamiman
Copy link
Contributor

@Konamiman Konamiman commented Sep 25, 2024

Changes proposed in this Pull Request:

This pull request is the beginning of the implementation of a Cost of Goods Sold feature in WooCommerce, it introduces the code and REST API changes to support programmatically setting and getting one single cost value for any product.

Changes in code API

  • A new feature is incorporated (handled by FeaturesController): cost_of_goods_sold
  • The following methods are added to the WC_Abstract_Legacy_Product class:
    • set_cogs_value( float $value ): void
    • get_cogs_value(): float
    • get_cogs_effective_value(): float
    • get_cogs_total_value(): float
  • The following methods are added (additionally to the ones above) to the WC_Product_Variation class:
    • get_cogs_value_overrides_parent(): bool
    • set_cogs_value_overrides_parent( bool $value )
  • The WC_Product_Data_Store_CPT and WC_Product_Variation_Data_Store_CPT get the modifications needed to persist the new values (see below).

Currently get_cogs_value, get_cogs_effective_value and get_cogs_total_value return all the same value, with one exception: in products that are variations, get_cogs_effective_value and get_cogs_total_value return the stored value for the variation added to the stored value for the parent product, unless set_cogs_value_overrides_parent(true) was executed.

Storage

The value set by set_cogs_value is stored as product meta using the _cogs_total_value key, but only if the feature is enabled and the value is not zero (a missing meta value for a product will be treated as equivalent to a value of zero).

The value set by set_cogs_value_overrides_parent is stored as product meta using the _cogs_value_overrides_parent key, but only if the feature is enabled and the value is true (a missing meta value for a product will be treated as equivalent to a value of false).

Changes in REST API

The following is added to the REST API schema for products and variations (/wp-json/wc/v3/products/<product id> and /wp-json/wc/v3/products/<product id>/variations endpoints, for both GET and POST requests):

{
    "cost_of_goods_sold": {
        "values": [
            {
                "defined_value": <float>
                "effective_value": <float>
            }
        ],
        "total_value": <float>,
        "defined_value_overrides_parent": <bool>
    }
}

defined_value_overrides_parent is present only for variations. effective_value and total_value are read-only fields.

In principle, for POST endpoints only one object with values is expected to be found inside values. However the endpoints code will add upp the defined_valuess of all the objects if more than one is supplied, and the result will be the value set for the product.

The POST endpoints will not modify the current value of a product being modified if no values key (or no cost_of_goods_sold key whatsoever) is present, but an empty values array will set a value of zero. Similarly, for variations the "value overrides parent" flag will not be modified if no defined_value_overrides_parent is present. Default values for new products are a cost of zero and no override for variations.

Hooks

The following filters are introduced:

  • woocommerce_get_cogs_total_value: to customize the value that gets returned by get_cogs_total_value.
  • woocommerce_load_cogs_value: to modify the value that gets loaded from the database for a given product.
  • woocommerce_load_cogs_overrides_parent_value_flag: same as above but for the value override flag.
  • woocommerce_save_cogs_value: to modify the value that gets saved to the database for a given product, or to suppress the value saving altogether.
  • woocommerce_save_cogs_overrides_parent_value_flag: same as above but for the value override flag.

How to test the changes in this Pull Request:

Create a variable product with at least one variation. Let's assume their ids are $product_id and $variation_id.

You also need to enable the feature. Go to WooCommerce - Settings - Advanced - Features, check the box for "Cost of Goods Sold" and save.

Code API changes

For your convenience it's recommended to test these steps from a wp shell.

  1. Load the product and verify that it has a COGS value of zero, then modify the value and verify that you can retrieve it:
$p = wc_get_product($product_id);

echo $p->get_cogs_value(); // 0
echo $p->get_cogs_effective_value(); //0
echo $p->get_cogs_total_value(); //0

$p->set_cogs_value(12.34);

echo $p->get_cogs_value(); // 12.34
echo $p->get_cogs_effective_value(); //12.34
echo $p->get_cogs_total_value(); //12.34
  1. Save the product and verify that the value is persisted in the database:
$p->save();

$p2 = wc_get_product($product_id);
echo $p2->get_cogs_value(); // 12.34

echo get_post_meta($product_id, '_cogs_total_value', true); // 12.34
  1. Set the value as zero, save again and verify that the meta value has disappeared from the database:
$p->set_cogs_value(0);
$p->save();

global $wpdb; echo $wpdb->get_var("SELECT COUNT(1) FROM {$wpdb->postmeta} WHERE meta_key='_cogs_total_value' AND post_id=$product_id"); // 0

// Restore state for the next test
$p->set_cogs_value(12.34);
$p->save();
  1. Repeat the above steps using $variation_id instead of $product_id and using a value of 20.5 instead of 12.34. The results should be the same except that the effective and total values will have the parent value added:
$v=wc_get_product($variation_id);

$v->set_cogs_value(20.5);

echo $v->get_cogs_value(); // 20.5
echo $v->get_cogs_effective_value(); // 32.84
echo $v->get_cogs_total_value(); // 32.84
  1. Set the "overrides parent" to true and verify that now the effective and total values are equal to the defined value. Also save the product and verify that the flag gets saved as expected:
echo $v->get_cogs_value_overrides_parent(); // false
$v->set_cogs_value_overrides_parent(true);
echo $v->get_cogs_value_overrides_parent(); // true

echo $v->get_cogs_value(); // 20.5
echo $v->get_cogs_effective_value(); // 20.5
echo $v->get_cogs_total_value(); // 20.5

$v->save();

echo get_post_meta($variation_id, '_cogs_value_overrides_parent', true); // 'yes'
  1. Set the flag to false again, save the variation, and verify that the meta value has disappeared from the database:
$v->set_cogs_value_overrides_parent(false);
$v->save();

global $wpdb; echo $wpdb->get_var("SELECT COUNT(1) FROM {$wpdb->postmeta} WHERE meta_key='_cogs_value_overrides_parent' AND post_id=$variation_id"); // 0

Testing the REST API

Perform GET requests to the /wp-json/wc/v3/products/<product id>, /wp-json/wc/v3/products/<variation id> and /wp-json/wc/v3/products/<product id>/variations/<variation id> and verify that you get the extra cost_of_goods_sold item with the appropriate information. For the variation test with and without the "overrides parent" flag set.

Now try a POST request to /wp-json/wc/v3/products/<product id> with the following body, and verify (using the code API, the GET endpoint, or querying the database) that the related information is updated accordingly (the final value should be 32.84):

{
    "cost_of_goods_sold": {
        "values": [
            {
                "defined_value": 12.34
            },
            {
                "defined_value": 20.5
            }
        ]
    }
}

Now try two POST request to /wp-json/wc/v3/products/<product id>/variations/<variation id> with the following bodies, verify that in each case only the appropriate information (value for the first, overrides flag for the second) is updated:

{
    "cost_of_goods_sold": {
        "values": [
            {
                "defined_value": 111
            }
        ]
    }
}
{
    "cost_of_goods_sold": {
         "defined_value_overrides_parent": <true or false>
    }
}

Now try the following body in a POST request to either the product or the variation endpoint, and verify that the cost value is set to zero:

{
    "cost_of_goods_sold": {
        "values": []
    }
}

Finally, try OPTIONS requests on the endpoints 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:

// Set a value and save. The stored value is unchanged, value returned by get_cogs_value is what you return here.
add_filter('woocommerce_load_cogs_value', function($cogs_value, $product) {
	return $cogs_value;
}, 10, 2);

// Same as above, but for the overrides flag in a variation.
add_filter('woocommerce_load_cogs_overrides_parent_value_flag', function($cogs_value_overrides_parent, $product) {
	return $cogs_value_overrides_parent;
}, 10, 2);

// Set a 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_cogs_value', function($cogs_value, $product) {
	return $cogs_value;
}, 10, 2);

// Same as above, but for the overrides flag in a variation (and you can't return -1).
add_filter('woocommerce_save_cogs_overrides_parent_value_flag', function($cogs_value_overrides_parent, $product) {
	return $cogs_value_overrides_parent;
}, 10, 2);

// What you return here is what gets returned by get_cogs_total_value.
add_filter('woocommerce_get_cogs_total_value', function($cogs_value, $product) {
	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 product objects, but values aren't loaded from, nor persisted to the database (and you get "doing it wrong" errors).
  • Products retrieved via REST API don't contain a cost_of_goods_sold key.
  • A cost_of_goods_sold key passed to a POST REST API request for a product will be ignored (it will not cause any change in the product).
  • The OPTIONS requests don't include a cost_of_goods_sold key in the returned schema.

Note that disabling the feature won't delete any product metadata that was created while the feature was enabled.

A note on redundancy and future-proofing

It may seem that having three different cost values (defined, effective, total) is redundant. And it is at the current stage of the project, but these three values are added to support future extension of the feature while keeping backwards compatibility. More precisely, the following additions might be added at a later point:

  • Cost quantity types, so you can e.g. specify a cost as a percent of the product price instead of a fixed monetary value. Then the "effective" cost would be calculated accordingly from the defined value.
  • Cost value types, so you can have more than one value for a given product (e.g. cost of materials and cost of actual manufacturing). Then the total cost would be the sum of the effective values for all the value types.

This is also the reason of the seemingly convoluted design of the cost_of_goods_sold item in the REST API schema (a values array rather than one single value).

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 UI for a non-legacy feature was showing up greyed out
("UI disabled") when the setting that controls the feature
was off ("feature disabled").
…ses.

Also update the data stores appropriately.

COGS values will be stored in a "_cogs_total_value" product meta key,
but only when the value is not zero.

For variations, a flag indicating if the COGS value overrides
the parent value will be stored in a "_cogs_value_overrides_parent"
product meta key, but only when the value is true (stored as "yes").
(get/create/modify products and variations endpoints).
@Konamiman Konamiman self-assigned this Sep 25, 2024
@github-actions github-actions bot added the plugin: woocommerce Issues related to the WooCommerce Core plugin. label Sep 25, 2024
@github-actions
Copy link
Contributor

github-actions bot commented Sep 25, 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.

@moory-se
Copy link
Contributor

moory-se commented Sep 26, 2024

Oh, we are so looking forward to have this in the core.

One small note:

The value set by set_cogs_value is stored as product meta using the _cogs_total_value key, but only if the feature is enabled and the value is not zero.

Zero is a perfectly fine value for COGS - when you are given free items from a vendor you are (at least in sweden) not allowed to keep any other value than 0 in your books. I think you should allow zero here.

@Konamiman
Copy link
Contributor Author

Konamiman commented Sep 27, 2024

The value set by set_cogs_value is stored as product meta using the _cogs_total_value key, but only if the feature is enabled and the value is not zero.

Zero is a perfectly fine value for COGS

Hi, I think I did not explain this correctly. Of course zero will be an allowed value for COGS and in fact it's the value that all products will get by default. What I meant is that when the value is zero, no _cogs_total_value key will be stored in the meta table for the product when saving it; but then, when loading the product again the code will detect that the meta value is missing and will assume a value of zero. This allows to save some database space, and as an added bonus, removes the need to do an initial filling of the meta table with zero values for all the existing products. I have updated the pull request description clarifying this.

@moory-se
Copy link
Contributor

moory-se commented Sep 27, 2024 via email

@Konamiman Konamiman marked this pull request as ready for review September 27, 2024 11:21
@Konamiman Konamiman requested review from a team and naman03malhotra and removed request for a team September 27, 2024 11:25
@Konamiman Konamiman requested review from barryhughes and removed request for naman03malhotra September 27, 2024 11:25
@github-actions
Copy link
Contributor

github-actions bot commented Sep 27, 2024

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.

A note on redundancy and future-proofing [...]

I think I understand, yet it still seems like a complex interface and, at least for me—and I may be the outlier—not immediately intuitive as a result. Are there other possibilities worth exploring?

# I realize `mixed` and enums are both off-the-table right now,
# this is just meant to be illustrative.
set_cogs( mixed $value, Cogs_Method $method = Cogs_Method::FIXED_COST ): void

Or possibly:

# Where type CostOfGoodsSold lets us describe various possibilities.
set_cogs( CostOfGoodsSold $cost_of_goods_sold ): void

To be super clear, I'm not strongly opposed to what we have. Yet, once it enters production, the API will, to a large extent, become frozen ... so it feels like it's worth a little time ensuring it's as comfortable and intuitive as we can make it, in addition to offering the necessary flexibility and future-proofing.

- CogwsAwareTrait extracted from the CogsAwareRestControllerTrait,
  it contains only the cogs_is_enabled method.
- cogs_is_enabled has now a string parameter defaulting to null,
  when it's not null and the feature is disabled, a doing_it_wrong
  will be thrown (using the argument value as the function name).
- All the get/set cogs value methods in the base product class
  now execute cogs_is_enabled with a non-null parameter.
- New get_cogs_effective_value_core method added, consistent with
  get_cogs_total_value_core.
@Konamiman
Copy link
Contributor Author

Are there other possibilities worth exploring?

Actually I was thinking that the method to set the value would evolve like this after the MVP:

function set_cogs_value( float $value, string $value_type = null )

This would be paired with the concept of COGS categories, where a product belonging to a given category would mean that it would have a fixed set (defined per category) of COGS value types. The quantity type of each value type would be fixed (part of the definition of the category). null for $value_type would be allowed only when the product doesn't belong to any category and thus has only one single fixed value (the default for all products and compatible with the current implementation).

Additionally, a set_cogs_values(array $values) (note the plural) method would be added to allow setting more than one value with one single call, and here's where we could add any additional flexibility we want.

once it enters production, the API will, to a large extent, become frozen

Of course, but on the other hand, while the feature is experimental we have a bit more of flexibility to make changes to the public API (and any possible design issue is more likely to surface once code begins to be merged and thus tested in real(ish) scenarios).

@Konamiman Konamiman requested a review from barryhughes October 2, 2024 14:37
@barryhughes
Copy link
Member

while the feature is experimental we have a bit more of flexibility to make changes to the public API

Do we/should we do anything else to make it clear these (public, non-internal) methods may be subject to change? Supplement the 'warning' messages in the docblocks? Not sure if it's necessary, just being cautious.

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.

Left a comment asking how we might make it clear the API is unstable (in the sense, it may be refined before the feature leaves the experimental status), but beyond that it tests per instructions 👍🏼

@Konamiman
Copy link
Contributor Author

Do we/should we do anything else to make it clear these (public, non-internal) methods may be subject to change?

I understand that this is kind of implied by the fact that it's listed in the experimental features list in the features page, with the section described as "These features are either experimental or incomplete". That said, at the very least we should explicitly mention that when the time to publicly announce the feature MVP comes.

@Konamiman Konamiman merged commit cbf98a0 into trunk Oct 4, 2024
@Konamiman Konamiman deleted the cogs/feature-flag-and-code-api branch October 4, 2024 09:57
@github-actions github-actions bot added this to the 9.5.0 milestone Oct 4, 2024
@github-actions github-actions bot added the needs: analysis Indicates if the PR requires a PR testing scrub session. label Oct 4, 2024
@nigeljamesstevenson nigeljamesstevenson 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 16, 2024
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