"""FedEx Ship API client — One Rate shipments. Handles OAuth token fetch and shipment creation. Returns a tracking number and a temp-file path to the PDF label, which the caller opens for printing. Phase 2 hook: swap _SHIP_URL / _TOKEN_URL to the sandbox URLs for testing. """ from __future__ import annotations import base64 import logging from dataclasses import dataclass import requests log = logging.getLogger(__name__) _TOKEN_URL_PROD = "https://apis.fedex.com/oauth/token" _SHIP_URL_PROD = "https://apis.fedex.com/ship/v1/shipments" _TOKEN_URL_SANDBOX = "https://apis-sandbox.fedex.com/oauth/token" _SHIP_URL_SANDBOX = "https://apis-sandbox.fedex.com/ship/v1/shipments" # Maps UI display strings → FedEx API codes _SERVICE_CODES: dict[str, str] = { "FedEx 2Day": "FEDEX_2_DAY", "FedEx 2Day AM": "FEDEX_2_DAY_AM", } _PACKAGE_CODES: dict[str, str] = { "FedEx Envelope": "FEDEX_ENVELOPE", "FedEx Pak": "FEDEX_PAK", } @dataclass class ShipmentResult: tracking_number: str label_pdf_path: str # absolute path to a temp PDF file class FedExClient: """Thin wrapper around the FedEx REST Ship API.""" def __init__( self, api_key: str, secret: str, account: str, shipper: dict, sandbox: bool = False, ) -> None: self._api_key = api_key self._secret = secret self._account = account self._shipper = shipper # keys: name, company, phone, address1, city, state, zip self._token_url = _TOKEN_URL_SANDBOX if sandbox else _TOKEN_URL_PROD self._ship_url = _SHIP_URL_SANDBOX if sandbox else _SHIP_URL_PROD # ── Public API ───────────────────────────────────────────────────────── def create_shipment(self, data: dict) -> ShipmentResult: """Create a One Rate shipment and return tracking number + label PDF path. `data` is the dict from ShippingTab._get_shipment_data(). Raises RuntimeError with a user-readable message on any failure. """ token = self._fetch_token() service = _SERVICE_CODES.get(data["service"], "FEDEX_2_DAY") pkg_type = _PACKAGE_CODES.get(data["package_type"], "FEDEX_PAK") street_lines = [data["address1"]] if data.get("address2"): street_lines.append(data["address2"]) payload = self._build_payload(data, service, pkg_type, street_lines) resp = requests.post( self._ship_url, json=payload, headers={ "Authorization": f"Bearer {token}", "Content-Type": "application/json", "X-locale": "en_US", }, timeout=30, ) if not resp.ok: raise RuntimeError(_format_api_error(resp)) return self._parse_response(resp.json()) # ── Private helpers ──────────────────────────────────────────────────── def _fetch_token(self) -> str: try: resp = requests.post( self._token_url, data={ "grant_type": "client_credentials", "client_id": self._api_key, "client_secret": self._secret, }, headers={"Content-Type": "application/x-www-form-urlencoded"}, timeout=15, ) resp.raise_for_status() return resp.json()["access_token"] except requests.RequestException as exc: raise RuntimeError( f"Could not connect to FedEx (authentication step):\n{exc}" ) from exc def _build_payload( self, data: dict, service: str, pkg_type: str, street_lines: list[str], ) -> dict: s = self._shipper payload: dict = { "labelResponseOptions": "LABEL", "accountNumber": {"value": self._account}, "requestedShipment": { "shipper": { "contact": { "personName": s.get("name", ""), "companyName": s.get("company", ""), "phoneNumber": s.get("phone", ""), }, "address": { "streetLines": [s.get("address1", "")], "city": s.get("city", ""), "stateOrProvinceCode": s.get("state", ""), "postalCode": s.get("zip", ""), "countryCode": "US", }, }, "recipients": [{ "contact": { "personName": data["name"], "companyName": data.get("company", ""), "phoneNumber": data["phone"], **({"emailAddress": data["email"]} if data.get("email") else {}), }, "address": { "streetLines": street_lines, "city": data["city"], "stateOrProvinceCode": data["state"], "postalCode": data["zip"], "countryCode": "US", "residential": False, }, }], "serviceType": service, "packagingType": pkg_type, "pickupType": "USE_SCHEDULED_PICKUP", "shippingChargesPayment": { "paymentType": "SENDER", "payor": { "responsibleParty": { "accountNumber": {"value": self._account}, }, }, }, "labelSpecification": { "labelFormatType": "COMMON2D", "imageType": "PDF", "labelStockType": "PAPER_LETTER", }, "requestedPackageLineItems": [{ "weight": {"units": "LB", "value": 1}, }], }, } return payload def _parse_response(self, result: dict) -> ShipmentResult: shipments = result.get("output", {}).get("transactionShipments", []) if not shipments: raise RuntimeError("FedEx accepted the request but returned no shipment data.") shipment = shipments[0] pieces = shipment.get("pieceResponses", [{}]) piece = pieces[0] if pieces else {} tracking = ( shipment.get("masterTrackingNumber") or piece.get("trackingNumber") or "" ) docs = piece.get("packageDocuments", [{}]) doc = docs[0] if docs else {} b64_pdf = doc.get("encodedLabel", "") if not b64_pdf: raise RuntimeError( f"Shipment created (tracking: {tracking}) but FedEx sent no label." ) from datetime import datetime from .paths import appdata_dir pdf_bytes = base64.b64decode(b64_pdf) labels_dir = appdata_dir() / "labels" labels_dir.mkdir(exist_ok=True) stamp = datetime.now().strftime("%Y%m%d_%H%M%S") label_path = labels_dir / f"FedEx_{tracking or 'label'}_{stamp}.pdf" label_path.write_bytes(pdf_bytes) log.info("FedEx shipment created: tracking=%s label=%s", tracking, label_path) return ShipmentResult(tracking_number=tracking, label_pdf_path=str(label_path)) # ── Error formatting ──────────────────────────────────────────────────────── def _format_api_error(resp: requests.Response) -> str: status = resp.status_code try: body = resp.json() errors = body.get("errors", []) msgs = [ f"{e.get('code', '')}: {e.get('message', '')}".strip(": ") for e in errors ] detail = " | ".join(msgs) if msgs else str(body) except Exception: detail = resp.text or "(no body)" hint = "" if status == 401: hint = "\n\nCheck that the API key and secret in config.json are correct." elif status == 400: hint = "\n\nCheck the recipient address — a required field may be missing." return f"FedEx returned HTTP {status}: {detail}{hint}"