-
Notifications
You must be signed in to change notification settings - Fork 110
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
Fix formatting issues for discount and cash payment input values when the device and store currency settings are different #13979
Conversation
…ecimal formatUserInput is meant to be used for user input, assuming it's in device settings. If store and device have inverse thousand and decimal separators, a correct conversion would only be done for device-supported input. test_formatUserInput_doesnt_support_numbers_with_decimal_and_thousands_separator_and_different_device_and_store_locale() shows a case where this method isn't supposed to format a number correctly.
…void number conversion issues When reopening DiscountLine, use Decimal amount to format text field, and avoid currencyFormatter conversions which can fail when store and device settings differ.
Formatted total can include decimal and thousands separator, which can be wrongly presented on a text field when store and device currency formatting settings differ.
priceFieldFormatter.formatUserInput is meant to be used by user input driven by device currency formatting settings. Passing Decimal ensures the values are preset correctly
…wModel Direct Decimal conversions can fail since user input may have different separators than Decimal initializer may expect. Use a Decimal value provided by priceFIeldFormatter for correct conversion
…nt-than-was-originally-added
@staskus Thanks for improving this – I'm unlikely to have time to review until Thursday with my AFK and beta testing to do this afternoon, so I hope @iamgabrielma can do the review. |
📲 You can test the changes from this Pull Request in WooCommerce iOS by scanning the QR code below to install the corresponding build.
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for working on this @staskus, it works great and I was unable to break it during testing 🎉
✅ Cash payment
✅ Formatting discount
✅ Discount line
✅ Custom amount
✅ Shipping
Specifically the Cash payment issue is a big one that we missed, but also easily missed since US-stores don't generally use values in the thousands. This one and the assumption of the values are inputted since only one separator can be added and it's interpreted as a decimal separator
we made at some point when initially implementing this logic are very good points regarding not making assumptions too early on how features are used, or can bite us later.
I found some additional issues when testing, related to handling negative amounts, but these are unrelated to the changes in this PR and will log them on the side (for example, merchants can tap on "collect payment" with a negative-valued order, just to fail later on during the collection flow as cannot be collected).
I left some questions, just so we can log separate issues to be tackled at some point 👍
@@ -44,7 +44,7 @@ final class CashPaymentTenderViewModel: ObservableObject { | |||
self.onOrderPaid = onOrderPaid | |||
self.analytics = analytics | |||
self.currencyFormatter = .init(currencySettings: storeCurrencySettings) | |||
formattableAmountViewModel.presetAmount(formattedTotal) | |||
formattableAmountViewModel.presetAmount(currencyFormatter.convertToDecimal(total)?.decimalValue ?? 0) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Question: Would it be worth to extract this bit into a separate function so we can log (maybe also test) if the conversion from String -> NSDecimal -> Decimal fails at some point?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good question.
We can always log a failure to convert. Even within the currency formatter itself, so we would see something in the logs if users report issues.
In this instance, the conversion to Decimal
can fail if CashPaymentTenderViewModel
is initialized with total: String
in an unexpected format. And technically this value is completely unconstrained. Anything can be passed through a total: String
. It would be impossible to unit test this. A safer approach would be to always pass these values as Decimal
rather than String
, and only turn between Decimal
and String
in two cases:
- When the value comes from the API
- When the user inputs the value
I'll check what it would mean passing Decimal
to CashPaymentTenderViewModel
and if a conversion in a different place would be safer. However, I imagine a better solution would require a larger refactoring.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Okay, so CurrencyFormatter
already logs an error:
DDLogError("Error: string input is not a number: \(stringValue)")
I checked the chain. total
value comes from Order
, which should be supported by CurrencyFormatter
. Failure can only be unexpected.
} | ||
|
||
return amountDecimal as Decimal | ||
return decimalFormatter.number(from: amount)?.decimalValue |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Question (not for this PR): Should we allow this property to be of an optional type? As we're moving the responsibility of handling nil-values to the components who consume the price formatter, rather than dealing with issues directly here on an centralised manner.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't have a quick answer to this. Looking specifically at PriceFieldFormatter
, amountDecimal
being optional probably makes sense, since amount
can also be empty.
The other option I imagine is to return 0
and maybe log
the failure? I'll check how this method is used. If all the callers just turn this value into 0
, we could also centralize it in PriceFieldFormatter
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I removed optionality from PriceFieldFormatter
decimalValue
XCTAssertEqual(priceFieldFormatter.formattedAmount, "$1000.40") | ||
} | ||
|
||
func test_formatUserInput_upports_numbers_with_decimal_and_thousands_separator_uses_custom_separators() { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit:
func test_formatUserInput_upports_numbers_with_decimal_and_thousands_separator_uses_custom_separators() { | |
func test_formatUserInput_supports_numbers_with_decimal_and_thousands_separator_uses_custom_separators() { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed
_ = priceFieldFormatter.formatUserInput("1000,5") | ||
|
||
// Then | ||
XCTAssertEqual(priceFieldFormatter.formattedAmount, "€1000,5") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I was curious to see if this fails if we assert against €1000,50
since we pass numberOfDecimals: 2
and it fails (expected since we're not validating this after the fact), but maybe we want to add an additional test to check if cuts down to the expected number of capped decimals if the user input happens to be more:
func test_formatUserInput_when_decimals_more_than_expected_then_returns_the_max_numberOfDecimals_allowed() {
// Given
let customSettings = CurrencySettings(currencyCode: .EUR,
currencyPosition: .left,
thousandSeparator: ".",
decimalSeparator: ",",
numberOfDecimals: 2)
let priceFieldFormatter = PriceFieldFormatter(locale: Locale(identifier: "lt-LT"), storeCurrencySettings: customSettings)
// When
_ = priceFieldFormatter.formatUserInput("1000,5123")
// Then
XCTAssertEqual(priceFieldFormatter.formattedAmount, "€1000,51")
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for the suggestion, added.
Closes: #13961
Description
Edit Discount View displays a different discount than originally added when using amounts larger than a thousand and store and device separator settings differ. Additionally, I found the same issue affects the Cash Payment view as well.
The issue comes from a broken
PriceFieldFormatter.formatAmount
method. We use it in two cases:When a user inputs an amount into a text field, the amount is likely inputted using the device decimal separator. The
formatAmount
method anticipates it and replaces the device decimal separator with the store decimal separator. It works as expected when the values are inputted since only one separator can be added and it's interpreted as a decimal separator.When the text field is initialized with an already existing value, the value is formatted in store settings. If the value is larger than a thousand, we pass value with thousands and decimal separator:
1,000.55
. However, if device settings uses different separators,PriceFieldFormatter.formatAmount
fails to format the value as expected, and 'Edit Discount' or 'Cash Payment' views are initialized with wrong values.Solution
PriceFieldFormatter.formatAmount
only for user input. For this reason, I renamed it toformatUserInput
and added a comment.PriceFieldFormatter.formatAmount
that acceptsDecimal
. We expect to use this value when presetting the value to the text field. This method reuses the same formatting logic asformatUserInput
but doesn't have to perform any sanitizing of the text field.Decimal
and usePriceFieldFormatter.formatAmount
. We can convert values toDecimal
usingcurrencyFormatter.convertToDecimal
since the values are formatted forAPI
(more info [Order payments] Fix Change and Discount calculations for numbers including a grouping separator #13854).PriceFieldFormatter.amountDecimal
value rather thanDecimal
when formatting the final price string. When we don't providelocale
toDecimal
, it fails to parse the string that uses,
decimal separator. Since at this place 'Add Discount' tries to convert user input toDecimal
, it's safer to usePriceFieldFormatter
which relies on the same formatter to convert between the value and decimal.Steps to reproduce
Original Issue - Edit Discount View
Thousands Separator
to,
, Decimal Separator to.
. (Default US Settings)Settings
->Language & Region
) set Number Format to (1234 567,89
) (Lithuania Region Settings)1000.54
)Video of an issue
Original.Issue.-.Edit.Discount.View.MP4
Related Issue - Cash Payment
Original Issue - Edit Discount View
1000.54
)Video of an issue
Related.Issue.-.Cash.Payment.MP4
Semi-related issue - Formatting Discount Value
Thousands Separator
to.
, Decimal Separator to,
(Reverse US Settings)Settings
->Language & Region
) set Number Format to (1234 567,89
) (Lithuania Region Settings)10.44
->10.56
...)Video of an issue
Related.Issue.-.Formatting.Discount.Value.MP4
Testing information
This PR took a long time to test and find a setup where I could not encounter more critical issues. Input fields I tested:
The changes affect the same text fields I was fixing in an unrelated PR Fix Custom Amount input field is occasionally not responsive.
Verified given views with multiple combinations of Store Settings and Device Settings.
Store Settings:
.
,
and a random value.
,
and a random valueNumbers:
Updated unit tests to cover broken scenarios.
Device Settings:
All the combinations that iOS support:
Videos
Discount Line
Simulator.Screen.Recording.-.iPhone.15.-.2024-09-16.at.17.24.35.mp4
Cash Payment
Cash.Payment.-.Fixed.mp4
Random Store Separator for Discounts
Random.separator.-.Discount.-.Fixed.mp4
Custom Amount
Simulator.Screen.Recording.-.iPhone.15.-.2024-09-16.at.17.25.12.mp4
Shipping
Shipping.-.Not.Affected.mp4
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: