Skip to content

Commit

Permalink
[Revamp Shipping Labels • Customs] Validation & Required (#14921)
Browse files Browse the repository at this point in the history
  • Loading branch information
toupper authored Jan 22, 2025
2 parents b0f4522 + 46288e7 commit a50b799
Show file tree
Hide file tree
Showing 5 changed files with 300 additions and 4 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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")

}

}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -27,6 +29,8 @@ final class WooShippingCustomsFormViewModel: ObservableObject {
}

listenToItemsRequiredInformationValues()
listenForRequiredInformation()
listenForInternationalTransactionNumberIsRequired()
}

@Published var itemsViewModels: [WooShippingCustomsItemViewModel] = []
Expand All @@ -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,
Expand All @@ -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..<internationalTransactionNumber.endIndex, in: internationalTransactionNumber)
return regex.firstMatch(in: internationalTransactionNumber, options: [], range: range) != nil
} catch {
return false
}
}
}

private extension WooShippingCustomsFormViewModel {
func listenForRequiredInformation() {
Publishers.CombineLatest3($itemsRequiredInformationIsEntered, $internationalTransactionNumber, $internationalTransactionNumberIsRequired)
.sink { [weak self] itemsRequiredInformationIsEntered, internationalTransactionNumber, internationalTransactionNumberIsRequired in
guard let self = self else { return }

guard itemsRequiredInformationIsEntered else {
self.requiredInformationIsEntered = false
return
}

guard internationalTransactionNumberIsRequired else {
self.requiredInformationIsEntered = true
return
}

self.requiredInformationIsEntered = internationalTransactionNumber.isNotEmpty && self.isValidITN()
}
.store(in: &cancellables)
}

func listenForInternationalTransactionNumberIsRequired() {
$itemsViewModels
.map { childViewModels in
childViewModels.map { $0.$hsTariffNumberTotalValue.eraseToAnyPublisher() }
}
.flatMap { childPublishers in
childPublishers.combineLatest()
}
.sink { [weak self] values in
var hsTariffNumberTotalValueDictionary: [String: Decimal] = [:]
for (hsTariffNumber, totalValuePerItem) in values.compacted() {
hsTariffNumberTotalValueDictionary[hsTariffNumber, default: 0] += totalValuePerItem
}

self?.internationalTransactionNumberIsRequired = hsTariffNumberTotalValueDictionary.values.contains { $0 > 2500 }
}
.store(in: &cancellables)
}

func listenToItemsRequiredInformationValues() {
// Listen to the items required information and enable the button depending on it
$itemsViewModels
Expand All @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()]
Expand All @@ -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"
Expand Down Expand Up @@ -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()]
Expand All @@ -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)
}
}
Loading

0 comments on commit a50b799

Please sign in to comment.