From a6d306d4b1ed003305e49b6c18abb710831f1736 Mon Sep 17 00:00:00 2001 From: Justintime50 <39606064+Justintime50@users.noreply.github.com> Date: Wed, 11 Feb 2026 10:48:30 -0700 Subject: [PATCH 1/2] feat: add FedEx multi-factor authentication registration support Co-Authored-By: Claude Sonnet 4.5 --- easypost/easypost_client.py | 2 + easypost/models/__init__.py | 2 + .../fedex_account_validation_response.py | 5 + easypost/models/fedex_request_pin_response.py | 5 + easypost/services/__init__.py | 1 + .../services/fedex_registration_service.py | 158 ++++++++++++++++++ tests/test_fedex_registration.py | 134 +++++++++++++++ 7 files changed, 307 insertions(+) create mode 100644 easypost/models/fedex_account_validation_response.py create mode 100644 easypost/models/fedex_request_pin_response.py create mode 100644 easypost/services/fedex_registration_service.py create mode 100644 tests/test_fedex_registration.py diff --git a/easypost/easypost_client.py b/easypost/easypost_client.py index bbd0a6e7..a2b3219e 100644 --- a/easypost/easypost_client.py +++ b/easypost/easypost_client.py @@ -25,6 +25,7 @@ EmbeddableService, EndShipperService, EventService, + FedExRegistrationService, InsuranceService, LumaService, OrderService, @@ -66,6 +67,7 @@ def __init__( self.billing = BillingService(self) self.carrier_account = CarrierAccountService(self) self.carrier_metadata = CarrierMetadataService(self) + self.fedex_registration = FedExRegistrationService(self) self.claim = ClaimService(self) self.customer_portal = CustomerPortalService(self) self.customs_info = CustomsInfoService(self) diff --git a/easypost/models/__init__.py b/easypost/models/__init__.py index 9df262ba..07b1d378 100644 --- a/easypost/models/__init__.py +++ b/easypost/models/__init__.py @@ -10,6 +10,8 @@ from easypost.models.customs_item import CustomsItem from easypost.models.end_shipper import EndShipper from easypost.models.event import Event +from easypost.models.fedex_account_validation_response import FedExAccountValidationResponse +from easypost.models.fedex_request_pin_response import FedExRequestPinResponse from easypost.models.insurance import Insurance from easypost.models.order import Order from easypost.models.parcel import Parcel diff --git a/easypost/models/fedex_account_validation_response.py b/easypost/models/fedex_account_validation_response.py new file mode 100644 index 00000000..3c9e5ba2 --- /dev/null +++ b/easypost/models/fedex_account_validation_response.py @@ -0,0 +1,5 @@ +from easypost.easypost_object import EasyPostObject + + +class FedExAccountValidationResponse(EasyPostObject): + pass diff --git a/easypost/models/fedex_request_pin_response.py b/easypost/models/fedex_request_pin_response.py new file mode 100644 index 00000000..fd6ad13d --- /dev/null +++ b/easypost/models/fedex_request_pin_response.py @@ -0,0 +1,5 @@ +from easypost.easypost_object import EasyPostObject + + +class FedExRequestPinResponse(EasyPostObject): + pass diff --git a/easypost/services/__init__.py b/easypost/services/__init__.py index c792757b..61385d14 100644 --- a/easypost/services/__init__.py +++ b/easypost/services/__init__.py @@ -14,6 +14,7 @@ from easypost.services.embeddable_service import EmbeddableService from easypost.services.end_shipper_service import EndShipperService from easypost.services.event_service import EventService +from easypost.services.fedex_registration_service import FedExRegistrationService from easypost.services.insurance_service import InsuranceService from easypost.services.luma_service import LumaService from easypost.services.order_service import OrderService diff --git a/easypost/services/fedex_registration_service.py b/easypost/services/fedex_registration_service.py new file mode 100644 index 00000000..789288d0 --- /dev/null +++ b/easypost/services/fedex_registration_service.py @@ -0,0 +1,158 @@ +import uuid +from typing import Any + +from easypost.easypost_object import convert_to_easypost_object +from easypost.models.fedex_account_validation_response import FedExAccountValidationResponse +from easypost.models.fedex_request_pin_response import FedExRequestPinResponse +from easypost.requestor import RequestMethod, Requestor +from easypost.services.base_service import BaseService + + +class FedExRegistrationService(BaseService): + def __init__(self, client): + self._client = client + + def register_address(self, fedex_account_number: str, **params) -> FedExAccountValidationResponse: + """Register the billing address for a FedEx account. + + Args: + fedex_account_number: The FedEx account number. + params: Map of parameters. + + Returns: + FedExAccountValidationResponse object with next steps (PIN or invoice validation). + """ + wrapped_params = self._wrap_address_validation(params) + url = f"/fedex_registrations/{fedex_account_number}/address" + + response = Requestor(self._client).request(method=RequestMethod.POST, url=url, params=wrapped_params) + + return convert_to_easypost_object(response=response) + + def request_pin(self, fedex_account_number: str, pin_method_option: str) -> FedExRequestPinResponse: + """Request a PIN for FedEx account verification. + + Args: + fedex_account_number: The FedEx account number. + pin_method_option: The PIN delivery method: "SMS", "CALL", or "EMAIL". + + Returns: + FedExRequestPinResponse object confirming PIN was sent. + """ + wrapped_params = {"pin_method": {"option": pin_method_option}} + url = f"/fedex_registrations/{fedex_account_number}/pin" + + response = Requestor(self._client).request(method=RequestMethod.POST, url=url, params=wrapped_params) + + return convert_to_easypost_object(response=response) + + def validate_pin(self, fedex_account_number: str, **params) -> FedExAccountValidationResponse: + """Validate the PIN entered by the user for FedEx account verification. + + Args: + fedex_account_number: The FedEx account number. + params: Map of parameters. + + Returns: + FedExAccountValidationResponse object. + """ + wrapped_params = self._wrap_pin_validation(params) + url = f"/fedex_registrations/{fedex_account_number}/pin/validate" + + response = Requestor(self._client).request(method=RequestMethod.POST, url=url, params=wrapped_params) + + return convert_to_easypost_object(response=response) + + def submit_invoice(self, fedex_account_number: str, **params) -> FedExAccountValidationResponse: + """Submit invoice information to complete FedEx account registration. + + Args: + fedex_account_number: The FedEx account number. + params: Map of parameters. + + Returns: + FedExAccountValidationResponse object. + """ + wrapped_params = self._wrap_invoice_validation(params) + url = f"/fedex_registrations/{fedex_account_number}/invoice" + + response = Requestor(self._client).request(method=RequestMethod.POST, url=url, params=wrapped_params) + + return convert_to_easypost_object(response=response) + + def _wrap_address_validation(self, params: dict[str, Any]) -> dict[str, Any]: + """Wraps address validation parameters and ensures the "name" field exists. + If not present, generates a UUID (with hyphens removed) as the name. + + Args: + params: The original parameters map. + + Returns: + A new map with properly wrapped address_validation and easypost_details. + """ + wrapped_params = {} + + if "address_validation" in params: + address_validation = params["address_validation"].copy() + self._ensure_name_field(address_validation) + wrapped_params["address_validation"] = address_validation + + if "easypost_details" in params: + wrapped_params["easypost_details"] = params["easypost_details"] + + return wrapped_params + + def _wrap_pin_validation(self, params: dict[str, Any]) -> dict[str, Any]: + """Wraps PIN validation parameters and ensures the "name" field exists. + If not present, generates a UUID (with hyphens removed) as the name. + + Args: + params: The original parameters map. + + Returns: + A new map with properly wrapped pin_validation and easypost_details. + """ + wrapped_params = {} + + if "pin_validation" in params: + pin_validation = params["pin_validation"].copy() + self._ensure_name_field(pin_validation) + wrapped_params["pin_validation"] = pin_validation + + if "easypost_details" in params: + wrapped_params["easypost_details"] = params["easypost_details"] + + return wrapped_params + + def _wrap_invoice_validation(self, params: dict[str, Any]) -> dict[str, Any]: + """Wraps invoice validation parameters and ensures the "name" field exists. + If not present, generates a UUID (with hyphens removed) as the name. + + Args: + params: The original parameters map. + + Returns: + A new map with properly wrapped invoice_validation and easypost_details. + """ + wrapped_params = {} + + if "invoice_validation" in params: + invoice_validation = params["invoice_validation"].copy() + self._ensure_name_field(invoice_validation) + wrapped_params["invoice_validation"] = invoice_validation + + if "easypost_details" in params: + wrapped_params["easypost_details"] = params["easypost_details"] + + return wrapped_params + + def _ensure_name_field(self, mapping: dict[str, Any]) -> None: + """Ensures the "name" field exists in the provided map. + If not present, generates a UUID (with hyphens removed) as the name. + This follows the pattern used in the web UI implementation. + + Args: + mapping: The map to ensure the "name" field in. + """ + if "name" not in mapping or mapping["name"] is None: + mapping["name"] = str(uuid.uuid4()).replace("-", "") diff --git a/tests/test_fedex_registration.py b/tests/test_fedex_registration.py new file mode 100644 index 00000000..c37d07c7 --- /dev/null +++ b/tests/test_fedex_registration.py @@ -0,0 +1,134 @@ +from unittest.mock import MagicMock + +from easypost.easypost_object import EasyPostObject +from easypost.models import CarrierAccount + + +def test_register_address(prod_client, monkeypatch): + """Tests registering a billing address.""" + fedex_account_number = "123456789" + address_validation = { + "name": "BILLING NAME", + "street1": "1234 BILLING STREET", + "city": "BILLINGCITY", + "state": "ST", + "postal_code": "12345", + "country_code": "US", + } + easypost_details = {"carrier_account_id": "ca_123"} + params = { + "address_validation": address_validation, + "easypost_details": easypost_details, + } + + json_response = { + "email_address": None, + "options": ["SMS", "CALL", "INVOICE"], + "phone_number": "***-***-9721", + } + + mock_requestor = MagicMock() + mock_requestor.return_value.request.return_value = json_response + + monkeypatch.setattr("easypost.services.fedex_registration_service.Requestor", mock_requestor) + + response = prod_client.fedex_registration.register_address(fedex_account_number, **params) + assert isinstance(response, EasyPostObject) + assert response.email_address is None + assert "SMS" in response.options + assert "CALL" in response.options + assert "INVOICE" in response.options + assert response.phone_number == "***-***-9721" + + +def test_request_pin(prod_client, monkeypatch): + """Tests requesting a pin.""" + fedex_account_number = "123456789" + + json_response = {"message": "sent secured Pin"} + + mock_requestor = MagicMock() + mock_requestor.return_value.request.return_value = json_response + + monkeypatch.setattr("easypost.services.fedex_registration_service.Requestor", mock_requestor) + + response = prod_client.fedex_registration.request_pin(fedex_account_number, "SMS") + assert isinstance(response, EasyPostObject) + assert response.message == "sent secured Pin" + + +def test_validate_pin(prod_client, monkeypatch): + """Tests validating a pin.""" + fedex_account_number = "123456789" + pin_validation = { + "pin_code": "123456", + "name": "BILLING NAME", + } + easypost_details = {"carrier_account_id": "ca_123"} + params = { + "pin_validation": pin_validation, + "easypost_details": easypost_details, + } + + json_response = { + "id": "ca_123", + "object": "CarrierAccount", + "type": "FedexAccount", + "credentials": { + "account_number": "123456789", + "mfa_key": "123456789-XXXXX", + }, + } + + mock_requestor = MagicMock() + mock_requestor.return_value.request.return_value = json_response + + monkeypatch.setattr("easypost.services.fedex_registration_service.Requestor", mock_requestor) + + response = prod_client.fedex_registration.validate_pin(fedex_account_number, **params) + assert isinstance(response, CarrierAccount) + assert response.id == "ca_123" + assert response.object == "CarrierAccount" + assert response.type == "FedexAccount" + assert response.credentials["account_number"] == "123456789" + assert response.credentials["mfa_key"] == "123456789-XXXXX" + + +def test_submit_invoice(prod_client, monkeypatch): + """Tests submitting details about an invoice.""" + fedex_account_number = "123456789" + invoice_validation = { + "name": "BILLING NAME", + "invoice_number": "INV-12345", + "invoice_date": "2025-12-08", + "invoice_amount": "100.00", + "invoice_currency": "USD", + } + easypost_details = {"carrier_account_id": "ca_123"} + params = { + "invoice_validation": invoice_validation, + "easypost_details": easypost_details, + } + + json_response = { + "id": "ca_123", + "object": "CarrierAccount", + "type": "FedexAccount", + "credentials": { + "account_number": "123456789", + "mfa_key": "123456789-XXXXX", + }, + } + + mock_requestor = MagicMock() + mock_requestor.return_value.request.return_value = json_response + + monkeypatch.setattr("easypost.services.fedex_registration_service.Requestor", mock_requestor) + + response = prod_client.fedex_registration.submit_invoice(fedex_account_number, **params) + assert isinstance(response, CarrierAccount) + assert response.id == "ca_123" + assert response.object == "CarrierAccount" + assert response.type == "FedexAccount" + assert response.credentials["account_number"] == "123456789" + assert response.credentials["mfa_key"] == "123456789-XXXXX" From d608b213d6afcaa0c500987e22f2c8005b75a0aa Mon Sep 17 00:00:00 2001 From: Justintime50 <39606064+Justintime50@users.noreply.github.com> Date: Wed, 11 Feb 2026 11:26:11 -0700 Subject: [PATCH 2/2] chore: cleanup --- easypost/easypost_client.py | 7 +- easypost/models/__init__.py | 2 - .../fedex_account_validation_response.py | 5 -- easypost/models/fedex_request_pin_response.py | 5 -- .../services/fedex_registration_service.py | 71 +++---------------- 5 files changed, 10 insertions(+), 80 deletions(-) delete mode 100644 easypost/models/fedex_account_validation_response.py delete mode 100644 easypost/models/fedex_request_pin_response.py diff --git a/easypost/easypost_client.py b/easypost/easypost_client.py index a2b3219e..2da85038 100644 --- a/easypost/easypost_client.py +++ b/easypost/easypost_client.py @@ -5,10 +5,7 @@ SUPPORT_EMAIL, TIMEOUT, ) -from easypost.hooks import ( - RequestHook, - ResponseHook, -) +from easypost.hooks import RequestHook, ResponseHook from easypost.services import ( AddressService, ApiKeyService, @@ -67,7 +64,6 @@ def __init__( self.billing = BillingService(self) self.carrier_account = CarrierAccountService(self) self.carrier_metadata = CarrierMetadataService(self) - self.fedex_registration = FedExRegistrationService(self) self.claim = ClaimService(self) self.customer_portal = CustomerPortalService(self) self.customs_info = CustomsInfoService(self) @@ -75,6 +71,7 @@ def __init__( self.embeddable = EmbeddableService(self) self.end_shipper = EndShipperService(self) self.event = EventService(self) + self.fedex_registration = FedExRegistrationService(self) self.insurance = InsuranceService(self) self.luma = LumaService(self) self.order = OrderService(self) diff --git a/easypost/models/__init__.py b/easypost/models/__init__.py index 07b1d378..9df262ba 100644 --- a/easypost/models/__init__.py +++ b/easypost/models/__init__.py @@ -10,8 +10,6 @@ from easypost.models.customs_item import CustomsItem from easypost.models.end_shipper import EndShipper from easypost.models.event import Event -from easypost.models.fedex_account_validation_response import FedExAccountValidationResponse -from easypost.models.fedex_request_pin_response import FedExRequestPinResponse from easypost.models.insurance import Insurance from easypost.models.order import Order from easypost.models.parcel import Parcel diff --git a/easypost/models/fedex_account_validation_response.py b/easypost/models/fedex_account_validation_response.py deleted file mode 100644 index 3c9e5ba2..00000000 --- a/easypost/models/fedex_account_validation_response.py +++ /dev/null @@ -1,5 +0,0 @@ -from easypost.easypost_object import EasyPostObject - - -class FedExAccountValidationResponse(EasyPostObject): - pass diff --git a/easypost/models/fedex_request_pin_response.py b/easypost/models/fedex_request_pin_response.py deleted file mode 100644 index fd6ad13d..00000000 --- a/easypost/models/fedex_request_pin_response.py +++ /dev/null @@ -1,5 +0,0 @@ -from easypost.easypost_object import EasyPostObject - - -class FedExRequestPinResponse(EasyPostObject): - pass diff --git a/easypost/services/fedex_registration_service.py b/easypost/services/fedex_registration_service.py index 789288d0..7406246d 100644 --- a/easypost/services/fedex_registration_service.py +++ b/easypost/services/fedex_registration_service.py @@ -2,8 +2,6 @@ from typing import Any from easypost.easypost_object import convert_to_easypost_object -from easypost.models.fedex_account_validation_response import FedExAccountValidationResponse -from easypost.models.fedex_request_pin_response import FedExRequestPinResponse from easypost.requestor import RequestMethod, Requestor from easypost.services.base_service import BaseService @@ -12,16 +10,8 @@ class FedExRegistrationService(BaseService): def __init__(self, client): self._client = client - def register_address(self, fedex_account_number: str, **params) -> FedExAccountValidationResponse: - """Register the billing address for a FedEx account. - - Args: - fedex_account_number: The FedEx account number. - params: Map of parameters. - - Returns: - FedExAccountValidationResponse object with next steps (PIN or invoice validation). - """ + def register_address(self, fedex_account_number: str, **params) -> dict[str, Any]: + """Register the billing address for a FedEx account.""" wrapped_params = self._wrap_address_validation(params) url = f"/fedex_registrations/{fedex_account_number}/address" @@ -29,16 +19,8 @@ def register_address(self, fedex_account_number: str, **params) -> FedExAccountV return convert_to_easypost_object(response=response) - def request_pin(self, fedex_account_number: str, pin_method_option: str) -> FedExRequestPinResponse: - """Request a PIN for FedEx account verification. - - Args: - fedex_account_number: The FedEx account number. - pin_method_option: The PIN delivery method: "SMS", "CALL", or "EMAIL". - - Returns: - FedExRequestPinResponse object confirming PIN was sent. - """ + def request_pin(self, fedex_account_number: str, pin_method_option: str) -> dict[str, Any]: + """Request a PIN for FedEx account verification.""" wrapped_params = {"pin_method": {"option": pin_method_option}} url = f"/fedex_registrations/{fedex_account_number}/pin" @@ -46,16 +28,8 @@ def request_pin(self, fedex_account_number: str, pin_method_option: str) -> FedE return convert_to_easypost_object(response=response) - def validate_pin(self, fedex_account_number: str, **params) -> FedExAccountValidationResponse: - """Validate the PIN entered by the user for FedEx account verification. - - Args: - fedex_account_number: The FedEx account number. - params: Map of parameters. - - Returns: - FedExAccountValidationResponse object. - """ + def validate_pin(self, fedex_account_number: str, **params) -> dict[str, Any]: + """Validate the PIN entered by the user for FedEx account verification.""" wrapped_params = self._wrap_pin_validation(params) url = f"/fedex_registrations/{fedex_account_number}/pin/validate" @@ -63,16 +37,8 @@ def validate_pin(self, fedex_account_number: str, **params) -> FedExAccountValid return convert_to_easypost_object(response=response) - def submit_invoice(self, fedex_account_number: str, **params) -> FedExAccountValidationResponse: - """Submit invoice information to complete FedEx account registration. - - Args: - fedex_account_number: The FedEx account number. - params: Map of parameters. - - Returns: - FedExAccountValidationResponse object. - """ + def submit_invoice(self, fedex_account_number: str, **params) -> dict[str, Any]: + """Submit invoice information to complete FedEx account registration.""" wrapped_params = self._wrap_invoice_validation(params) url = f"/fedex_registrations/{fedex_account_number}/invoice" @@ -83,12 +49,6 @@ def submit_invoice(self, fedex_account_number: str, **params) -> FedExAccountVal def _wrap_address_validation(self, params: dict[str, Any]) -> dict[str, Any]: """Wraps address validation parameters and ensures the "name" field exists. If not present, generates a UUID (with hyphens removed) as the name. - - Args: - params: The original parameters map. - - Returns: - A new map with properly wrapped address_validation and easypost_details. """ wrapped_params = {} @@ -105,12 +65,6 @@ def _wrap_address_validation(self, params: dict[str, Any]) -> dict[str, Any]: def _wrap_pin_validation(self, params: dict[str, Any]) -> dict[str, Any]: """Wraps PIN validation parameters and ensures the "name" field exists. If not present, generates a UUID (with hyphens removed) as the name. - - Args: - params: The original parameters map. - - Returns: - A new map with properly wrapped pin_validation and easypost_details. """ wrapped_params = {} @@ -127,12 +81,6 @@ def _wrap_pin_validation(self, params: dict[str, Any]) -> dict[str, Any]: def _wrap_invoice_validation(self, params: dict[str, Any]) -> dict[str, Any]: """Wraps invoice validation parameters and ensures the "name" field exists. If not present, generates a UUID (with hyphens removed) as the name. - - Args: - params: The original parameters map. - - Returns: - A new map with properly wrapped invoice_validation and easypost_details. """ wrapped_params = {} @@ -150,9 +98,6 @@ def _ensure_name_field(self, mapping: dict[str, Any]) -> None: """Ensures the "name" field exists in the provided map. If not present, generates a UUID (with hyphens removed) as the name. This follows the pattern used in the web UI implementation. - - Args: - mapping: The map to ensure the "name" field in. """ if "name" not in mapping or mapping["name"] is None: mapping["name"] = str(uuid.uuid4()).replace("-", "")