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

Disallow entering discounts higher than price #14083

Merged
merged 11 commits into from
Oct 3, 2024

Conversation

iamgabrielma
Copy link
Contributor

@iamgabrielma iamgabrielma commented Oct 1, 2024

Closes: #14080

Description

There's a discrepancy between platforms regarding how we allow discounts to be applied to products, in iOS we allow discounts higher than the product price, while Android does not.

With this PR we get closer to Android by not allowing these, displaying an error message when a merchant attempts to do so, and disallowing the Add button of working until is resolved.

Simulator Screen Recording - iPhone 15 - 2024-10-01 at 11 22 59

There are further UI and logic improvements that can be done in this screen, but I have left them outside this PR to keep a reduced scope (eg: The "remove discount" button appears when there are no discounts saved yet).

Note: There seems to be a lot of unnecessary complexity in these discount-related screens, most likely because we haven't updated the logic from when we stopped handling cases that no longer exist (fees). At some point would be good to separate "discounts" from "legacy fees", and improve the general design of how we apply these.

Testing

  • Go to Orders > + > + Add product > expand the product and tap on + Add discount
  • Observe that if you enter a fixed-type discount higher than the product price:
    • An error message appears
    • The Add button is disabled
    • Dismissing the modal does not save changes
  • Repeat the same for a percentage-type discount and observe the same behaviour if the discount is higher than 100%.
Fixed Percentage
simulator_screenshot_976DD458-DE2C-4C15-9854-74210830F7AC simulator_screenshot_FB0F5C9E-9987-43EB-A02B-5BCADF5DA1E5

  • I have considered if this change warrants user-facing release notes and have added them to RELEASE-NOTES.txt if necessary.

Reviewer (or Author, in the case of optional code reviews):

Please make sure these conditions are met before approving the PR, or request changes if the PR needs improvement:

  • The PR is small and has a clear, single focus, or a valid explanation is provided in the description. If needed, please request to split it into smaller PRs.
  • Ensure Adequate Unit Test Coverage: The changes are reasonably covered by unit tests or an explanation is provided in the PR description.
  • Manual Testing: The author listed all the tests they ran, including smoke tests when needed (e.g., for refactorings). The reviewer confirmed that the PR works as expected on all devices (phone/tablet) and no regressions are added.

Comment on lines 293 to 297
func updateDiscountDisallowedState(_ value: Bool) {
DispatchQueue.main.async {
self.discountValueIsDisallowed = value
}
}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'm not sure how good practice is this, since we generally send the values directly to the publisher, but I don't see a specific problem with it either. Initially I attempted to use combine to observe changes on the price amount and enable/disable appropriately, but that changes the current implementation enough to open a new can of worms in several places.

Copy link
Contributor

Choose a reason for hiding this comment

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

There's nothing wrong with it, but as mentioned above, you shouldn't need it if you do the validation in response to user input, rather than in response to reading the value.

@wpmobilebot
Copy link
Collaborator

wpmobilebot commented Oct 1, 2024

WooCommerce iOS📲 You can test the changes from this Pull Request in WooCommerce iOS by scanning the QR code below to install the corresponding build.

App NameWooCommerce iOS WooCommerce iOS
Build Numberpr14083-1b8c10a
Version20.6
Bundle IDcom.automattic.alpha.woocommerce
Commit1b8c10a
App Center BuildWooCommerce - Prototype Builds #11095
Automatticians: You can use our internal self-serve MC tool to give yourself access to App Center if needed.

@iamgabrielma iamgabrielma added type: task An internally driven task. feature: order details Related to order details. labels Oct 1, 2024
@iamgabrielma iamgabrielma added this to the 20.7 milestone Oct 1, 2024
@iamgabrielma iamgabrielma marked this pull request as ready for review October 1, 2024 05:58
@iamgabrielma iamgabrielma requested a review from joshheald October 1, 2024 07:08
Copy link
Contributor

@joshheald joshheald left a comment

Choose a reason for hiding this comment

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

It tests well... but we could easily make it less complex. See review comments for details, and give me a shout if you want any more detail.

I tested on an iPad running iOS 17.7, no issues related to the different display approach there.

/// Captures state when a discount value should be disallowed, for example,
/// when the discount entered is higher than the total price of a product.
///
@Published private(set) var discountValueIsDisallowed: Bool = false
Copy link
Contributor

Choose a reason for hiding this comment

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

I'd suggest either naming this specific to the reason for why it's disallowed, or making it an enum that explains why with code, rather than a bool.

I think at the moment it's only used for the discount being too high, so let's call it

Suggested change
@Published private(set) var discountValueIsDisallowed: Bool = false
@Published private(set) var discountExceedsProductPrice: Bool = 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.

Updated! 1771281

Comment on lines 154 to 158
if priceAfterDiscount.compare(NSDecimalNumber.zero) == .orderedAscending {
updateDiscountDisallowedState(true)
return currencyFormatter.formatAmount(priceAfterDiscount) ?? ""
}
updateDiscountDisallowedState(false)
Copy link
Contributor

Choose a reason for hiding this comment

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

Inside a function named calculatePriceAfterDiscount, I'd consider this a side-effect.

Since calculatePriceAfterDiscount is used directly from a view for display, it really ought to be a var, ideally a @Published var, but at least a computed. I know that's pre-existing, but we should avoid adding a side effect here.

It's potentially confusing that we check whether the input is valid as a response to displaying the discount, rather than as a response to the user entering the discount.

A better trigger might be FeeOrDiscountLineDetailsViewModel.updateAmount(_:) and updatePercentage, which happens when the onChangeText for the input field is called. This would also remove the need for the async block too.

If you move it there, you should probably rename the functions to avoid it being a side effect, e.g. amountTextChanged and percentageTextChanged, but it's much more of a nitpick at that point.

A complicating factor is that the base price is currently passed in to FeeOrDiscountLineDetailsViewModel from the view, as an argument to calculatePriceAfterDiscount, which is weird – but it's also passed in when the view model is created, as baseAmountForPercentage. That's just the base price, with a re-name, I see no reason that we can't use it for calculating an amount discount as well.

If you do all of that, you can have a much simpler function to check the validity:

    func checkDiscountValidity() {
        let priceAfterDiscount = baseAmount - finalAmountDecimal

        discountValueIsDisallowed = priceAfterDiscount < 0
    }

Then just call that from your text changed functions.

You're working with some unnecessarily convoluted code here, but I think the PR as it stands makes it more complex, and we don't need to do that.

I've only had a fairly short poke at this, but it appears to work. If I'm missing some detail, apologies and just let me know!

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks for the guidance! Appreciated 💟

I had three main issues here that made it trickier-than-expected:

  1. The -unnecessary- complexity of it all
  2. Being forced to use a function with the price property passed in, directly from the view, for the updates.
  3. I was unable to see where updateAmount or updatePercentage were called. Using both "Show Callers" and the "Call Hierarchy" showed nothing, I see now that this was because these are not directly invoked, but passed through the closure via .onChange(), most likely to facilitate decoupling, so we do not need to pass a viewmodel in, but this made it harder to follow its actual usage.

By removing the constraint of (2) as you mentioned, and just using the value we pass in the initializer everything became much simpler, and the tests have become also more understandable now: We pass an initial amount, we update the amount with a discount, and we assert if that's allowed or not.

Main update was done on 1771281 , I've also updated the property name on 9dda8ea , and made it a computed var on 6e3c39b

I think there's still some oddities to be addressed in this feature (like how much we need a separation between baseAmount and initialTotal, or if we still need lineType at all) but that's for another day :D

Comment on lines 293 to 297
func updateDiscountDisallowedState(_ value: Bool) {
DispatchQueue.main.async {
self.discountValueIsDisallowed = value
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

There's nothing wrong with it, but as mentioned above, you shouldn't need it if you do the validation in response to user input, rather than in response to reading the value.

By using the existing `baseAmountForPercentage` that we pass to the VM in the init, we can remove the requirement to pass the `price` to the function from the view, which simplifies both the calculation of the discount, as well as validating when a discount shouldn’t be allowed.
@iamgabrielma iamgabrielma requested a review from joshheald October 2, 2024 04:17
}
let discount = NSDecimalNumber(decimal: finalAmountDecimal)
let priceAfterDiscount = price.subtracting(discount)
var recalculateFormattedPriceAfterDiscount: String {
Copy link
Contributor

@joshheald joshheald Oct 2, 2024

Choose a reason for hiding this comment

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

Suggested change
var recalculateFormattedPriceAfterDiscount: String {
var formattedPriceAfterDiscount: String {

Strange to have a verb in a property name, it makes it seem like a function

Copy link
Contributor Author

Choose a reason for hiding this comment

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

You're right, updated: 795fcff

Copy link
Contributor

@joshheald joshheald left a comment

Choose a reason for hiding this comment

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

Thanks for the changes, the code looks a lot better. I've suggested a rename in comments.

There is a bug I've found, minor but worth a quick check while you have context – if you enter an invalid discount, switch to the other type ($/%), enter a valid discount there, then switch back to the first type, it will let you add the previously-entered invalid discount.

Repro

  • Create an order and add a product to it
  • Expand the product and enter a discount greater than its price
  • Change to % discount – observe that the validation message is not cleared, even though the discount shows as $0
  • Type any percentage <= 100% – observe that the validation message is cleared
  • Change to $ discount – observe that the previous amount is still there
  • Tap Add
  • You have just discounted by more than the price.

This is happening because we only check the validity when we type something – we could perhaps also do that check when we change the type.

Discount validity is only checked when the input field changes, but is not checked if a merchant happens to switch between discount types (fixed vs percentual), which can lead to invalid state when a discount is modified in one view, and then they switch type, as discount validity won’t be checked again until the input is updated.
@dangermattic
Copy link
Collaborator

1 Warning
⚠️ This PR is assigned to the milestone 20.7. This milestone is due in less than 2 days.
Please make sure to get it merged by then or assign it to a milestone with a later deadline.

Generated by 🚫 Danger

@iamgabrielma
Copy link
Contributor Author

This is happening because we only check the validity when we type something – we could perhaps also do that check when we change the type.

Good catch, you're right on: when we switch between fixed and percentage discounts we recreate a new view instance of LineDetailView, This will only check for discount validity once the input field has been modified, so it won't happen immediately after a merchant has switched between discount types, since at that point onChange(:) hasn't been called because nothing has been edited.

I've initially tried to fix it in the view model when we perform the discount line type switch, but this ends crashing the app due what seems to be a massive call stack to the computed variable from view recreation. Instead I've just added a call for discount validity check when the view appears, which would happen only once each time we switch between discount types. Fixed here: b94b567

Simulator Screen Recording - iPhone 15 - 2024-10-03 at 10 27 59

@iamgabrielma iamgabrielma merged commit 2a8925b into trunk Oct 3, 2024
14 checks passed
@iamgabrielma iamgabrielma deleted the task/14080-disallow-negative-product-discount branch October 3, 2024 04:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
feature: order details Related to order details. type: task An internally driven task.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Platform parity: Disallow for discounts bigger than the price of the product
4 participants