Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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: 2 additions & 0 deletions RELEASE-NOTES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
- [*] Watch app: Fixed connection issue upon fresh install [https://github.com/woocommerce/woocommerce-ios/pull/15867]
- [Internal] Shipping Labels: Optimize requests for syncing countries [https://github.com/woocommerce/woocommerce-ios/pull/15875]
- [*] Shipping Labels: Display label size from account settings as default [https://github.com/woocommerce/woocommerce-ios/pull/15873]
- [*] Shipping Labels: Ensured customs form validation enforces non-zero product weight to fix shipping rate loading failure. [https://github.com/woocommerce/woocommerce-ios/pull/15927]
- [*] Shipping Labels: ITN number is now required for shipments with total value more than 2500. [https://github.com/woocommerce/woocommerce-ios/pull/15937]

22.7
-----
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,14 +140,13 @@ private extension WooShippingCustomsFormViewModel {
.map { input -> ITNValidationError? in
let (itn, items, hsTariffNumberTotalValueDictionary, countryCode) = input
guard itn.isEmpty else {
return itn.isValidITN ? nil : .invalidFormat
return ITNNumberValidator.isValid(itn) ? nil : .invalidFormat
}

let totalItemValue = items.reduce(0, { sum, item in
sum + item.totalValue
})
if hsTariffNumberTotalValueDictionary.isEmpty,
totalItemValue > Constants.minimumValueForRequiredITN {
if totalItemValue > Constants.minimumValueForRequiredITN {
return .missingForTotalShipmentValue
}

Expand Down Expand Up @@ -204,7 +203,7 @@ private extension WooShippingCustomsFormViewModel {
restrictionType: restrictionType.toFormRestrictionType(),
restrictionComments: restrictionType == .other ? restrictionDetails : "",
nonDeliveryOption: returnToSenderIfNotDelivered ? .return : .abandon,
itn: internationalTransactionNumber.isValidITN ? internationalTransactionNumber : "",
itn: ITNNumberValidator.isValid(internationalTransactionNumber) ? internationalTransactionNumber : "",
items: itemsViewModels.map {
ShippingLabelCustomsForm.Item(
description: $0.description,
Expand Down Expand Up @@ -246,9 +245,10 @@ extension WooShippingCustomsFormViewModel.ITNValidationError {

private enum Localization {
static let itnInvalidFormat = NSLocalizedString(
"wooShippingCustomsFormViewModel.ITNValidationError.invalidFormat",
value: "Please enter a valid ITN in one of these formats: X12345678901234, AES X12345678901234, or NOEEI 30.37(a).",
comment: "Message when the ITN field is invalid in the customs form of a shipping label"
"wooShippingCustomsFormViewModel.ITNValidationError.invalidFormat.mandatoryAES",
value: "Please enter a valid ITN in one of these formats: AES X12345678901234, or NOEEI 30.37(a).",
comment: "Message when the ITN field is invalid in the customs form of a shipping label. " +
"Doesn't contain X12345678901234 format example."
)
static let itnRequiredForTariffClass = NSLocalizedString(
"wooShippingCustomsFormViewModel.ITNValidationError.missingForTariffClass",
Expand Down Expand Up @@ -385,18 +385,24 @@ extension WooShippingContentType {
}
}

private extension String {
var isValidITN: Bool {
guard self.isNotEmpty else {
enum ITNNumberValidator {
/// Validates AES/ITN (International Transaction Number) or NOEEI (No EEI) exemption codes
/// Accepts formats like:
/// - AES ITN: X12345678901234, AES 12345678901234 or AES ITN: 12345678901234
/// - NOEEI exemptions: NOEEI 30.36 or NOEEI 30.36(a) or NOEEI 30.36(a)(1)
/// AES/ITN numbers which are 14 digits long, optionally prefixed with 'X', 'AES', and/or 'ITN'
/// NOEEI exemption codes in the format "NOEEI 30.XX" with optional subsection letters and numbers
static func isValid(_ itnNumber: String) -> Bool {
guard itnNumber.isNotEmpty else {
return true
}

let pattern = "^(?:(?:AES X\\d{14})|(?:NOEEI 30\\.\\d{1,2}(?:\\([a-z]\\)(?:\\(\\d\\))?)?))$"
let pattern = "^(?:(?:AES(?!\\S)\\s*(?:ITN:?\\s*)?X?\\d{14})|(?:NOEEI\\s+30\\.\\d{2}(?:\\([a-z]\\)(?:\\(\\d\\))?)?))$"

do {
let regex = try NSRegularExpression(pattern: pattern)
let range = NSRange(self.startIndex..<self.endIndex, in: self)
return regex.firstMatch(in: self, options: [], range: range) != nil
let regex = try NSRegularExpression(pattern: pattern, options: .caseInsensitive)
let range = NSRange(itnNumber.startIndex..<itnNumber.endIndex, in: itnNumber)
return regex.firstMatch(in: itnNumber, options: [], range: range) != nil
} catch {
return false
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ struct WooShippingCustomsItem: View {
@State private var isShowingDescriptionInfoDialog = false
@State private var isShowingOriginCountryInfoDialog = false


@Environment(\.shippingWeightUnit) var weightUnit: String

var body: some View {
Expand Down Expand Up @@ -167,12 +166,12 @@ struct WooShippingCustomsItem: View {
.padding(.trailing, Layout.unitsHorizontalSpacing)
}
.roundedBorder(cornerRadius: Layout.borderCornerRadius,
lineColor: viewModel.weightPerUnit.isEmpty ? warningRedColor : Color(.separator),
lineColor: viewModel.isValidWeight ? Color(.separator) : warningRedColor,
lineWidth: Layout.borderLineWidth)
Text(Localization.valueRequiredWarningText)
.foregroundColor(warningRedColor)
.footnoteStyle()
.renderedIf(viewModel.weightPerUnit.isEmpty)
.renderedIf(!viewModel.isValidWeight)
}
}
.padding(.bottom, Layout.collapsibleViewVerticalSpacing)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ final class WooShippingCustomsItemViewModel: ObservableObject {
return length >= 6 && length <= 12
}

var isValidWeight: Bool {
return Self.isWeightValid(weightPerUnit)
}

@Published var requiredInformationIsEntered: Bool = false

private var cancellables = Set<AnyCancellable>()
Expand All @@ -79,7 +83,12 @@ final class WooShippingCustomsItemViewModel: ObservableObject {
self.itemProductID = itemProductID
self.itemQuantity = itemQuantity
self.valuePerUnit = String(itemValue)
self.weightPerUnit = String(itemWeight)

/// Skip zero weight
if Self.isWeightNonZero(itemWeight) {
self.weightPerUnit = String(itemWeight)
}

self.currencySymbol = currencySymbol
self.storageManager = storageManager

Expand Down Expand Up @@ -127,7 +136,11 @@ private extension WooShippingCustomsItemViewModel {
func combineRequiredInformationIsEntered() {
Publishers.CombineLatest4($description, $valuePerUnit, $weightPerUnit, $selectedCountry)
.sink { [weak self] description, valuePerUnit, weightPerUnit, selectedCountry in
self?.requiredInformationIsEntered = description.isNotEmpty && valuePerUnit.isNotEmpty && weightPerUnit.isNotEmpty && selectedCountry != nil
guard let self else { return }
requiredInformationIsEntered = description.isNotEmpty &&
valuePerUnit.isNotEmpty &&
Self.isWeightValid(weightPerUnit) &&
selectedCountry != nil
}
.store(in: &cancellables)
}
Expand All @@ -149,4 +162,13 @@ private extension WooShippingCustomsItemViewModel {
}
.store(in: &cancellables)
}

/// Specifically introduced to check for a `0` value
static func isWeightValid(_ weightString: String) -> Bool {
return isWeightNonZero(Double(weightString) ?? 0)
}

static func isWeightNonZero(_ weightValue: Double) -> Bool {
return weightValue > 0
}
}
Loading
Loading