Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion RELEASE-NOTES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
-----
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -506,6 +507,19 @@ private extension WooShippingShipmentDetailsViewModel {
.map(\.?.country)
.eraseToAnyPublisher()
}

func isHSTariffNumberRequiredPublisher() -> AnyPublisher<Bool, Never> {
$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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ final class WooShippingCustomsFormViewModel: ObservableObject {
init(order: Order,
shipment: Shipment,
originCountryCode: AnyPublisher<String?, Never>? = nil,
isHSTariffNumberRequired: AnyPublisher<Bool, Never>? = nil,
storageManager: StorageManagerType = ServiceLocator.storageManager,
onFormReady: @escaping (ShippingLabelCustomsForm) -> ()) {
self.onFormReady = onFormReady
Expand All @@ -54,6 +55,7 @@ final class WooShippingCustomsFormViewModel: ObservableObject {
itemWeight: $0.weight,
currencySymbol: currencySymbol(from: order),
originCountryCode: originCountryCode,
isHSTariffNumberRequired: isHSTariffNumberRequired,
storageManager: storageManager)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,15 +105,29 @@ 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)
.footnoteStyle()
.renderedIf(!viewModel.isValidTariffNumber)
///
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: redundant line.


Button {
isShowingHSTarrifInfoWebView = true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,13 +64,21 @@ final class WooShippingCustomsItemViewModel: ObservableObject {

private var cancellables = Set<AnyCancellable>()

/// 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,
itemValue: Double,
itemWeight: Double,
currencySymbol: String,
originCountryCode: AnyPublisher<String?, Never>? = nil,
isHSTariffNumberRequired: AnyPublisher<Bool, Never>? = nil,
storageManager: StorageManagerType = ServiceLocator.storageManager) {
self.title = itemName
self.description = itemName
Expand All @@ -89,6 +97,9 @@ final class WooShippingCustomsItemViewModel: ObservableObject {
originCountryCode?
.assign(to: &$originCountryCode)

isHSTariffNumberRequired?
.assign(to: &$isHSTariffNumberRequired)

fetchCountries()

combineToPreselectCountry()
Expand Down Expand Up @@ -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
Copy link
Contributor

@itsmeichigo itsmeichigo Jul 29, 2025

Choose a reason for hiding this comment

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

Nit: I didn't know that && takes priority over ||. Still, should we add braces or split this into two properties for readability?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

👌 Gonna add braces for readability


requiredInformationIsEntered = description.isNotEmpty &&
valuePerUnit.isNotEmpty &&
Self.isWeightValid(weightPerUnit) &&
selectedCountry != nil &&
hsTariffNumberRequirementMet
}
.store(in: &cancellables)
}

func combineHSTariffNumberTotalValue() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<WooShippingAddress?, Never>()
let destinationAddressSubject = PassthroughSubject<WooShippingAddress?, Never>()

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<WooShippingAddress?, Never>()
Expand Down