diff --git a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Customs/WooShippingCustomsForm.swift b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Customs/WooShippingCustomsForm.swift index 4a3fe870b76..ce9fdc96aa4 100644 --- a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Customs/WooShippingCustomsForm.swift +++ b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Customs/WooShippingCustomsForm.swift @@ -3,6 +3,7 @@ import WooFoundation import Yosemite struct WooShippingCustomsForm: View { + @Environment(\.colorScheme) var colorScheme @Environment(\.presentationMode) var presentationMode @ObservedObject var viewModel: WooShippingCustomsFormViewModel @State private var isShowingITNInfoWebView = false @@ -59,6 +60,12 @@ struct WooShippingCustomsForm: View { .roundedBorder(cornerRadius: Constants.borderCornerRadius, lineColor: Color(.separator), lineWidth: Constants.borderWidth) } + private var warningRedColor: Color { + let shade: ColorStudioShade = colorScheme == .dark ? .shade40 : .shade60 + + return .withColorStudio(name: .red, shade: shade) + } + var body: some View { NavigationView { GeometryReader { geometry in @@ -94,6 +101,14 @@ struct WooShippingCustomsForm: View { .padding(Constants.borderPadding) .roundedBorder(cornerRadius: Constants.borderCornerRadius, lineColor: Color(.separator), lineWidth: Constants.borderWidth) + Text(Localization.itnValidationWarningMessage) + .foregroundColor(warningRedColor) + .footnoteStyle() + .renderedIf(!viewModel.isValidITN()) + Text(Localization.itnRequiredWarningMessage) + .foregroundColor(warningRedColor) + .footnoteStyle() + .renderedIf(viewModel.internationalTransactionNumberIsRequired && viewModel.internationalTransactionNumber.isEmpty) Button { isShowingITNInfoWebView = true } label: { @@ -193,6 +208,14 @@ extension WooShippingCustomsForm { static let productDetailsTitle = NSLocalizedString("wooShipping.customs.productDetails", value: "Product Details", comment: "Product Details Section title") + static let itnValidationWarningMessage = NSLocalizedString("wooShipping.customs.itnValidation", + value: "Please enter a valid ITN", + comment: "Customs validation warning for the ITN field") + static let itnRequiredWarningMessage = NSLocalizedString("wooShipping.customs.itnValidation", + value: "International Transaction Number is required for shipping items " + + "valued over $2,500 per tariff number", + comment: "Customs validation warning for the ITN field") + } } diff --git a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Customs/WooShippingCustomsFormViewModel.swift b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Customs/WooShippingCustomsFormViewModel.swift index c3b196a51c5..ba6472d840b 100644 --- a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Customs/WooShippingCustomsFormViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Customs/WooShippingCustomsFormViewModel.swift @@ -5,9 +5,11 @@ import WooFoundation final class WooShippingCustomsFormViewModel: ObservableObject { @Published var internationalTransactionNumber: String = "" + @Published var internationalTransactionNumberIsRequired: Bool = false @Published var returnToSenderIfNotDelivered: Bool = false @Published var requiredInformationIsEntered: Bool = false + @Published var itemsRequiredInformationIsEntered: Bool = false @Published var contentType: WooShippingContentType = .merchandise @Published var restrictionType: WooShippingRestrictionType = .none @@ -27,6 +29,8 @@ final class WooShippingCustomsFormViewModel: ObservableObject { } listenToItemsRequiredInformationValues() + listenForRequiredInformation() + listenForInternationalTransactionNumberIsRequired() } @Published var itemsViewModels: [WooShippingCustomsItemViewModel] = [] @@ -40,7 +44,7 @@ final class WooShippingCustomsFormViewModel: ObservableObject { restrictionType: restrictionType.toFormRestrictionType(), restrictionComments: "", nonDeliveryOption: returnToSenderIfNotDelivered ? .return : .abandon, - itn: internationalTransactionNumber, + itn: isValidITN() ? internationalTransactionNumber : "", items: itemsViewModels.map { ShippingLabelCustomsForm.Item(description: $0.description, quantity: $0.orderItem.quantity, @@ -53,9 +57,64 @@ final class WooShippingCustomsFormViewModel: ObservableObject { ) onCompletion(form) } + + func isValidITN() -> Bool { + guard internationalTransactionNumber.isNotEmpty else { + return true + } + + let pattern = "^(?:(?:AES X\\d{14})|(?:NOEEI 30\\.\\d{1,2}(?:\\([a-z]\\)(?:\\(\\d\\))?)?))$" + + do { + let regex = try NSRegularExpression(pattern: pattern) + let range = NSRange(internationalTransactionNumber.startIndex.. 2500 } + } + .store(in: &cancellables) + } + func listenToItemsRequiredInformationValues() { // Listen to the items required information and enable the button depending on it $itemsViewModels @@ -69,7 +128,7 @@ private extension WooShippingCustomsFormViewModel { childValidityArray.allSatisfy { $0 } // Check if all are valid } .sink { [weak self] value in - self?.requiredInformationIsEntered = value + self?.itemsRequiredInformationIsEntered = value } .store(in: &cancellables) } diff --git a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Customs/WooShippingCustomsItemViewModel.swift b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Customs/WooShippingCustomsItemViewModel.swift index 558d57f8559..57f787c2133 100644 --- a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Customs/WooShippingCustomsItemViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Customs/WooShippingCustomsItemViewModel.swift @@ -15,6 +15,8 @@ final class WooShippingCustomsItemViewModel: ObservableObject { @Published var valuePerUnit: String = "" @Published var weightPerUnit: String = "" @Published var originCountry: WooShippingCustomsCountry + // Useful to determine externally if the shipping requires an ITN + @Published var hsTariffNumberTotalValue: (String, Decimal)? private let storageManager: StorageManagerType private let stores: StoresManager @@ -71,10 +73,11 @@ final class WooShippingCustomsItemViewModel: ObservableObject { fetchCountries() combineRequiredInformationIsEntered() + combineHSTariffNumberTotalValue() } } -extension WooShippingCustomsItemViewModel { +private extension WooShippingCustomsItemViewModel { func fetchCountries() { try? resultsController.performFetch() let action = DataAction.synchronizeCountries(siteID: siteID) { [weak self] (result) in @@ -97,4 +100,22 @@ extension WooShippingCustomsItemViewModel { } .store(in: &cancellables) } + + func combineHSTariffNumberTotalValue() { + Publishers.CombineLatest($valuePerUnit, $hsTariffNumber) + .sink { [weak self] valuePerUnit, hsTariffNumber in + guard let self = self else { return } + + guard self.currencySymbol == "$", + let valuePerUnitDecimal = Decimal(string: valuePerUnit), + hsTariffNumber.isNotEmpty, + isValidTariffNumber else { + self.hsTariffNumberTotalValue = nil + return + } + + self.hsTariffNumberTotalValue = (hsTariffNumber, valuePerUnitDecimal * orderItem.quantity) + } + .store(in: &cancellables) + } } diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Shipping Label/WooShipping Create Shipping Labels/WooShipping Customs/WooShippingCustomsFormViewModelTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Shipping Label/WooShipping Create Shipping Labels/WooShipping Customs/WooShippingCustomsFormViewModelTests.swift index 0864bb95ab7..716179c42ca 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/Shipping Label/WooShipping Create Shipping Labels/WooShipping Customs/WooShippingCustomsFormViewModelTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/Shipping Label/WooShipping Create Shipping Labels/WooShipping Customs/WooShippingCustomsFormViewModelTests.swift @@ -5,6 +5,18 @@ import Yosemite class WooShippingCustomsFormViewModelTests: XCTestCase { private var viewModel: WooShippingCustomsFormViewModel! + override func setUp() { + super.setUp() + + viewModel = WooShippingCustomsFormViewModel(order: Order.fake(), onCompletion: { _ in }) + } + + override func tearDown() { + super.tearDown() + + viewModel = nil + } + func test_onDismiss_calls_onCompletion_with_right_values() { // Given let orderItems = [MockOrderItem.sampleItem(productID: 123, quantity: 2), MockOrderItem.sampleItem()] @@ -16,7 +28,7 @@ class WooShippingCustomsFormViewModelTests: XCTestCase { viewModel.restrictionType = .quarantine viewModel.contentType = .gift - viewModel.internationalTransactionNumber = "1234" + viewModel.internationalTransactionNumber = "NOEEI 30.37(a)" viewModel.returnToSenderIfNotDelivered = false viewModel.itemsViewModels.first?.description = "Test Item" @@ -62,6 +74,24 @@ class WooShippingCustomsFormViewModelTests: XCTestCase { XCTAssertTrue(passedForm?.items.first?.hsTariffNumber.isEmpty ?? false) } + func test_onDismiss_when_calls_onCompletion_with_invalid_itn_then_returns_empty() { + // Given + let orderItems = [MockOrderItem.sampleItem(productID: 123, quantity: 2), MockOrderItem.sampleItem()] + + var passedForm: ShippingLabelCustomsForm? + viewModel = WooShippingCustomsFormViewModel(order: Order.fake().copy(items: orderItems), onCompletion: { form in + passedForm = form + }) + + viewModel.internationalTransactionNumber = "1234" + + // When + viewModel.onDismiss() + + // Then + XCTAssertTrue(passedForm?.itn.isEmpty ?? false) + } + func test_init_passes_right_currency() { // Given let orderItems = [MockOrderItem.sampleItem(productID: 123, quantity: 2), MockOrderItem.sampleItem()] @@ -72,4 +102,117 @@ class WooShippingCustomsFormViewModelTests: XCTestCase { // Then XCTAssertEqual(viewModel.itemsViewModels.first?.currencySymbol, "$") } + + func test_isValidITN_when_internationalTransactionNumber_is_empty_then_returns_true() { + // Given + viewModel.internationalTransactionNumber = "" + + // Then + XCTAssertTrue(viewModel.isValidITN()) + } + + func test_isValidITN_when_passing_a_valid_AES_internationalTransactionNumber_then_returns_true() { + // Given + viewModel.internationalTransactionNumber = "AES X12345678901234" + + // Then + XCTAssertTrue(viewModel.isValidITN()) + } + + func test_isValidITN_when_passing_a_valid_NOEEI_internationalTransactionNumber_then_returns_true() { + // Given + viewModel.internationalTransactionNumber = "NOEEI 30.37(a)" + + // Then + XCTAssertTrue(viewModel.isValidITN()) + } + + func test_isValidITN_when_passing_an_invalid_internationalTransactionNumber_then_returns_false() { + // Given + viewModel.internationalTransactionNumber = "INVALID 123456" + + // Then + XCTAssertFalse(viewModel.isValidITN()) + } + + func test_isValidITN_when_passing_an_AES_internationalTransactionNumber_with_special_characters_then_returns_false() { + // Given + viewModel.internationalTransactionNumber = "AES X123@#4567890" + + // Then + XCTAssertFalse(viewModel.isValidITN()) + } + + func test_isValidITN_when_passing_a_long_internationalTransactionNumber_then_returns_false() { + // Given + viewModel.internationalTransactionNumber = "AES X12345678901234567890" + + // Then + XCTAssertFalse(viewModel.isValidITN()) + } + + func test_internationalTransactionNumberIsRequired_when_item_view_models_hsTariffNumberTotalValue_is_nil_then_returns_false() { + // Given + let orderItems = [MockOrderItem.sampleItem(productID: 123, quantity: 2), MockOrderItem.sampleItem()] + + viewModel = WooShippingCustomsFormViewModel(order: Order.fake().copy(items: orderItems), onCompletion: { _ in }) + + // When + viewModel.itemsViewModels.first?.hsTariffNumberTotalValue = nil + + // Then + XCTAssertFalse(viewModel.internationalTransactionNumberIsRequired) + } + + func test_internationalTransactionNumberIsRequired_when_item_view_models_hsTariffNumberTotalValue_is_less_than_2500_then_returns_false() { + // Given + let orderItems = [MockOrderItem.sampleItem(productID: 123, quantity: 2), MockOrderItem.sampleItem()] + + viewModel = WooShippingCustomsFormViewModel(order: Order.fake().copy(items: orderItems), onCompletion: { _ in }) + + // When + viewModel.itemsViewModels.first?.hsTariffNumberTotalValue = ("123456", 1000) + + XCTAssertFalse(viewModel.requiredInformationIsEntered) + } + + func test_internationalTransactionNumberIsRequired_when_item_view_models_hsTariffNumberTotalValue_is_less_than_2500_then_returns_true() { + // Given + let orderItems = [MockOrderItem.sampleItem(productID: 123, quantity: 2), MockOrderItem.sampleItem()] + + viewModel = WooShippingCustomsFormViewModel(order: Order.fake().copy(items: orderItems), onCompletion: { _ in }) + + viewModel.itemsViewModels.first?.requiredInformationIsEntered = true + viewModel.itemsViewModels.first?.hsTariffNumberTotalValue = ("123456", 1000) + viewModel.itemsViewModels[1].hsTariffNumberTotalValue = ("123456", 2000) + viewModel.internationalTransactionNumber = "" + + XCTAssertFalse(viewModel.requiredInformationIsEntered) + } + + func test_requiredInformationIsEntered_when_itn_is_required_but_invalid_then_returns_false() { + // Given + let orderItems = [MockOrderItem.sampleItem(productID: 123, quantity: 2), MockOrderItem.sampleItem()] + + viewModel = WooShippingCustomsFormViewModel(order: Order.fake().copy(items: orderItems), onCompletion: { _ in }) + + viewModel.itemsViewModels.first?.requiredInformationIsEntered = true + viewModel.internationalTransactionNumber = "1234" + + XCTAssertFalse(viewModel.requiredInformationIsEntered) + } + + func test_requiredInformationIsEntered_when_itn_is_required_and_valid_then_returns_true() { + // Given + let orderItems = [MockOrderItem.sampleItem()] + + viewModel = WooShippingCustomsFormViewModel(order: Order.fake().copy(items: orderItems), onCompletion: { _ in }) + + debugPrint("viewModel.itemsViewModels", viewModel.itemsViewModels) + + viewModel.internationalTransactionNumber = "NOEEI 30.37(a)" + viewModel.itemsViewModels.first?.requiredInformationIsEntered = true + + XCTAssertTrue(viewModel.requiredInformationIsEntered) + } } diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Shipping Label/WooShipping Create Shipping Labels/WooShipping Customs/WooShippingCustomsItemViewModelTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Shipping Label/WooShipping Create Shipping Labels/WooShipping Customs/WooShippingCustomsItemViewModelTests.swift index cc9db9a7481..589e734e682 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/Shipping Label/WooShipping Create Shipping Labels/WooShipping Customs/WooShippingCustomsItemViewModelTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/Shipping Label/WooShipping Create Shipping Labels/WooShipping Customs/WooShippingCustomsItemViewModelTests.swift @@ -44,4 +44,54 @@ class WooShippingCustomsItemViewModelTests: XCTestCase { viewModel.hsTariffNumber = "987654321098" XCTAssertTrue(viewModel.isValidTariffNumber) } + + func test_hsTariffNumberTotalValue_when_currency_symbol_is_not_$_returns_nil() { + // When + let orderItem = MockOrderItem.sampleItem(quantity: 2) + viewModel = WooShippingCustomsItemViewModel(originCountry: WooShippingCustomsCountry(code: "", name: "United States"), + orderItem: orderItem, currencySymbol: "$") + + // Then + XCTAssertNil(viewModel.hsTariffNumberTotalValue) + + } + + func test_hsTariffNumberTotalValue_when_currency_symbol_is_$_but_hsTariffNumber_is_empty_returns_nil() { + // When + let orderItem = MockOrderItem.sampleItem(quantity: 2) + viewModel = WooShippingCustomsItemViewModel(originCountry: WooShippingCustomsCountry(code: "", name: "United States"), + orderItem: orderItem, currencySymbol: "$") + viewModel.valuePerUnit = "1000" + viewModel.hsTariffNumber = "" + + // Then + XCTAssertNil(viewModel.hsTariffNumberTotalValue) + + } + + func test_hsTariffNumberTotalValue_when_currency_symbol_is_$_but_invalid_hs_tariff_number_returns_nil() { + // When + let orderItem = MockOrderItem.sampleItem(quantity: 2) + viewModel = WooShippingCustomsItemViewModel(originCountry: WooShippingCustomsCountry(code: "", name: "United States"), + orderItem: orderItem, currencySymbol: "$") + viewModel.hsTariffNumber = "123" + viewModel.valuePerUnit = "1000" + + + // Then + XCTAssertNil(viewModel.hsTariffNumberTotalValue) + } + + func test_hsTariffNumberTotalValue_when_currency_symbol_is_$_and_value_is_more_than_2500_and_valid_hs_tariff_number_returns_values() { + // When + let orderItem = MockOrderItem.sampleItem(quantity: 2) + viewModel = WooShippingCustomsItemViewModel(originCountry: WooShippingCustomsCountry(code: "", name: "United States"), + orderItem: orderItem, currencySymbol: "$") + viewModel.valuePerUnit = "3000" + viewModel.hsTariffNumber = "123456" + + // Then + XCTAssertEqual(viewModel.hsTariffNumberTotalValue?.0, viewModel.hsTariffNumber) + XCTAssertEqual(viewModel.hsTariffNumberTotalValue?.1, Decimal(string: viewModel.valuePerUnit)! * orderItem.quantity) + } }