Skip to content

Commit 7757ca1

Browse files
Justintime50claude
andauthored
feat: add FedEx multi-factor authentication registration support (#383)
Ports the work from EasyPost/easypost-java#367 to here. --------- Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 8a5968d commit 7757ca1

File tree

4 files changed

+241
-4
lines changed

4 files changed

+241
-4
lines changed

easypost/easypost_client.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,7 @@
55
SUPPORT_EMAIL,
66
TIMEOUT,
77
)
8-
from easypost.hooks import (
9-
RequestHook,
10-
ResponseHook,
11-
)
8+
from easypost.hooks import RequestHook, ResponseHook
129
from easypost.services import (
1310
AddressService,
1411
ApiKeyService,
@@ -25,6 +22,7 @@
2522
EmbeddableService,
2623
EndShipperService,
2724
EventService,
25+
FedExRegistrationService,
2826
InsuranceService,
2927
LumaService,
3028
OrderService,
@@ -73,6 +71,7 @@ def __init__(
7371
self.embeddable = EmbeddableService(self)
7472
self.end_shipper = EndShipperService(self)
7573
self.event = EventService(self)
74+
self.fedex_registration = FedExRegistrationService(self)
7675
self.insurance = InsuranceService(self)
7776
self.luma = LumaService(self)
7877
self.order = OrderService(self)

easypost/services/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from easypost.services.embeddable_service import EmbeddableService
1515
from easypost.services.end_shipper_service import EndShipperService
1616
from easypost.services.event_service import EventService
17+
from easypost.services.fedex_registration_service import FedExRegistrationService
1718
from easypost.services.insurance_service import InsuranceService
1819
from easypost.services.luma_service import LumaService
1920
from easypost.services.order_service import OrderService
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import uuid
2+
from typing import Any
3+
4+
from easypost.easypost_object import convert_to_easypost_object
5+
from easypost.requestor import RequestMethod, Requestor
6+
from easypost.services.base_service import BaseService
7+
8+
9+
class FedExRegistrationService(BaseService):
10+
def __init__(self, client):
11+
self._client = client
12+
13+
def register_address(self, fedex_account_number: str, **params) -> dict[str, Any]:
14+
"""Register the billing address for a FedEx account."""
15+
wrapped_params = self._wrap_address_validation(params)
16+
url = f"/fedex_registrations/{fedex_account_number}/address"
17+
18+
response = Requestor(self._client).request(method=RequestMethod.POST, url=url, params=wrapped_params)
19+
20+
return convert_to_easypost_object(response=response)
21+
22+
def request_pin(self, fedex_account_number: str, pin_method_option: str) -> dict[str, Any]:
23+
"""Request a PIN for FedEx account verification."""
24+
wrapped_params = {"pin_method": {"option": pin_method_option}}
25+
url = f"/fedex_registrations/{fedex_account_number}/pin"
26+
27+
response = Requestor(self._client).request(method=RequestMethod.POST, url=url, params=wrapped_params)
28+
29+
return convert_to_easypost_object(response=response)
30+
31+
def validate_pin(self, fedex_account_number: str, **params) -> dict[str, Any]:
32+
"""Validate the PIN entered by the user for FedEx account verification."""
33+
wrapped_params = self._wrap_pin_validation(params)
34+
url = f"/fedex_registrations/{fedex_account_number}/pin/validate"
35+
36+
response = Requestor(self._client).request(method=RequestMethod.POST, url=url, params=wrapped_params)
37+
38+
return convert_to_easypost_object(response=response)
39+
40+
def submit_invoice(self, fedex_account_number: str, **params) -> dict[str, Any]:
41+
"""Submit invoice information to complete FedEx account registration."""
42+
wrapped_params = self._wrap_invoice_validation(params)
43+
url = f"/fedex_registrations/{fedex_account_number}/invoice"
44+
45+
response = Requestor(self._client).request(method=RequestMethod.POST, url=url, params=wrapped_params)
46+
47+
return convert_to_easypost_object(response=response)
48+
49+
def _wrap_address_validation(self, params: dict[str, Any]) -> dict[str, Any]:
50+
"""Wraps address validation parameters and ensures the "name" field exists.
51+
If not present, generates a UUID (with hyphens removed) as the name.
52+
"""
53+
wrapped_params = {}
54+
55+
if "address_validation" in params:
56+
address_validation = params["address_validation"].copy()
57+
self._ensure_name_field(address_validation)
58+
wrapped_params["address_validation"] = address_validation
59+
60+
if "easypost_details" in params:
61+
wrapped_params["easypost_details"] = params["easypost_details"]
62+
63+
return wrapped_params
64+
65+
def _wrap_pin_validation(self, params: dict[str, Any]) -> dict[str, Any]:
66+
"""Wraps PIN validation parameters and ensures the "name" field exists.
67+
If not present, generates a UUID (with hyphens removed) as the name.
68+
"""
69+
wrapped_params = {}
70+
71+
if "pin_validation" in params:
72+
pin_validation = params["pin_validation"].copy()
73+
self._ensure_name_field(pin_validation)
74+
wrapped_params["pin_validation"] = pin_validation
75+
76+
if "easypost_details" in params:
77+
wrapped_params["easypost_details"] = params["easypost_details"]
78+
79+
return wrapped_params
80+
81+
def _wrap_invoice_validation(self, params: dict[str, Any]) -> dict[str, Any]:
82+
"""Wraps invoice validation parameters and ensures the "name" field exists.
83+
If not present, generates a UUID (with hyphens removed) as the name.
84+
"""
85+
wrapped_params = {}
86+
87+
if "invoice_validation" in params:
88+
invoice_validation = params["invoice_validation"].copy()
89+
self._ensure_name_field(invoice_validation)
90+
wrapped_params["invoice_validation"] = invoice_validation
91+
92+
if "easypost_details" in params:
93+
wrapped_params["easypost_details"] = params["easypost_details"]
94+
95+
return wrapped_params
96+
97+
def _ensure_name_field(self, mapping: dict[str, Any]) -> None:
98+
"""Ensures the "name" field exists in the provided map.
99+
If not present, generates a UUID (with hyphens removed) as the name.
100+
This follows the pattern used in the web UI implementation.
101+
"""
102+
if "name" not in mapping or mapping["name"] is None:
103+
mapping["name"] = str(uuid.uuid4()).replace("-", "")

tests/test_fedex_registration.py

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
from unittest.mock import MagicMock
2+
3+
from easypost.easypost_object import EasyPostObject
4+
from easypost.models import CarrierAccount
5+
6+
7+
def test_register_address(prod_client, monkeypatch):
8+
"""Tests registering a billing address."""
9+
fedex_account_number = "123456789"
10+
address_validation = {
11+
"name": "BILLING NAME",
12+
"street1": "1234 BILLING STREET",
13+
"city": "BILLINGCITY",
14+
"state": "ST",
15+
"postal_code": "12345",
16+
"country_code": "US",
17+
}
18+
easypost_details = {"carrier_account_id": "ca_123"}
19+
params = {
20+
"address_validation": address_validation,
21+
"easypost_details": easypost_details,
22+
}
23+
24+
json_response = {
25+
"email_address": None,
26+
"options": ["SMS", "CALL", "INVOICE"],
27+
"phone_number": "***-***-9721",
28+
}
29+
30+
mock_requestor = MagicMock()
31+
mock_requestor.return_value.request.return_value = json_response
32+
33+
monkeypatch.setattr("easypost.services.fedex_registration_service.Requestor", mock_requestor)
34+
35+
response = prod_client.fedex_registration.register_address(fedex_account_number, **params)
36+
assert isinstance(response, EasyPostObject)
37+
assert response.email_address is None
38+
assert "SMS" in response.options
39+
assert "CALL" in response.options
40+
assert "INVOICE" in response.options
41+
assert response.phone_number == "***-***-9721"
42+
43+
44+
def test_request_pin(prod_client, monkeypatch):
45+
"""Tests requesting a pin."""
46+
fedex_account_number = "123456789"
47+
48+
json_response = {"message": "sent secured Pin"}
49+
50+
mock_requestor = MagicMock()
51+
mock_requestor.return_value.request.return_value = json_response
52+
53+
monkeypatch.setattr("easypost.services.fedex_registration_service.Requestor", mock_requestor)
54+
55+
response = prod_client.fedex_registration.request_pin(fedex_account_number, "SMS")
56+
assert isinstance(response, EasyPostObject)
57+
assert response.message == "sent secured Pin"
58+
59+
60+
def test_validate_pin(prod_client, monkeypatch):
61+
"""Tests validating a pin."""
62+
fedex_account_number = "123456789"
63+
pin_validation = {
64+
"pin_code": "123456",
65+
"name": "BILLING NAME",
66+
}
67+
easypost_details = {"carrier_account_id": "ca_123"}
68+
params = {
69+
"pin_validation": pin_validation,
70+
"easypost_details": easypost_details,
71+
}
72+
73+
json_response = {
74+
"id": "ca_123",
75+
"object": "CarrierAccount",
76+
"type": "FedexAccount",
77+
"credentials": {
78+
"account_number": "123456789",
79+
"mfa_key": "123456789-XXXXX",
80+
},
81+
}
82+
83+
mock_requestor = MagicMock()
84+
mock_requestor.return_value.request.return_value = json_response
85+
86+
monkeypatch.setattr("easypost.services.fedex_registration_service.Requestor", mock_requestor)
87+
88+
response = prod_client.fedex_registration.validate_pin(fedex_account_number, **params)
89+
assert isinstance(response, CarrierAccount)
90+
assert response.id == "ca_123"
91+
assert response.object == "CarrierAccount"
92+
assert response.type == "FedexAccount"
93+
assert response.credentials["account_number"] == "123456789"
94+
assert response.credentials["mfa_key"] == "123456789-XXXXX"
95+
96+
97+
def test_submit_invoice(prod_client, monkeypatch):
98+
"""Tests submitting details about an invoice."""
99+
fedex_account_number = "123456789"
100+
invoice_validation = {
101+
"name": "BILLING NAME",
102+
"invoice_number": "INV-12345",
103+
"invoice_date": "2025-12-08",
104+
"invoice_amount": "100.00",
105+
"invoice_currency": "USD",
106+
}
107+
easypost_details = {"carrier_account_id": "ca_123"}
108+
params = {
109+
"invoice_validation": invoice_validation,
110+
"easypost_details": easypost_details,
111+
}
112+
113+
json_response = {
114+
"id": "ca_123",
115+
"object": "CarrierAccount",
116+
"type": "FedexAccount",
117+
"credentials": {
118+
"account_number": "123456789",
119+
"mfa_key": "123456789-XXXXX",
120+
},
121+
}
122+
123+
mock_requestor = MagicMock()
124+
mock_requestor.return_value.request.return_value = json_response
125+
126+
monkeypatch.setattr("easypost.services.fedex_registration_service.Requestor", mock_requestor)
127+
128+
response = prod_client.fedex_registration.submit_invoice(fedex_account_number, **params)
129+
assert isinstance(response, CarrierAccount)
130+
assert response.id == "ca_123"
131+
assert response.object == "CarrierAccount"
132+
assert response.type == "FedexAccount"
133+
assert response.credentials["account_number"] == "123456789"
134+
assert response.credentials["mfa_key"] == "123456789-XXXXX"

0 commit comments

Comments
 (0)