Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use recommended order currency API for multicurrency subscription renewal | switch | resubscribe #4228

Merged
merged 26 commits into from
Jun 29, 2022

Conversation

haszari
Copy link
Contributor

@haszari haszari commented May 4, 2022

Fixes #4152

Changes proposed in this Pull Request

Custom order tables are coming to WooCommerce core.

To prepare for that, we need to move away from using low-level APIs to access post meta for orders.

This PR updates the multicurrency WC Subscriptions integration to use the more robust and clearer get_currency() API. Previously the code used get_post_meta, which is not recommended, and could break in future when stores migrate to using custom order tables.

Testing instructions

This change should not change any behaviour. As I understand it, there's no buggy behaviour here unless the store has migrated to custom tables (which is not yet available/shipped yet).

For my testing, I added logging to confirm the relevant lines of code were run, and logged the currency code (to ensure it was correct). All the flows below trigger the code when preparing an order for cart/checkout.

In all tests, the currency code should be the shopper's currency, not the store default.

The test instructions below are all a very simple happy path. Reviewers please suggest and test alternative flows that might be impacted.

Prerequisite - set up payments, subscription product, and multi-currency
  1. Onboard WooCommerce Payments and install and activate WC Subscriptions.
  2. Publish a subscription product:
  • Go to WooCommerce admin dashboard, click Products > Add New in sidebar.
  • Type a product name e.g. Coffee Subscription.
  • Scroll down to Product data box and select Simple subscription from Product type dropdown.
  • Enter a price e.g. 20 in Subscription price ($) field.
  • Select a renewal term, e.g. every day.
  1. Set up multi currency. Needs to be activated in Woo settings and WCPay, and a currency switcher displayed for customer on store front end.
  • In WooCommerce admin dashboard, go to Payments > Settings.
  • Click Multi-currency tab.
  • Click Add currencies and add one or more additional currencies, e.g. Yen, Euro etc.
  • Scroll down to Store settings.
  • Ensure Add a currency switcher to the Storefront theme on breadcrumb section..
  • Click Save changes.

You should now have a subscription product in your store that shoppers can purchase in their chosen currency.

Test multicurrency subscription renewal
  1. Complete prerequisite setup above.
  2. Purchase a subscription in customer currency (not store default).
  3. Go to subscription in admin dashboard.
  4. Select action Create pending renewal order, click Update.
  5. As customer, go to My account, click Subscriptions, scroll down to Related orders.
  6. Click Pay. Confirm correct (customer) currency is displayed in checkout.
Test multicurrency subscription switch
  1. Complete prerequisite setup above.
  2. In WooCommerce > Settings > Subscriptions, enable both Allow switching options. (See below pic)
  3. Set up subscription products that subscriber can switch between (aka upgrade/downgrade). Easiest way is setting up a variable subscription product with 2 or more levels (e.g. Basic | Pro attribute). Can also switch between grouped products.
    • Click on Products > Add new in WooCommerce admin dashboard sidebar.
    • Type a product name e.g. Coffee Subscription.
    • Scroll down to Product data box and select Variable subscription from Product type dropdown.
    • Click Attributes.
    • Click Add next to Custom product attribute dropdown.
    • Type Level in Name: box.
    • Type Basic | Regular | Pro in Value(s) box.
    • Check Used for variations checkbox.
    • Click Save attributes.
    • Click Variations.
    • Select Create variations from all attributes from dropdown and click Go. Click OK in Are you sure… modal to continue. Click OK in 3 variations added modal.
    • Click disclosure triangle (on right) in first variation and add a price in Subscription price ($) field.
    • Repeat for all variations.
    • Click Save changes.
    • Scroll up to top of page and click Publish on right to publish the variable subscription product.
  4. As shopper, subscribe to one of the variation products.
  5. Go to My account, click Subscriptions.
  6. Click switchable subscription to view details/options.
  7. Click Upgrade or Downgrade. Select a variation to switch to and click Switch subscription. Confirm correct (customer) currency is displayed in checkout.

Screen Shot 2022-06-14 at 11 15 26 AM

Test multicurrency resubscribe
  1. Complete prerequisite setup above.
  2. Purchase a subscription in customer currency (not store default).
  3. Go to subscription in admin dashboard.
  4. Click Cancel for the subscription. Subscription should now be cancelled, so shopper can resubscribe.
  5. As customer, go to My account, click Subscriptions
  6. Click Resubscribe. Refer to docs for details - resubscribing is a kind of renewal.
  7. Confirm correct (customer) currency is displayed in checkout.

  • Run npm run changelog to add a changelog file, choose patch to leave it empty if the change is not significant. You can add multiple changelog files in one PR by running this command a few times.
  • Covered with tests (or have a good reason not to test in description ☝️)
  • Tested on mobile (or does not apply)

Post merge

- previously used raw `get_post_meta()`
- this is no longer recommended, and could break on stores using
(forthcoming) custom order tables
@haszari haszari self-assigned this May 4, 2022
@haszari
Copy link
Contributor Author

haszari commented May 4, 2022

Note: I skipped the checks when committing this. There was a problem with phpcs, I'm not sure if it's related to these changes.

Here's the error:

�[1mFILE: ...cludes/multi-currency/Compatibility/WooCommerceSubscriptions.php�[0m
----------------------------------------------------------------------
�[1mFOUND 1 ERROR AFFECTING 1 LINE�[0m
----------------------------------------------------------------------
 1 | �[31mERROR�[0m | �[1mAn error occurred during processing; checking has been
   |       | aborted. The error message was: trim(): Passing null to
   |       | parameter #1 ($string) of type string is deprecated in
   |       | /Users/rua/_automattic/transact-dev/woocommerce-payments/vendor/wp-coding-standards/wpcs/WordPress/Sniffs/NamingConventions/PrefixAllGlobalsSniff.php
   |       | on line 280�[0m (Internal.Exception)
-------------------------------------------------------------------

@haszari haszari changed the title Use high-level order currency API for multicurrency subscription renewal | switch | resubscribe Use recommended order currency API for multicurrency subscription renewal | switch | resubscribe May 4, 2022
@mattallan
Copy link
Contributor

I skipped the checks when committing this. There was a problem with phpcs, I'm not sure if it's related to these changes.

Here's the error:

Interesting! That error might be caused by your node version. Try running nvm use while in the woocommerce-payments directory

@@ -148,7 +148,7 @@ public function override_selected_currency( $return ) {

$subscription_renewal = $this->cart_contains_renewal();
if ( $subscription_renewal ) {
return get_post_meta( $subscription_renewal['subscription_renewal']['renewal_order_id'], '_order_currency', true );
return $subscription_renewal['subscription_renewal']['renewal_order_id']->get_currency();
Copy link
Contributor

Choose a reason for hiding this comment

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

$subscription_renewal['subscription_renewal']['renewal_order_id'] is an order ID/integer which doesn't have a get_currency() method.

We'll have to get the order object first, i.e.:

$order = wc_get_order( $subscription_renewal['subscription_renewal']['renewal_order_id'] );
return $order ? $order->get_currency() : false;

Copy link
Contributor Author

Choose a reason for hiding this comment

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

D'oh! Of course! Fixed in 20ea6b0

Copy link
Contributor Author

@haszari haszari May 9, 2022

Choose a reason for hiding this comment

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

Note in all these I'm returning $return instead of hard-coded "false". Should be equivalent, but feels more in-keeping with the function comment.

Side note – I don't really understand what this function is doing. The early return when passed $return = [currency] param makes no sense to me; if passing a valid currency code, the function can't "override"?

/**
* Checks to see if the if the selected currency needs to be overridden.
*
* @param mixed $return Default is false, but could be three letter currency code.
*
* @return mixed Three letter currency code or false if not.
*/
public function override_selected_currency( $return ) {
// If it's not false, return it.
if ( $return ) {
return $return;
}

We can dig into that when testing, I'm still figuring out how to test this 🤔

@@ -159,12 +159,12 @@ public function override_selected_currency( $return ) {
$switch_cart_items = $this->get_subscription_switch_cart_items();
if ( 0 < count( $switch_cart_items ) ) {
$switch_cart_item = array_shift( $switch_cart_items );
return get_post_meta( $switch_cart_item['subscription_switch']['subscription_id'], '_order_currency', true );
return $switch_cart_item['subscription_switch']['subscription_id']->get_currency();
Copy link
Contributor

Choose a reason for hiding this comment

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

Similarly for these lines as well, however since we're using the subscription ID we should get a subscription object using:
wcs_get_subscription( $subscription_resubscribe['subscription_resubscribe']['subscription_id'] )

Then call get_currency() on that object 😃

@haszari
Copy link
Contributor Author

haszari commented May 9, 2022

I skipped the checks when committing this. There was a problem with phpcs, I'm not sure if it's related to these changes.

Interesting! That error might be caused by your node version. Try running nvm use while in the woocommerce-payments directory

Just tried that. Now on npm 12, still getting same error. Skipped checks again :)

@brucealdridge brucealdridge self-requested a review May 16, 2022 23:10
@haszari haszari requested a review from a team June 14, 2022 01:37
@haszari
Copy link
Contributor Author

haszari commented Jun 14, 2022

This is ready for another review & test.

Changes since previous reviews:

  1. Testing instructions added (and I've confirmed these do trigger the relevant code).
  2. Fixed an issue with resubscribe case - c433f4e .

Unit tests?

I'm keen to add unit tests - IMO this is a good candidate for unit tests. If we add unit test coverage for all code touched by custom tables changes, we can re-run those tests later with custom table set up (etc). @mattallan @brucealdridge keen to hear your thoughts 💭

Could add unit tests as part of this PR or as follow up.

@haszari
Copy link
Contributor Author

haszari commented Jun 16, 2022

Could add unit tests as part of this PR or as follow up.

We already have tests for this! And one is failing – working on fixing it.

@haszari
Copy link
Contributor Author

haszari commented Jun 16, 2022

I think the unit tests are failing because the new implementation depends on various functions / state that isn't mocked:

  • wc_get_order()
  • $order->get_currency()
  • wcs_get_subscription()

How can I mock those global functions - is this something we do in our unit tests?

Here are the options as I see it…

  1. Mock these functions. I think this is the approach used most often in WCPay tests, keen to hear what people think.
  2. Refactor the code that uses the globals into method(s) on WooCommerceSubscriptions, then mock those methods. That feels like cheating to me, and would undermine the usefulness of the test. Core parts of the logic I'm adding would be fenced off and mocked instead of tested.
  3. Mock up the state/data more holistically - and mock less functions. More of a WordPress test approach, aka integration testing. I'd create and store a real order using regular APIs (in setup/teardown) and link to real renewal and subscription objects as needed for what I want to test.

What's the best approach to get these tests working and providing value long term?

@jrodger
Copy link
Contributor

jrodger commented Jun 16, 2022

And one is failing – working on fixing it.

I'm also seeing 2 tests erroring out due to an infinite loop, but that might just be on my environment. Worth checking once the failing one is fixed though!

I think the unit tests are failing because the new implementation depends on various functions / state that isn't mocked

wc_get_order() and wcs_get_subscription() are examples of global functions that are tricky to mock. For $order->get_currency() depending on the context we can usually treat this as a plain data object, so no need to mock it, just pass around a real order object and methods like this will work fine.

Here are the options as I see it

Agreed, I think this covers all the options!

Mock these functions. I think this is the approach used most often in WCPay tests, keen to hear what people think.

Do you have any examples where that has been done for global functions like wc_get_order()? I've had a quick look and can't find anything. Generally speaking though, for non-global dependencies, we'd look to mock them. I used to refer to this as testing the "outer seam" (i.e. passing parameters in) and the "inner seam" (asserting that dependencies were called as expected). I have stopped using this analogy because I can't find it written down anywhere and I'm worried someone was making a joke at my expense 😂 .

Since these are global functions I wouldn't recommend mocking them, unless we can find some precedence elsewhere in the codebase

Refactor the code that uses the globals into method(s) on WooCommerceSubscriptions, then mock those methods.

You could do this, but I'd suggest instead encapsulating the global functions in a class (or classes) that could be injected into WooCommerceSubscriptions. That helper class could then be mocked. This doesn't feel like cheating to me, you're just splitting up different responsibilities into different classes. Having said that, I can't find an example of us doing this.

The closest I can find is this call to process_payment_for_order where we gather everything we need from global functions and pass them into this unit testable method as parameters. It's almost the inverse of adding methods to make the global calls, but achieves the same aim.

Mock up the state/data more holistically - and mock less functions.

IMO this is the best approach here, and it matches what's being done in the unit test already. Instead of creating post meta data we'd just create an order. We're using a helper method to create test orders in places like this. Do you see any unique challenges to this approach when it comes to subscriptions? Perhaps extending that WooCommerce Core helper and adding some methods for creating subscription orders would be an option?

You're correct when you say this is more of an integration test, while I'd like to see us breaking more of this plugin up into unit testable chunks, WordPress wasn't written with unit testing in mind so it's perfectly acceptable to follow a more integration testy approach IMO.

Rua Haszard added 2 commits June 17, 2022 12:19
- Make a real order with non-default currency - integration test style.
- Enhance mock_wcs_cart_contains_renewal so test can customise
  product and renewal order ids.
- Mock the real order instead of hacking post meta (legacy implementation detail).
- Fix up all uses of mock_wcs_cart_contains_renewal() for new signature.
- Rename / update comment to clarify test scope.
- Mock up a real order (aka subscription) with custom currency.
- Mock that order idea in $_GET request params.
@haszari
Copy link
Contributor Author

haszari commented Jun 17, 2022

[recommended unit testing approach]
Mock up the state/data more holistically - and mock less functions.
create test orders
aka integration testing / WP testing

Update! I've fixed the first two tests to use a real order, and tweaked mock_wcs_cart_contains_renewal() to allow use of real order (and updated tests that use it).

These two tests are now working:

  • test_override_selected_currency_return_currency_code_when_renewal_in_cart()
  • test_override_selected_currency_return_currency_code_for_switch_request() (renamed)

Next steps

I'm still working on the others. To get them working I need to mock up more cart state, e.g. add a switch item to the cart to test this code:

$switch_cart_items = $this->get_subscription_switch_cart_items();
if ( 0 < count( $switch_cart_items ) ) {
$switch_cart_item = array_shift( $switch_cart_items );
$switch_subscription = wcs_get_subscription( $switch_cart_item['subscription_switch']['subscription_id'] );
return $switch_subscription ? $switch_subscription->get_currency() : $return;
}

@mattallan can you point me to WCS APIs for setting up a switch cart item?

// get_subscription_switch_cart_items() is a wrapper for wcs_get_order_type_cart_items()
// How does a `switch` cart item get added to cart – can I call those functions in my test to set up the relevant state?
wcs_get_order_type_cart_items( 'switch' );

@mattallan
Copy link
Contributor

can you point me to WCS APIs for setting up a switch cart item?

We don't have a simple API to setup a switch cart item in a unit test environment, however, I would take a look at WC_Subscriptions_Switcher::set_switch_details_in_cart() to see how we do it.

Here's is the cart item data we use to determine whether an item is a switch:

$cart_item['subscription_switch'] = array(
	'subscription_id'        => $subscription->get_id(),
	'item_id'                => absint( $_GET['item'] ),
	'next_payment_timestamp' => $next_payment_timestamp,
	'upgraded_or_downgraded' => '',
);

You could just manually set this data to mock a switch cart item.

Alternatively, you could port over something like this WC Subscriptions unit test helper (get_switch_cart_item_instance() which we use to get a switch cart item object

Rua Haszard added 2 commits June 23, 2022 14:29
Rua Haszard added 2 commits June 23, 2022 14:41
- parameterise wcs_get_order_type_cart_items so can use real order id
- mock sub (order) and wcs_get_subscription to return mocked sub
- it's fine right
@haszari
Copy link
Contributor Author

haszari commented Jun 23, 2022

Unit tests are now passing

  • I used WC_Order to simulate a subscription. This means we don't need to mock WC_Subscription->get_currency().
  • Other than that, I'm using the existing mocks to mock up what the test expects the tested function to call (i.e. APIs).
  • I parameterised various mock utils so I could pass in a "real" subscription ID, e.g. see mock_wcs_get_order_type_cart_items().

Rua Haszard added 2 commits June 23, 2022 14:54
- ERROR: UndefinedFunction
$switch_subscription = wcs_get_subscription( $switch_cart_item['subscription_switch']['subscription_id'] );
@haszari
Copy link
Contributor Author

haszari commented Jun 23, 2022

I think I'm getting psalm errors now, due to using wcs_get_subscription. Example error:

152
ERROR: UndefinedFunction - �
[153](https://github.com/Automattic/woocommerce-payments/runs/7016129242?check_suite_focus=true#step:5:154)
			$switch_subscription = wcs_get_subscription( $switch_cart_item['subscription_switch']['subscription_id'] );
[154](https://github.com/Automattic/woocommerce-payments/runs/7016129242?check_suite_focus=true#step:5:155)
[155](https://github.com/Automattic/woocommerce-payments/runs/7016129242?check_suite_focus=true#step:5:156)
[156](https://github.com/Automattic/woocommerce-payments/runs/7016129242?check_suite_focus=true#step:5:157)
ERROR: UndefinedFunction - �
[157](https://github.com/Automattic/woocommerce-payments/runs/7016129242?check_suite_focus=true#step:5:158)
			$switch_subscription = wcs_get_subscription( $subscription_resubscribe['subscription_resubscribe']['subscription_id'] );
[158](https://github.com/Automattic/woocommerce-payments/runs/7016129242?check_suite_focus=true#step:5:159)

Added a wrapper method here 1dda55e but that seems to break the tests?

There were 2 errors:

1) WCPay_Multi_Currency_WooCommerceSubscriptions_Tests::test_override_selected_currency_return_currency_code_when_switch_in_cart
TypeError: Return value of WCPay\MultiCurrency\Compatibility\WooCommerceSubscriptions::get_subscription() must be of the type array, object returned

/var/www/html/wp-content/plugins/woocommerce-payments/includes/multi-currency/Compatibility/WooCommerceSubscriptions.php:309
/var/www/html/wp-content/plugins/woocommerce-payments/includes/multi-currency/Compatibility/WooCommerceSubscriptions.php:163
/var/www/html/wp-content/plugins/woocommerce-payments/tests/unit/multi-currency/compatibility/test-class-woocommerce-subscriptions.php:323
phpvfscomposer:///var/www/html/wp-content/plugins/woocommerce-payments/vendor/phpunit/phpunit/phpunit:97

2) WCPay_Multi_Currency_WooCommerceSubscriptions_Tests::test_override_selected_currency_return_currency_code_when_resubscribe_in_cart
TypeError: Return value of WCPay\MultiCurrency\Compatibility\WooCommerceSubscriptions::get_subscription() must be of the type array, object returned

/var/www/html/wp-content/plugins/woocommerce-payments/includes/multi-currency/Compatibility/WooCommerceSubscriptions.php:309
/var/www/html/wp-content/plugins/woocommerce-payments/includes/multi-currency/Compatibility/WooCommerceSubscriptions.php:169
/var/www/html/wp-content/plugins/woocommerce-payments/tests/unit/multi-currency/compatibility/test-class-woocommerce-subscriptions.php:348
phpvfscomposer:///var/www/html/wp-content/plugins/woocommerce-payments/vendor/phpunit/phpunit/phpunit:97

Getting closer!

@haszari
Copy link
Contributor Author

haszari commented Jun 27, 2022

Checks have passed! Ready for a final review @mattallan and/or @Jinksi - this has had one review but the test code has not been reviewed.

@haszari
Copy link
Contributor Author

haszari commented Jun 28, 2022

Testing instructions added here: Test multicurrency subscription renewal, switch and re-subscribe

FYI @Jinksi - slightly more detailed test instructions for your reviewing pleasure 😁

FYI @mattallan - can you check over these instructions to make sure they are effective for testing these changes, and complete enough for GlobalStep (i.e. did I miss a step!) - thanks 🙌

@Jinksi Jinksi self-requested a review June 28, 2022 05:07
Copy link
Member

@Jinksi Jinksi left a comment

Choose a reason for hiding this comment

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

This LGTM @haszari 🎉 🙌

Unit tests pass and cover changes to the override_selected_currency() function.

I followed the release testing instructions and found it all to be working as expected:

  • Prerequisites
  • Test multicurrency subscription renewal
  • Test multicurrency subscription switch
  • Test multicurrency resubscribe

Note there was a discrepancy in Test multicurrency subscription switch step 7, and the Resubscribe button was not available to click. I suggest this change:

- 7. Click `Resubscribe`. Confirm correct (customer) currency is displayed in checkout.
+ 7. Click `Upgrade or Downgrade`. Select a variation to switch to and click `Switch subscription`. Confirm correct (customer) currency is displayed in checkout.

@haszari
Copy link
Contributor Author

haszari commented Jun 29, 2022

Thanks for the thorough & helpful review & testing @Jinksi !

Note there was a discrepancy in Test multicurrency subscription switch step 7, and the Resubscribe button was not available to click. I suggest this change:

I've made that change and also fleshed out details for creating a switchable product (variable subscription product.

…rrency-order-meta-api' into fix/4152-subscribe-renew-multicurrency-order-meta-api
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Update code calling get_post_meta() with an Order IDs and use CRUD methods instead
4 participants