Skip to content

Commit d21bde5

Browse files
[Shipping Labels] Update ITN number format (#15937)
Merging to unblock the release
2 parents 940ec1d + 3a12a6d commit d21bde5

File tree

3 files changed

+104
-14
lines changed

3 files changed

+104
-14
lines changed

RELEASE-NOTES.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
- [Internal] Shipping Labels: Optimize requests for syncing countries [https://github.com/woocommerce/woocommerce-ios/pull/15875]
1818
- [*] Shipping Labels: Display label size from account settings as default [https://github.com/woocommerce/woocommerce-ios/pull/15873]
1919
- [*] 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]
20+
- [*] Shipping Labels: ITN number is now required for shipments with total value more than 2500. [https://github.com/woocommerce/woocommerce-ios/pull/15937]
2021

2122
22.7
2223
-----

WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Customs/WooShippingCustomsFormViewModel.swift

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -140,14 +140,13 @@ private extension WooShippingCustomsFormViewModel {
140140
.map { input -> ITNValidationError? in
141141
let (itn, items, hsTariffNumberTotalValueDictionary, countryCode) = input
142142
guard itn.isEmpty else {
143-
return itn.isValidITN ? nil : .invalidFormat
143+
return ITNNumberValidator.isValid(itn) ? nil : .invalidFormat
144144
}
145145

146146
let totalItemValue = items.reduce(0, { sum, item in
147147
sum + item.totalValue
148148
})
149-
if hsTariffNumberTotalValueDictionary.isEmpty,
150-
totalItemValue > Constants.minimumValueForRequiredITN {
149+
if totalItemValue > Constants.minimumValueForRequiredITN {
151150
return .missingForTotalShipmentValue
152151
}
153152

@@ -204,7 +203,7 @@ private extension WooShippingCustomsFormViewModel {
204203
restrictionType: restrictionType.toFormRestrictionType(),
205204
restrictionComments: restrictionType == .other ? restrictionDetails : "",
206205
nonDeliveryOption: returnToSenderIfNotDelivered ? .return : .abandon,
207-
itn: internationalTransactionNumber.isValidITN ? internationalTransactionNumber : "",
206+
itn: ITNNumberValidator.isValid(internationalTransactionNumber) ? internationalTransactionNumber : "",
208207
items: itemsViewModels.map {
209208
ShippingLabelCustomsForm.Item(
210209
description: $0.description,
@@ -246,9 +245,10 @@ extension WooShippingCustomsFormViewModel.ITNValidationError {
246245

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

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

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

396402
do {
397-
let regex = try NSRegularExpression(pattern: pattern)
398-
let range = NSRange(self.startIndex..<self.endIndex, in: self)
399-
return regex.firstMatch(in: self, options: [], range: range) != nil
403+
let regex = try NSRegularExpression(pattern: pattern, options: .caseInsensitive)
404+
let range = NSRange(itnNumber.startIndex..<itnNumber.endIndex, in: itnNumber)
405+
return regex.firstMatch(in: itnNumber, options: [], range: range) != nil
400406
} catch {
401407
return false
402408
}

WooCommerce/WooCommerceTests/ViewRelated/Shipping Label/WooShipping Create Shipping Labels/WooShipping Customs/WooShippingCustomsFormViewModelTests.swift

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,89 @@ final class WooShippingCustomsFormViewModelTests: XCTestCase {
394394
XCTAssertFalse(itemViewModel.isValidWeight)
395395
XCTAssertFalse(itemViewModel.requiredInformationIsEntered)
396396
}
397+
398+
func test_ITNNumberValidator_when_number_is_valid_then_returns_true() {
399+
// Valid ITN formats
400+
XCTAssertTrue(ITNNumberValidator.isValid("AES X12345678901234"))
401+
XCTAssertTrue(ITNNumberValidator.isValid("AES 12345678901234"))
402+
XCTAssertTrue(ITNNumberValidator.isValid("AES ITN 12345678901234"))
403+
XCTAssertTrue(ITNNumberValidator.isValid("AES ITN:12345678901234"))
404+
XCTAssertTrue(ITNNumberValidator.isValid("aes itn:12345678901234"))
405+
XCTAssertTrue(ITNNumberValidator.isValid("aes x12345678901234"))
406+
407+
// Valid NOEEI formats
408+
XCTAssertTrue(ITNNumberValidator.isValid("NOEEI 30.36"))
409+
XCTAssertTrue(ITNNumberValidator.isValid("NOEEI 30.37(a)"))
410+
XCTAssertTrue(ITNNumberValidator.isValid("NOEEI 30.37(a)(1)"))
411+
XCTAssertTrue(ITNNumberValidator.isValid("noeei 30.37(a)(1)"))
412+
}
413+
414+
func test_ITNNumberValidator_when_number_is_invalid_then_returns_false() {
415+
// Invalid formats
416+
XCTAssertFalse(ITNNumberValidator.isValid("X12345678901234"))
417+
XCTAssertFalse(ITNNumberValidator.isValid("12345678901234"))
418+
XCTAssertFalse(ITNNumberValidator.isValid("AES Y12345678901234")) // Invalid prefix
419+
XCTAssertFalse(ITNNumberValidator.isValid("X1234567890123")) // Too short
420+
XCTAssertFalse(ITNNumberValidator.isValid("X123456789012345")) // Too long
421+
XCTAssertFalse(ITNNumberValidator.isValid("NOEEI 30.3")) // Incomplete NOEEI
422+
XCTAssertFalse(ITNNumberValidator.isValid("NOEEI 30.37(a)(1)(i)")) // Invalid NOEEI
423+
XCTAssertFalse(ITNNumberValidator.isValid("AESX12345678901234"))
424+
XCTAssertFalse(ITNNumberValidator.isValid("NOEEI30.36"))
425+
426+
// Empty and whitespace
427+
XCTAssertTrue(ITNNumberValidator.isValid(""))
428+
XCTAssertFalse(ITNNumberValidator.isValid(" "))
429+
}
430+
431+
func test_itnValidationError_when_totalShipmentValueExceedsThreshold_andTariffClassesDont() {
432+
// Given
433+
let storageManager = MockStorageManager()
434+
let originCountryCodeSubject = PassthroughSubject<String?, Never>()
435+
436+
let usCountry = Country(
437+
code: "US",
438+
name: "United States",
439+
states: []
440+
)
441+
let ukCountry = Country(
442+
code: "UK",
443+
name: "United Kingdom",
444+
states: []
445+
)
446+
storageManager.insertSampleCountries(readOnlyCountries: [usCountry, ukCountry])
447+
let shipment = sampleShipment
448+
let order = Order.fake().copy(currency: "USD")
449+
450+
viewModel = WooShippingCustomsFormViewModel(
451+
order: order,
452+
shipment: shipment,
453+
originCountryCode: originCountryCodeSubject.eraseToAnyPublisher(),
454+
storageManager: storageManager,
455+
onFormReady: { _ in }
456+
)
457+
458+
// Set values to have a total > $2500, but each tariff class < $2500
459+
// Item 1 has quantity 2, Item 2 has quantity 1.
460+
viewModel.itemsViewModels[0].valuePerUnit = "1200" // Total: 2 * 1200 = 2400
461+
viewModel.itemsViewModels[1].valuePerUnit = "200" // Total: 1 * 200 = 200
462+
// Shipment total: 2600
463+
464+
// When
465+
originCountryCodeSubject.send("US")
466+
viewModel.updateDestinationCountry(code: "UK") // A country that doesn't have special ITN rules
467+
viewModel.internationalTransactionNumber = "" // No ITN provided
468+
// Set different tariff numbers for each item
469+
viewModel.itemsViewModels[0].hsTariffNumber = "111111"
470+
viewModel.itemsViewModels[1].hsTariffNumber = "222222"
471+
viewModel.itemsViewModels.forEach {
472+
$0.description = "Test"
473+
$0.weightPerUnit = "1"
474+
}
475+
476+
// Then
477+
// The shipment total value is 2600, which is > $2500, so an ITN should be required.
478+
XCTAssertEqual(viewModel.itnValidationError, .missingForTotalShipmentValue)
479+
}
397480
}
398481

399482
private extension WooShippingCustomsFormViewModelTests {

0 commit comments

Comments
 (0)