Skip to content

Commit 64a7801

Browse files
authored
feat(fedex): make pickupType passable via shipping options (#1095)
Adds fedex_pickup_type shipping option (DROPOFF_AT_FEDEX_LOCATION / CONTACT_FEDEX_TO_SCHEDULE / USE_SCHEDULED_PICKUP) wired into rate + shipment builders; default behavior unchanged. Co-authored-by: Chris Nolan <chrisnolan.ca+github@gmail.com>
1 parent 377049d commit 64a7801

7 files changed

Lines changed: 281 additions & 2 deletions

File tree

PRDs/FEDEX_SHIPMENT_PICKUP_TYPE.md

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
# Plan: FedEx Configurable `fedex_pickup_type` Option
2+
3+
FedEx's `pickupType` field (which describes how a shipper tenders a package — drop off, scheduled, or on-call) is hardcoded to `DROPOFF_AT_FEDEX_LOCATION` in both the rate and shipment request builders. This adds it as a standard FedEx `ShippingOption` so API consumers can send any of the three valid Ship API values. Default behaviour is unchanged (backward-compatible).
4+
5+
## Context
6+
7+
`pickupType` is hardcoded as `"DROPOFF_AT_FEDEX_LOCATION"` in both:
8+
9+
- `modules/connectors/fedex/karrio/providers/fedex/shipment/create.py` (line 293)
10+
- `modules/connectors/fedex/karrio/providers/fedex/rate.py` (line 214)
11+
12+
Valid FedEx Ship API values (from `ship-api.json`, `rate-api.json`, and the API Reference Guide `#pickuptypes` table):
13+
14+
| Enumeration | Description |
15+
| --------------------------- | ------------------------------------------------------------------ |
16+
| `DROPOFF_AT_FEDEX_LOCATION` | Shipment will be dropped off at a FedEx Location (current default) |
17+
| `CONTACT_FEDEX_TO_SCHEDULE` | FedEx will be contacted to request a pickup |
18+
| `USE_SCHEDULED_PICKUP` | Shipment will be picked up as part of a regular scheduled pickup |
19+
20+
Note: `ON_CALL`, `PACKAGE_RETURN_PROGRAM`, `REGULAR_STOP` are Pickup API values only — they do not belong in the shipment/rate request and should not be included in this option.
21+
22+
## Design Decisions
23+
24+
- **FedEx-only `ShippingOption`** — no SDK `ShipmentRequest` model changes
25+
- Unified `PickupRequest.pickup_type` (`one_time`/`daily`/`recurring`) is a _different_ concept (it schedules a carrier driver pickup event). The field we are adding answers "how does this shipment get to the carrier?" — set at shipment creation time.
26+
- `dpd_meta_dropoff_type` is a false cognate (controls label format at drop-off point, not tendering method) — not a reference pattern for this change
27+
- `help` text exposure in the API endpoint is out of scope — separate PR
28+
- Purolator (`PickupType`: `DropOff`/`PreScheduled`) and DHL Poland (`dropOffType`: `REGULAR_PICKUP`) also hardcode this concept — noted as future work, not in scope here
29+
- Default remains `DROPOFF_AT_FEDEX_LOCATION` — zero behaviour change for existing integrations
30+
31+
## Implementation Steps
32+
33+
### Step 1 — `units.py`: add `FedExPickupType` StrEnum
34+
35+
File: `modules/connectors/fedex/karrio/providers/fedex/units.py`
36+
37+
Add before the `ConnectionConfig` class:
38+
39+
```python
40+
class FedExPickupType(lib.StrEnum):
41+
"""How the shipper will tender the package to FedEx (Ship API / Rate API)."""
42+
# Shipper brings the package to a FedEx drop-off location
43+
dropoff_at_fedex_location = "DROPOFF_AT_FEDEX_LOCATION"
44+
# FedEx will be contacted to schedule a one-time pickup
45+
contact_fedex_to_schedule = "CONTACT_FEDEX_TO_SCHEDULE"
46+
# Package will be collected as part of a regular standing pickup schedule
47+
use_scheduled_pickup = "USE_SCHEDULED_PICKUP"
48+
```
49+
50+
### Step 2 — `units.py`: add `fedex_pickup_type` to `ShippingOption`
51+
52+
In the same file, add to the `ShippingOption` enum inside the delivery options group (near `fedex_saturday_delivery`):
53+
54+
```python
55+
fedex_pickup_type = lib.OptionEnum(
56+
"fedex_pickup_type",
57+
str,
58+
help=(
59+
"How the shipper will tender the package to FedEx. "
60+
"Valid values: DROPOFF_AT_FEDEX_LOCATION, CONTACT_FEDEX_TO_SCHEDULE, USE_SCHEDULED_PICKUP. "
61+
"Defaults to DROPOFF_AT_FEDEX_LOCATION."
62+
),
63+
meta=dict(category="DELIVERY_OPTIONS"),
64+
)
65+
```
66+
67+
### Step 3 — `shipment/create.py`: replace hardcoded `pickupType`
68+
69+
File: `modules/connectors/fedex/karrio/providers/fedex/shipment/create.py`, line 293
70+
71+
Replace:
72+
73+
```python
74+
pickupType="DROPOFF_AT_FEDEX_LOCATION",
75+
```
76+
77+
With:
78+
79+
```python
80+
pickupType=(options.fedex_pickup_type.state or "DROPOFF_AT_FEDEX_LOCATION"),
81+
```
82+
83+
### Step 4 — `rate.py`: replace hardcoded `pickupType`
84+
85+
File: `modules/connectors/fedex/karrio/providers/fedex/rate.py`, line 214
86+
87+
Replace:
88+
89+
```python
90+
pickupType="DROPOFF_AT_FEDEX_LOCATION",
91+
```
92+
93+
With:
94+
95+
```python
96+
pickupType=(options.fedex_pickup_type.state or "DROPOFF_AT_FEDEX_LOCATION"),
97+
```
98+
99+
### Step 5 — `i18n.py`: add translation entry
100+
101+
File: `modules/connectors/fedex/karrio/providers/fedex/i18n.py`
102+
103+
Add to `OPTION_NAME_TRANSLATIONS`:
104+
105+
```python
106+
"fedex_pickup_type": _("FedEx Pickup Type"),
107+
```
108+
109+
### Step 6 — `test_shipment.py`: add 2 new test methods + fixture constants
110+
111+
File: `modules/connectors/fedex/tests/fedex/test_shipment.py`
112+
113+
Add two fixture constants (minimal diffs of the existing `ShipmentRequest` fixture with only `pickupType` changed):
114+
115+
```python
116+
ShipmentUseScheduledPickupRequest = {
117+
... # copy of ShipmentRequest with "pickupType": "USE_SCHEDULED_PICKUP"
118+
}
119+
120+
ShipmentContactFedexPickupRequest = {
121+
... # copy of ShipmentRequest with "pickupType": "CONTACT_FEDEX_TO_SCHEDULE"
122+
}
123+
```
124+
125+
Add two new test methods to `TestFedExShipping`:
126+
127+
```python
128+
def test_create_shipment_request_with_use_scheduled_pickup(self):
129+
request = gateway.mapper.create_shipment_request(
130+
models.ShipmentRequest(**{**ShipmentPayload, "options": {"fedex_pickup_type": "USE_SCHEDULED_PICKUP"}})
131+
)
132+
self.assertEqual(request.serialize(), ShipmentUseScheduledPickupRequest)
133+
134+
def test_create_shipment_request_with_contact_fedex_pickup(self):
135+
request = gateway.mapper.create_shipment_request(
136+
models.ShipmentRequest(**{**ShipmentPayload, "options": {"fedex_pickup_type": "CONTACT_FEDEX_TO_SCHEDULE"}})
137+
)
138+
self.assertEqual(request.serialize(), ShipmentContactFedexPickupRequest)
139+
```
140+
141+
### Step 7 — `test_rate.py`: add 1 new test method + fixture constant
142+
143+
File: `modules/connectors/fedex/tests/fedex/test_rate.py`
144+
145+
Add one fixture constant (minimal diff of existing `RateRequest` with only `pickupType` changed):
146+
147+
```python
148+
RateUseScheduledPickupRequest = {
149+
... # copy of RateRequest with "pickupType": "USE_SCHEDULED_PICKUP"
150+
}
151+
```
152+
153+
Add one new test method to `TestFedExRating`:
154+
155+
```python
156+
def test_create_rate_request_with_use_scheduled_pickup(self):
157+
request = gateway.mapper.create_rate_request(
158+
models.RateRequest(**{**RatePayload, "options": {"fedex_pickup_type": "USE_SCHEDULED_PICKUP"}})
159+
)
160+
self.assertEqual(request.serialize(), RateUseScheduledPickupRequest)
161+
```
162+
163+
## Files Changed
164+
165+
| File | Change |
166+
| -------------------------------------------------------------------- | --------------------------------------------------------------- |
167+
| `modules/connectors/fedex/karrio/providers/fedex/units.py` | Add `FedExPickupType` enum + `ShippingOption.fedex_pickup_type` |
168+
| `modules/connectors/fedex/karrio/providers/fedex/shipment/create.py` | Replace hardcoded `pickupType` at line 293 |
169+
| `modules/connectors/fedex/karrio/providers/fedex/rate.py` | Replace hardcoded `pickupType` at line 214 |
170+
| `modules/connectors/fedex/karrio/providers/fedex/i18n.py` | Add `"fedex_pickup_type"` translation |
171+
| `modules/connectors/fedex/tests/fedex/test_shipment.py` | Add 2 tests + 2 fixture constants |
172+
| `modules/connectors/fedex/tests/fedex/test_rate.py` | Add 1 test + 1 fixture constant |
173+
174+
## Verification
175+
176+
```bash
177+
source bin/activate-env
178+
python -m unittest discover -v -f modules/connectors/fedex/tests
179+
```
180+
181+
All 7 existing tests must pass unchanged — confirming `DROPOFF_AT_FEDEX_LOCATION` remains the default when no option is supplied.
182+
183+
The 3 new tests each call `request.serialize()` and directly assert `"pickupType"` in the resulting dict equals the option value passed.
184+
185+
## Future Work (out of scope)
186+
187+
- Expose `OptionEnum.help` through `GET /v1/carriers/fedex/options` API endpoint
188+
- Add equivalent `purolator_pickup_type` option to Purolator connector (`DropOff`/`PreScheduled`)
189+
- Add equivalent `dhl_poland_dropoff_type` option to DHL Poland connector
190+
- Consider a unified `tendering_type` field on `ShipmentRequest` once all three carriers are done
191+
- The 'create_label` core module for the Dashboard only renders boolean "CheckBoxField" options and skips other options so we can't see this option via the Dashboard atm

modules/connectors/fedex/karrio/providers/fedex/i18n.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@
8484
"fedex_one_rate": _("FedEx One Rate"),
8585
"fedex_freight_guarantee": _("FedEx Freight Guarantee"),
8686
"fedex_saturday_delivery": _("FedEx Saturday Delivery"),
87+
"fedex_pickup_type": _("FedEx Pickup Type"),
8788
"fedex_smart_post_hub_id": _("FedEx Smart Post Hub ID"),
8889
"fedex_smart_post_allowed_indicia": _("FedEx Smart Post Allowed Indicia"),
8990
"fedex_alcohol": _("FedEx Alcohol"),

modules/connectors/fedex/karrio/providers/fedex/rate.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,7 @@ def rate_request(
211211
preferredCurrency=default_currency,
212212
rateRequestType=request_types,
213213
shipDateStamp=lib.fdate(shipment_date, "%Y-%m-%d"),
214-
pickupType="DROPOFF_AT_FEDEX_LOCATION",
214+
pickupType=(options.fedex_pickup_type.state or "DROPOFF_AT_FEDEX_LOCATION"),
215215
requestedPackageLineItems=[
216216
fedex.RequestedPackageLineItemType(
217217
subPackagingType=lib.identity(

modules/connectors/fedex/karrio/providers/fedex/shipment/create.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,7 @@ def shipment_request(
290290
)
291291
],
292292
recipientLocationNumber=None,
293-
pickupType="DROPOFF_AT_FEDEX_LOCATION",
293+
pickupType=(options.fedex_pickup_type.state or "DROPOFF_AT_FEDEX_LOCATION"),
294294
serviceType=service,
295295
packagingType=lib.identity(
296296
provider_units.PackagingType.map(

modules/connectors/fedex/karrio/providers/fedex/units.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,14 @@ class PaymentType(lib.Enum):
232232
third_party = "THIRD_PARTY"
233233

234234

235+
class FedExPickupType(lib.StrEnum):
236+
"""How the shipper will tender the package to FedEx (Ship API / Rate API)."""
237+
238+
dropoff_at_fedex_location = "DROPOFF_AT_FEDEX_LOCATION"
239+
contact_fedex_to_schedule = "CONTACT_FEDEX_TO_SCHEDULE"
240+
use_scheduled_pickup = "USE_SCHEDULED_PICKUP"
241+
242+
235243
class ConnectionConfig(lib.Enum):
236244
label_type = lib.OptionEnum("label_type", LabelType)
237245
smart_post_hub_id = lib.OptionEnum("smart_post_hub_id")
@@ -327,6 +335,16 @@ class ShippingOption(lib.Enum):
327335
fedex_one_rate = lib.OptionEnum("FEDEX_ONE_RATE", bool)
328336
fedex_freight_guarantee = lib.OptionEnum("FREIGHT_GUARANTEE", bool)
329337
fedex_saturday_delivery = lib.OptionEnum("SATURDAY_DELIVERY", bool, meta=dict(category="DELIVERY_OPTIONS"))
338+
fedex_pickup_type = lib.OptionEnum(
339+
"fedex_pickup_type",
340+
str,
341+
help=(
342+
"How the shipper will tender the package to FedEx. "
343+
"Valid values: DROPOFF_AT_FEDEX_LOCATION, CONTACT_FEDEX_TO_SCHEDULE, USE_SCHEDULED_PICKUP. "
344+
"Defaults to DROPOFF_AT_FEDEX_LOCATION."
345+
),
346+
meta=dict(category="DELIVERY_OPTIONS"),
347+
)
330348
fedex_smart_post_hub_id = lib.OptionEnum("SMART_POST_HUB_ID")
331349
fedex_smart_post_allowed_indicia = lib.OptionEnum("SMART_POST_ALLOWED_INDICIA")
332350

modules/connectors/fedex/tests/fedex/test_rate.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,21 @@ def test_create_rate_request(self):
1717

1818
self.assertEqual(request.serialize(), RateRequest)
1919

20+
def test_create_rate_request_with_use_scheduled_pickup(self):
21+
request = gateway.mapper.create_rate_request(
22+
models.RateRequest(
23+
**{
24+
**RatePayload,
25+
"options": {
26+
**RatePayload["options"],
27+
"fedex_pickup_type": "USE_SCHEDULED_PICKUP",
28+
},
29+
}
30+
)
31+
)
32+
33+
self.assertEqual(request.serialize(), RateUseScheduledPickupRequest)
34+
2035
def test_get_rate(self):
2136
with patch("karrio.mappers.fedex.proxy.lib.request") as mock:
2237
mock.return_value = "{}"
@@ -344,6 +359,14 @@ def test_rate_request_contains_semantic_address_fields(self):
344359
},
345360
}
346361

362+
RateUseScheduledPickupRequest = {
363+
**RateRequest,
364+
"requestedShipment": {
365+
**RateRequest["requestedShipment"],
366+
"pickupType": "USE_SCHEDULED_PICKUP",
367+
},
368+
}
369+
347370
RateResponse = """{
348371
"transactionId": "4cbf8c73-61eb-4538-a65a-4830fef5d265",
349372
"output": {

modules/connectors/fedex/tests/fedex/test_shipment.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,36 @@ def test_create_shipment_request(self):
2929

3030
self.assertEqual(request.serialize(), ShipmentRequest)
3131

32+
def test_create_shipment_request_with_use_scheduled_pickup(self):
33+
request = gateway.mapper.create_shipment_request(
34+
models.ShipmentRequest(
35+
**{
36+
**ShipmentPayload,
37+
"options": {
38+
**ShipmentPayload["options"],
39+
"fedex_pickup_type": "USE_SCHEDULED_PICKUP",
40+
},
41+
}
42+
)
43+
)
44+
45+
self.assertEqual(request.serialize(), ShipmentUseScheduledPickupRequest)
46+
47+
def test_create_shipment_request_with_contact_fedex_pickup(self):
48+
request = gateway.mapper.create_shipment_request(
49+
models.ShipmentRequest(
50+
**{
51+
**ShipmentPayload,
52+
"options": {
53+
**ShipmentPayload["options"],
54+
"fedex_pickup_type": "CONTACT_FEDEX_TO_SCHEDULE",
55+
},
56+
}
57+
)
58+
)
59+
60+
self.assertEqual(request.serialize(), ShipmentContactFedexPickupRequest)
61+
3262
def test_create_shipment_request_paid_by_recipient(self):
3363
request = gateway.mapper.create_shipment_request(
3464
self.ShipmentPaidByRecipientRequest
@@ -480,6 +510,22 @@ def test_parse_return_shipment_response(self):
480510
"shipAction": "CONFIRM",
481511
}
482512

513+
ShipmentUseScheduledPickupRequest = {
514+
**ShipmentRequest,
515+
"requestedShipment": {
516+
**ShipmentRequest["requestedShipment"],
517+
"pickupType": "USE_SCHEDULED_PICKUP",
518+
},
519+
}
520+
521+
ShipmentContactFedexPickupRequest = {
522+
**ShipmentRequest,
523+
"requestedShipment": {
524+
**ShipmentRequest["requestedShipment"],
525+
"pickupType": "CONTACT_FEDEX_TO_SCHEDULE",
526+
},
527+
}
528+
483529
ShipmentPaidByRecipientRequest = {
484530
"accountNumber": {"value": "2349857"},
485531
"labelResponseOptions": "LABEL",

0 commit comments

Comments
 (0)