diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt index 236ba20fac0..3a15f4fb8da 100644 --- a/RELEASE-NOTES.txt +++ b/RELEASE-NOTES.txt @@ -3,7 +3,7 @@ 23.0 ----- - +- [*] Shipping Labels: Made HS tariff number field required in customs form for EU destinations [https://github.com/woocommerce/woocommerce-ios/pull/15946] 22.9 ----- diff --git a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/ShipmentDetails/WooShippingShipmentDetailsViewModel.swift b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/ShipmentDetails/WooShippingShipmentDetailsViewModel.swift index 4e5c241768f..30ae578e986 100644 --- a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/ShipmentDetails/WooShippingShipmentDetailsViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/ShipmentDetails/WooShippingShipmentDetailsViewModel.swift @@ -70,6 +70,7 @@ final class WooShippingShipmentDetailsViewModel: ObservableObject { order: order, shipment: shipment, originCountryCode: originCountryCodePublisher(), + isHSTariffNumberRequired: isHSTariffNumberRequiredPublisher(), storageManager: storageManager ) { [weak self] form in self?.customsForm = form @@ -506,6 +507,19 @@ private extension WooShippingShipmentDetailsViewModel { .map(\.?.country) .eraseToAnyPublisher() } + + func isHSTariffNumberRequiredPublisher() -> AnyPublisher { + $destinationAddress + /// HS tariff number is required for EU countries + .map { address in + guard let address else { + return false + } + + return Country.countriesFollowingEUCustoms.contains(address.country) + } + .eraseToAnyPublisher() + } } private extension WooShippingShipmentDetailsViewModel { 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 951f0909cc4..01dc639e6db 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 @@ -42,6 +42,7 @@ final class WooShippingCustomsFormViewModel: ObservableObject { init(order: Order, shipment: Shipment, originCountryCode: AnyPublisher? = nil, + isHSTariffNumberRequired: AnyPublisher? = nil, storageManager: StorageManagerType = ServiceLocator.storageManager, onFormReady: @escaping (ShippingLabelCustomsForm) -> ()) { self.onFormReady = onFormReady @@ -54,6 +55,7 @@ final class WooShippingCustomsFormViewModel: ObservableObject { itemWeight: $0.weight, currencySymbol: currencySymbol(from: order), originCountryCode: originCountryCode, + isHSTariffNumberRequired: isHSTariffNumberRequired, storageManager: storageManager) } diff --git a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Customs/WooShippingCustomsItem.swift b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Customs/WooShippingCustomsItem.swift index 6e39a70688a..e7ae1ac573a 100644 --- a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Customs/WooShippingCustomsItem.swift +++ b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Customs/WooShippingCustomsItem.swift @@ -105,10 +105,23 @@ struct WooShippingCustomsItem: View { .subheadlineStyle() .padding(.top, Layout.collapsibleViewVerticalSpacing) - TextField(Localization.HSTariffNumberPlaceholder, text: $viewModel.hsTariffNumber) - .keyboardType(.numberPad) - .padding(Layout.extraPadding) - .roundedBorder(cornerRadius: Layout.borderCornerRadius, lineColor: Color(.separator), lineWidth: Layout.borderLineWidth) + /// HS tariff number + TextField( + viewModel.isHSTariffNumberRequired ? "" : Localization.HSTariffNumberPlaceholder, + text: $viewModel.hsTariffNumber + ) + .keyboardType(.numberPad) + .padding(Layout.extraPadding) + .roundedBorder( + cornerRadius: Layout.borderCornerRadius, + lineColor: (viewModel.isHSTariffNumberRequired && viewModel.hsTariffNumber.isEmpty) ? warningRedColor : Color(.separator), + lineWidth: Layout.borderLineWidth + ) + + Text(Localization.valueRequiredWarningText) + .foregroundColor(warningRedColor) + .footnoteStyle() + .renderedIf(viewModel.isHSTariffNumberRequired && viewModel.hsTariffNumber.isEmpty) Text(Localization.tariffNumberRulesWarningText) .foregroundColor(warningRedColor) 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 e70c7b24a16..cb09358d2c7 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 @@ -64,6 +64,13 @@ final class WooShippingCustomsItemViewModel: ObservableObject { private var cancellables = Set() + /// WOOMOB-891 + /// Shipments with a EU destination address must contain HS tariff number + /// + /// Introduced to enforce tariff validation + /// if `true` then `hsTariffNumber` must be valid for `requiredInformationIsEntered` to be `true` + @Published private(set) var isHSTariffNumberRequired: Bool = false + init(itemName: String, itemProductID: Int64, itemQuantity: Decimal, @@ -71,6 +78,7 @@ final class WooShippingCustomsItemViewModel: ObservableObject { itemWeight: Double, currencySymbol: String, originCountryCode: AnyPublisher? = nil, + isHSTariffNumberRequired: AnyPublisher? = nil, storageManager: StorageManagerType = ServiceLocator.storageManager) { self.title = itemName self.description = itemName @@ -89,6 +97,9 @@ final class WooShippingCustomsItemViewModel: ObservableObject { originCountryCode? .assign(to: &$originCountryCode) + isHSTariffNumberRequired? + .assign(to: &$isHSTariffNumberRequired) + fetchCountries() combineToPreselectCountry() @@ -128,15 +139,27 @@ private extension WooShippingCustomsItemViewModel { } func combineRequiredInformationIsEntered() { - Publishers.CombineLatest4($description, $valuePerUnit, $weightPerUnit, $selectedCountry) - .sink { [weak self] description, valuePerUnit, weightPerUnit, selectedCountry in - guard let self else { return } - requiredInformationIsEntered = description.isNotEmpty && - valuePerUnit.isNotEmpty && - Self.isWeightValid(weightPerUnit) && - selectedCountry != nil - } - .store(in: &cancellables) + Publishers.CombineLatest4( + $description, + $valuePerUnit, + $weightPerUnit, + $selectedCountry + ) + .combineLatest($hsTariffNumber, $isHSTariffNumberRequired) + .sink { [weak self] result in + guard let self else { return } + + let ((description, valuePerUnit, weightPerUnit, selectedCountry), hsTariffNumber, isHSTariffNumberRequired) = result + + let hsTariffNumberRequirementMet = (hsTariffNumber.isEmpty && !isHSTariffNumberRequired) || (isValidTariffNumber && hsTariffNumber.isNotEmpty) + + requiredInformationIsEntered = description.isNotEmpty && + valuePerUnit.isNotEmpty && + Self.isWeightValid(weightPerUnit) && + selectedCountry != nil && + hsTariffNumberRequirementMet + } + .store(in: &cancellables) } func combineHSTariffNumberTotalValue() { diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Shipping Label/WooShipping Create Shipping Labels/WooShippingShipmentDetailsViewModelTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Shipping Label/WooShipping Create Shipping Labels/WooShippingShipmentDetailsViewModelTests.swift index 8fa9308cff7..5867ba3122d 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/Shipping Label/WooShipping Create Shipping Labels/WooShippingShipmentDetailsViewModelTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/Shipping Label/WooShipping Create Shipping Labels/WooShippingShipmentDetailsViewModelTests.swift @@ -836,6 +836,76 @@ final class WooShippingShipmentDetailsViewModelTests: XCTestCase { XCTAssertEqual(customsItemViewModel.selectedCountry?.code, expectedCountry) } + func test_customs_form_is_incomplete_when_destination_country_is_eu_and_hsTariffNumber_is_empty() throws { + // Given + let stores = MockStoresManager(sessionManager: .testingInstance) + let storageManager = MockStorageManager() + + let originAddressSubject = PassthroughSubject() + let destinationAddressSubject = PassthroughSubject() + + let usCountry = Country(code: "US", name: "United States", states: []) + let caCountry = Country(code: "CA", name: "Canada", states: []) + let euCountries = [ + Country(code: "FR", name: "France", states: []), + Country(code: "DE", name: "Germany", states: []), + Country(code: "ES", name: "Spain", states: []), + Country(code: "IT", name: "Italy", states: []), + Country(code: "NL", name: "Netherlands", states: []) + ] + storageManager.insertSampleCountries(readOnlyCountries: [usCountry, caCountry] + euCountries) + + let item = ShippingLabelPackageItem( + productOrVariationID: 1, + orderItemID: 1, + name: "Test Item", + weight: 1, + quantity: 1, + value: 10, // low value, shouldn't require HS Tariff # based on value + dimensions: .fake(), + attributes: [], + imageURL: nil + ) + let shipment = Shipment( + contents: [CollapsibleShipmentItemCardViewModel(item: item, currency: "USD")], + currency: "USD", + currencySettings: ServiceLocator.currencySettings, + shippingSettingsService: ServiceLocator.shippingSettingsService + ) + + let viewModel = WooShippingShipmentDetailsViewModel( + order: Order.fake(), + shipment: shipment, + shippingLabel: nil, + originAddress: originAddressSubject.eraseToAnyPublisher(), + destinationAddress: destinationAddressSubject.eraseToAnyPublisher(), + stores: stores, + storageManager: storageManager + ) + + // When + let itemViewModel = viewModel.customsFormViewModel.itemsViewModels[0] + itemViewModel.hsTariffNumber = "" // Empty tariff number + + originAddressSubject.send(sampleOriginAddress(country: usCountry.code, state: "")) + destinationAddressSubject.send(sampleDestinationAddress(country: caCountry.code, state: "")) + + // Then: HS Tariff number should not be required for non EU destination countries regardless of value + XCTAssertTrue( + itemViewModel.requiredInformationIsEntered, + "HS Tariff number should not be required for \(usCountry.name)" + ) + + // And: HS Tariff number should be required for EU countries regardless of value + for euCountry in euCountries { + destinationAddressSubject.send(sampleDestinationAddress(country: euCountry.code, state: "")) + XCTAssertFalse( + itemViewModel.requiredInformationIsEntered, + "HS Tariff number should be required for \(euCountry.name)" + ) + } + } + func test_package_contains_complete_customs_form_when_required_data_is_prefilled() throws { // Setup let originAddressSubject = PassthroughSubject()