//- - - - - - - - - - START: usps_api_bridge/override/modules/zh_uspslabels/zh_uspslabels.php - - - - - - - - - -// active) { // 2. Check Debug IP Logic // If configured, only these IPs use the new API. // Everyone else continues using the old logic (parent). if ($bridge->isIpAllowed()) { // 3. Attempt to calculate rate via Bridge // We pass '$this' (the original module instance) to access its config settings $newRate = $bridge->calculateRate($params, $shipping_cost, $products, $this); // If Bridge returns a valid numeric rate, use it. // If it returns FALSE (api error, no token, etc), fall back to old logic. if ($newRate !== false && $newRate !== null) { return $newRate; } $bridge->externalLog(['calculateRate Failed' => ['params' => $params, 'shipping_cost' => $shipping_cost, 'products' => $products, 'this' => $this]]); $bridge->log("Bridge returned false/null. Falling back to Legacy API."); } } // 4. Fallback to Legacy Logic return parent::getPackageShippingCost($params, $shipping_cost, $products); } /** * Intercept the "Check API" button in Back Office */ public function ajaxProcessCheckApiConnection() { /** @var Usps_Api_Bridge $bridge */ $bridge = Module::getInstanceByName('usps_api_bridge'); if ($bridge && $bridge->active && $bridge->isIpAllowed()) { // We can implement a specific test function in the bridge later // For now, we just let it connect to OAuth // $bridge->testApiConnection(); // return; } parent::ajaxProcessCheckApiConnection(); } } //- - - - - - - - - - END: usps_api_bridge/override/modules/zh_uspslabels/zh_uspslabels.php - - - - - - - - - -// //- - - - - - - - - - START: usps_api_bridge/usps_api_bridge.php - - - - - - - - - -// name = 'usps_api_bridge'; $this->tab = 'shipping_logistics'; $this->version = '1.0.0'; $this->author = 'Panariga'; $this->need_instance = 0; $this->bootstrap = true; parent::__construct(); $this->displayName = $this->l('USPS API Bridge (OAuth2)'); $this->description = $this->l('Modern OAuth2 Bridge for the legacy ZH USPS Labels module.'); $this->confirmUninstall = $this->l('Are you sure? This will disable the connection to the new USPS API.'); } public function install() { return parent::install() && $this->registerHook('actionAdminControllerSetMedia') && // Just in case we need JS later Configuration::updateValue('USPS_BRIDGE_LIVE_MODE', 0) && Configuration::updateValue('USPS_BRIDGE_DEBUG_IPS', '') && Configuration::updateValue('USPS_BRIDGE_LOGGING', 1); } public function uninstall() { // Uninstall the override automatically to prevent errors return parent::uninstall() && Configuration::deleteByName('USPS_BRIDGE_CLIENT_ID') && Configuration::deleteByName('USPS_BRIDGE_CLIENT_SECRET') && Configuration::deleteByName('USPS_BRIDGE_ACCESS_TOKEN'); Configuration::deleteByName('USPS_BRIDGE_EXTERNAL_DEBUG_URL'); } public function getContent() { if (Tools::isSubmit('submitUspsBridgeConf')) { Configuration::updateValue('USPS_BRIDGE_CLIENT_ID', Tools::getValue('USPS_BRIDGE_CLIENT_ID')); Configuration::updateValue('USPS_BRIDGE_CLIENT_SECRET', Tools::getValue('USPS_BRIDGE_CLIENT_SECRET')); Configuration::updateValue('USPS_BRIDGE_LIVE_MODE', Tools::getValue('USPS_BRIDGE_LIVE_MODE')); Configuration::updateValue('USPS_BRIDGE_DEBUG_IPS', Tools::getValue('USPS_BRIDGE_DEBUG_IPS')); Configuration::updateValue('USPS_BRIDGE_LOGGING', Tools::getValue('USPS_BRIDGE_LOGGING')); Configuration::updateValue('USPS_BRIDGE_EXTERNAL_DEBUG_URL', Tools::getValue('USPS_BRIDGE_EXTERNAL_DEBUG_URL')); // Clear token on save to force refresh with new credentials Configuration::deleteByName('USPS_BRIDGE_ACCESS_TOKEN'); Configuration::deleteByName('USPS_BRIDGE_TOKEN_EXPIRY'); return $this->displayConfirmation($this->l('Settings updated & Token cache cleared')); } return $this->renderForm(); } public function renderForm() { $fields_form = [ 'form' => [ 'legend' => [ 'title' => $this->l('USPS OAuth2 Configuration'), 'icon' => 'icon-cogs', ], 'input' => [ [ 'type' => 'switch', 'label' => $this->l('Live Mode (Production API)'), 'name' => 'USPS_BRIDGE_LIVE_MODE', 'is_bool' => true, 'values' => [ ['id' => 'active_on', 'value' => 1, 'label' => $this->l('Yes')], ['id' => 'active_off', 'value' => 0, 'label' => $this->l('No')], ], ], [ 'type' => 'text', 'label' => $this->l('Consumer Key (Client ID)'), 'name' => 'USPS_BRIDGE_CLIENT_ID', 'required' => true, ], [ 'type' => 'text', 'label' => $this->l('Consumer Secret'), 'name' => 'USPS_BRIDGE_CLIENT_SECRET', 'required' => true, ], [ 'type' => 'textarea', 'label' => $this->l('Debug Allowed IPs'), 'name' => 'USPS_BRIDGE_DEBUG_IPS', 'desc' => $this->l('Comma separated IPs. If set, ONLY these IPs will use the New API. Everyone else uses the old module logic. Leave empty to enable for everyone.'), ], [ 'type' => 'switch', 'label' => $this->l('Enable API Logging'), 'name' => 'USPS_BRIDGE_LOGGING', 'is_bool' => true, 'values' => [ ['id' => 'active_on', 'value' => 1, 'label' => $this->l('Yes')], ['id' => 'active_off', 'value' => 0, 'label' => $this->l('No')], ], ], [ 'type' => 'textarea', 'label' => $this->l('External URL for Debug Log'), 'name' => 'USPS_BRIDGE_EXTERNAL_DEBUG_URL', 'desc' => $this->l(''), ], ], 'submit' => [ 'title' => $this->l('Save'), ], ], ]; $helper = new HelperForm(); $helper->show_toolbar = false; $helper->table = $this->table; $helper->module = $this; $helper->default_form_language = $this->context->language->id; $helper->identifier = $this->identifier; $helper->submit_action = 'submitUspsBridgeConf'; $helper->currentIndex = $this->context->link->getAdminLink('AdminModules', false) . '&configure=' . $this->name . '&tab_module=' . $this->tab . '&module_name=' . $this->name; $helper->token = Tools::getAdminTokenLite('AdminModules'); $helper->tpl_vars = [ 'fields_value' => [ 'USPS_BRIDGE_CLIENT_ID' => Configuration::get('USPS_BRIDGE_CLIENT_ID'), 'USPS_BRIDGE_CLIENT_SECRET' => Configuration::get('USPS_BRIDGE_CLIENT_SECRET'), 'USPS_BRIDGE_LIVE_MODE' => Configuration::get('USPS_BRIDGE_LIVE_MODE'), 'USPS_BRIDGE_DEBUG_IPS' => Configuration::get('USPS_BRIDGE_DEBUG_IPS'), 'USPS_BRIDGE_LOGGING' => Configuration::get('USPS_BRIDGE_LOGGING'), 'USPS_BRIDGE_EXTERNAL_DEBUG_URL' => Configuration::get('USPS_BRIDGE_EXTERNAL_DEBUG_URL'), ], ]; return $helper->generateForm([$fields_form]); } public function calculateRate($params, $shipping_cost, $products, $originalModule) { require_once(dirname(__FILE__) . '/classes/UspsV3Client.php'); // 1. Get OAuth Token $token = $this->getAccessToken(); if (!$token) return false; $carrierId = (int)$originalModule->id_carrier; if (!$carrierId && isset($params->id_carrier)) { $carrierId = (int)$params->id_carrier; } // 3. Get Method Code $sql = 'SELECT code FROM `' . _DB_PREFIX_ . 'uspsl_method` WHERE id_carrier = ' . (int)$carrierId; $methodCode = \Db::getInstance()->getValue($sql); if (!$methodCode) return false; // --- 4. CHECK LEGACY DB CACHE --- $zhCache = false; $canCache = class_exists('\UspsPsLabels\Cache') && class_exists('\UspsPsLabels\CacheRate'); if ($canCache) { $zhCache = \UspsPsLabels\Cache::cacheCart($params->id); if (\Validate::isLoadedObject($zhCache)) { $sql = 'SELECT rate FROM `' . _DB_PREFIX_ . 'uspsl_cache_rate` WHERE id_cache = ' . (int)$zhCache->id . ' AND id_carrier = ' . (int)$carrierId; $cachedRate = \Db::getInstance()->getValue($sql); if ($cachedRate !== false && $cachedRate !== null) { return (float)$cachedRate + $shipping_cost; } } } // ------------------------------- // 5. Determine International Status & Address Data (Cookie/Object Hybrid) $destZip = ''; $destCountryIso = ''; if (!empty($params->id_address_delivery)) { $address = new Address($params->id_address_delivery); if (Validate::isLoadedObject($address)) { $destZip = $address->postcode; $destCountryIso = Country::getIsoById($address->id_country); } } if (empty($destZip) && Tools::getIsset('postcode')) { $destZip = Tools::getValue('postcode'); } if (empty($destCountryIso) && Tools::getIsset('id_country')) { $destCountryIso = Country::getIsoById((int)Tools::getValue('id_country')); } $context = Context::getContext(); if (empty($destZip) && isset($context->cookie->postcode)) { $destZip = $context->cookie->postcode; } if (empty($destCountryIso) && isset($context->cookie->id_country)) { $destCountryIso = Country::getIsoById((int)$context->cookie->id_country); } else if (empty($destCountryIso) && isset($params->id_country)) { $destCountryIso = Country::getIsoById((int)$params->id_country); } if (empty($destCountryIso)) { $destCountryIso = 'US'; } if (empty($destZip)) { return false; } // Clean Data $originZip = $this->getOriginZip($originalModule); $originZip = substr(preg_replace('/[^0-9]/', '', $originZip), 0, 5); $destZip = substr(preg_replace('/[^0-9]/', '', $destZip), 0, 5); $isInternational = ($destCountryIso !== 'US'); // Map Code $newApiClass = $this->mapServiceCodeToApiClass($methodCode, $isInternational); if (!$newApiClass) return false; // 6. Pack Products $packedBoxes = $originalModule->getHelper()->getCarrierHelper()->packProducts($products, $params->id); if (empty($packedBoxes)) return false; // 7. Setup Client $client = new UspsV3Client($token, (bool)\Configuration::get('USPS_BRIDGE_LIVE_MODE')); $totalPrice = 0; $legacyPriceSetting = (int)\Configuration::get('USPSL_COMMERCIAL'); $requestedPriceType = ($legacyPriceSetting > 0) ? 'COMMERCIAL' : 'RETAIL'; // 8. Loop through boxes foreach ($packedBoxes as $packedBox) { $weightInLbs = $this->convertUnit($packedBox->getWeight(), 'g', 'lbs', 3); if ($weightInLbs < 0.1) $weightInLbs = 0.1; $box = $packedBox->getBox(); $length = $this->convertUnit($box->getOuterLength(), 'mm', 'in', 2); $width = $this->convertUnit($box->getOuterWidth(), 'mm', 'in', 2); $height = $this->convertUnit($box->getOuterDepth(), 'mm', 'in', 2); $category = 'NONSTANDARD'; if ($newApiClass === 'USPS_GROUND_ADVANTAGE') { if ($length <= 22 && $width <= 18 && $height <= 15 && $weightInLbs >= 0.375 && $weightInLbs <= 25) { $category = 'MACHINABLE'; } } $payload = [ 'originZIPCode' => $originZip, 'weight' => $weightInLbs, 'length' => $length, 'width' => $width, 'height' => $height, 'mailClass' => $newApiClass, 'priceType' => $requestedPriceType, 'mailingDate' => date('Y-m-d', strtotime('+1 day')), 'processingCategory' => $category, 'rateIndicator' => 'SP' ]; $flatRateIndicator = $this->mapBoxToRateIndicator($box->getReference()); if ($flatRateIndicator) { $payload['rateIndicator'] = $flatRateIndicator; } $response = $this->sendApiRequest($client, $payload, $isInternational, $destCountryIso, $destZip); if (isset($response['error']) && $payload['priceType'] === 'COMMERCIAL') { $payload['priceType'] = 'RETAIL'; $response = $this->sendApiRequest($client, $payload, $isInternational, $destCountryIso, $destZip); } if (isset($response['error'])) { // $this->log("API Fatal Error: " . $response['error']); return false; } if (isset($response['totalBasePrice'])) { $totalPrice += (float)$response['totalBasePrice']; } elseif (isset($response['rateOptions'][0]['totalBasePrice'])) { $totalPrice += (float)$response['rateOptions'][0]['totalBasePrice']; } else { return false; } } // --- 9. SAVE TO LEGACY DB CACHE --- if ($canCache && \Validate::isLoadedObject($zhCache)) { $newCacheRate = new \UspsPsLabels\CacheRate(); $newCacheRate->id_cache = $zhCache->id; $newCacheRate->id_carrier = $carrierId; $newCacheRate->code = $methodCode; $newCacheRate->rate = $totalPrice; $newCacheRate->save(); } // ---------------------------------- return $totalPrice + $shipping_cost; } /** * Helper to send request with Runtime Caching & Domestic/Intl switching */ private function sendApiRequest($client, $payload, $isInternational, $destCountryIso, $destZip) { // 1. Prepare the specific payload for the cache key // We simulate the modifications we are about to do to ensure the key is accurate $cachePayload = $payload; $cachePayload['destinationEntryFacilityType'] = 'NONE'; if ($isInternational) { $cachePayload['destinationCountryCode'] = $destCountryIso; // Use string directly $cachePayload['originZIPCode'] = $payload['originZIPCode']; // Ensure consistency // unset($cachePayload['destinationEntryFacilityType']); unset($cachePayload['destinationZIPCode']); $endpointType = 'INT'; } else { $cachePayload['destinationZIPCode'] = $destZip; // $cachePayload['destinationEntryFacilityType'] = 'NONE'; $endpointType = 'DOM'; } // 2. Generate Hash // We include the endpoint type to ensure uniqueness $cacheKey = md5(json_encode($cachePayload) . $endpointType); // 3. Check Cache if (isset($this->apiRuntimeCache[$cacheKey])) { return $this->apiRuntimeCache[$cacheKey]; } $this->externalLog(['sendApiRequest' => ['payload' => $payload, 'isInternational' => $isInternational, 'destCountryIso' => $destCountryIso, 'destZip' => $destZip]]); // 4. Perform Request if ($isInternational) { $response = $client->getInternationalRate($cachePayload); } else { $response = $client->getDomesticRate($cachePayload); } $this->externalLog(['sendApiRequest' => ['response' => $response]]); // 5. Determine if we should cache // We DO cache API errors (like 400 Bad Request) because retrying them won't fix invalid data. // We DO NOT cache Network/Transport errors (timeouts) so they can be retried. $shouldCache = true; if (isset($response['error'])) { // Check for Guzzle/Symfony Transport errors strings defined in UspsV3Client if ( strpos($response['error'], 'Network') !== false || strpos($response['error'], 'Connection') !== false || strpos($response['error'], 'Transport') !== false ) { $shouldCache = false; } } if ($shouldCache) { $this->apiRuntimeCache[$cacheKey] = $response; } return $response; } /** * Simple Unit Converter to replace the dependency on the old module's class */ private function convertUnit($value, $from, $to, $precision = 2) { $units = [ 'lb' => 453.59237, 'lbs' => 453.59237, 'oz' => 28.3495231, 'kg' => 1000, 'g' => 1, 'in' => 25.4, 'cm' => 10, 'mm' => 1 ]; // Normalize to base unit (grams or mm) $baseValue = $value * (isset($units[$from]) ? $units[$from] : 1); // Convert to target unit $converted = $baseValue / (isset($units[$to]) ? $units[$to] : 1); return round($converted, $precision); } /** * Helper: Get Origin Zip from Old Module DB */ private function getOriginZip($originalModule) { // The old module stores addresses in `ps_uspsl_address` // We look for the one marked 'origin' = 1 $sql = 'SELECT postcode FROM `' . _DB_PREFIX_ . 'uspsl_address` WHERE origin = 1 AND active = 1'; $zip = Db::getInstance()->getValue($sql); return $zip ? $zip : '90210'; // Fallback if configuration is missing } /** * MAPPING LOGIC: Old Module Codes -> New API Enums */ private function mapServiceCodeToApiClass(string $oldCode, bool $isInternational) { // $this->externalLog(['mapServiceCodeToApiClass' => ['oldCode' => $oldCode, 'isInternational' => $isInternational]]); // 1. Define the Standard Map if ($isInternational) { $map = [ // INTERNATIONAL 'INT_1' => 'PRIORITY_MAIL_EXPRESS_INTERNATIONAL', 'INT_2' => 'PRIORITY_MAIL_INTERNATIONAL', 'INT_15' => 'FIRST-CLASS_PACKAGE_INTERNATIONAL_SERVICE', 'INT_4' => 'FIRST-CLASS_PACKAGE_INTERNATIONAL_SERVICE', // GXG is suspended/retired, fallback to First Class ]; } else { $map = [ // DOMESTIC 'USA_0' => 'USPS_GROUND_ADVANTAGE', // Was First-Class 'USA_1' => 'PRIORITY_MAIL', 'USA_3' => 'PRIORITY_MAIL_EXPRESS', 'USA_6' => 'MEDIA_MAIL', 'USA_7' => 'LIBRARY_MAIL', 'USA_1058' => 'USPS_GROUND_ADVANTAGE', ]; } if (!isset($map[$oldCode])) { return false; } $apiClass = $map[$oldCode]; // 2. International Override Logic // If the destination is International, but the mapped class is Domestic, swap it. /* if ($isInternational) { switch ($apiClass) { case 'PRIORITY_MAIL': return 'PRIORITY_MAIL_INTERNATIONAL'; case 'PRIORITY_MAIL_EXPRESS': return 'PRIORITY_MAIL_EXPRESS_INTERNATIONAL'; // Ground Advantage, Media, and Library do not exist internationally. // The closest equivalent is First-Class Package International. case 'USPS_GROUND_ADVANTAGE': case 'MEDIA_MAIL': case 'LIBRARY_MAIL': return 'FIRST-CLASS_PACKAGE_INTERNATIONAL_SERVICE'; } } */ return $apiClass; } /** * MAPPING LOGIC: Flat Rate Boxes * Maps the internal name of the box to the API 'rateIndicator' */ private function mapBoxToRateIndicator($boxReference) { // You provided the PredefinedBox.php file earlier. // We map those names to New API 'rateIndicator' enum. // Example Reference: "USPS Medium Flat Rate Box" or "MediumFlatRateBox" // We do a loose match if (stripos($boxReference, 'Medium Flat Rate Box') !== false) return 'FB'; if (stripos($boxReference, 'Large Flat Rate Box') !== false) return 'PL'; if (stripos($boxReference, 'Small Flat Rate Box') !== false) return 'FS'; if (stripos($boxReference, 'Flat Rate Envelope') !== false) return 'FE'; if (stripos($boxReference, 'Padded Flat Rate Envelope') !== false) return 'FP'; if (stripos($boxReference, 'Legal Flat Rate Envelope') !== false) return 'FA'; return false; // Not a flat rate box, uses standard rates } /** * Manages OAuth2 Token life cycle */ private function getAccessToken() { $token = Configuration::get('USPS_BRIDGE_ACCESS_TOKEN'); $expiry = Configuration::get('USPS_BRIDGE_TOKEN_EXPIRY'); // Add 60 seconds buffer if ($token && $expiry > (time() + 60)) { return $token; } return $this->refreshAccessToken(); } private function refreshAccessToken() { $clientId = Configuration::get('USPS_BRIDGE_CLIENT_ID'); $clientSecret = Configuration::get('USPS_BRIDGE_CLIENT_SECRET'); $isLive = (bool)Configuration::get('USPS_BRIDGE_LIVE_MODE'); // CORRECT URLs based on the OpenAPI Spec provided: // Prod: https://apis.usps.com/oauth2/v3 // Test: https://apis-tem.usps.com/oauth2/v3 $url = $isLive ? 'https://apis.usps.com/oauth2/v3/token' : 'https://apis-tem.usps.com/oauth2/v3/token'; $this->log("Requesting New Token from: " . $url); // Create Symfony Client $client = HttpClient::create([ 'timeout' => 10, 'verify_peer' => true, // Set to true in strict production environments 'verify_host' => false, ]); try { $response = $client->request('POST', $url, [ 'headers' => [ 'Content-Type' => 'application/json', 'Accept' => 'application/json', ], // 'json' key automatically encodes the array to JSON and sets Content-Type 'json' => [ 'client_id' => $clientId, 'client_secret' => $clientSecret, 'grant_type' => 'client_credentials', // 'scope' => 'prices international-prices' // Specifying scope helps avoid ambiguity ], ]); // Get status code $statusCode = $response->getStatusCode(); // Convert response to array (pass false to prevent throwing exceptions on 4xx/5xx) $data = $response->toArray(false); if ($statusCode == 200 && isset($data['access_token'])) { $expiresIn = isset($data['expires_in']) ? (int)$data['expires_in'] : 3599; Configuration::updateValue('USPS_BRIDGE_ACCESS_TOKEN', $data['access_token']); Configuration::updateValue('USPS_BRIDGE_TOKEN_EXPIRY', time() + $expiresIn); $this->log("Token refreshed successfully."); return $data['access_token']; } // Log detailed error from USPS $this->log("Token Request Failed [HTTP $statusCode]: " . json_encode($data)); } catch (\Exception $e) { $this->log("Symfony HTTP Client Error: " . $e->getMessage()); } return false; } /** * Check if current visitor IP is allowed to use New API */ public function isIpAllowed() { $allowedIps = Configuration::get('USPS_BRIDGE_DEBUG_IPS'); // If empty, everyone is allowed (Production ready) if (empty($allowedIps)) { return true; } $ips = array_map('trim', explode(',', $allowedIps)); $currentIp = Tools::getRemoteAddr(); $allowed = in_array($currentIp, $ips); if (!$allowed) { // Optional: Log that we skipped logic due to IP (might be too spammy) // $this->log("IP $currentIp not in debug list. Using Old API."); } return $allowed; } public function log($message) { if (Configuration::get('USPS_BRIDGE_LOGGING')) { PrestaShopLogger::addLog( '[USPS-BRIDGE] ' . (is_array($message) ? json_encode($message) : $message), 1, null, 'Usps_Api_Bridge', 1, true ); } } public function externalLog(array $message) { if (!Validate::isUrl(Configuration::get('USPS_BRIDGE_EXTERNAL_DEBUG_URL'))) { return; } $client = HttpClient::create([ 'timeout' => 10, 'verify_peer' => true, // Set to true in strict production environments ]); try { $response[] = $client->request('POST', Configuration::get('USPS_BRIDGE_EXTERNAL_DEBUG_URL'), [ 'headers' => [ 'Content-Type' => 'application/json', 'Accept' => 'application/json', ], 'json' => [ $message ], ]); } catch (TransportExceptionInterface $t) { } } } //- - - - - - - - - - END: usps_api_bridge/usps_api_bridge.php - - - - - - - - - -// //- - - - - - - - - - START: usps_api_bridge/config_uk.xml - - - - - - - - - -// usps_api_bridge 1 0 //- - - - - - - - - - END: usps_api_bridge/config_uk.xml - - - - - - - - - -// //- - - - - - - - - - START: usps_api_bridge/classes/UspsV3Client.php - - - - - - - - - -// token = $token; $this->isLive = $isLive; // Base URLs per OpenAPI Spec $this->baseUrl = $this->isLive ? 'https://apis.usps.com/prices/v3' : 'https://apis-tem.usps.com/prices/v3'; } /** * Get Domestic Rate (Rates v3) */ public function getDomesticRate($payload) { return $this->post('/base-rates/search', $payload); } /** * Get International Rate (Rates v3) */ public function getInternationalRate($payload) { // International endpoint uses a different base structure per spec $intlBaseUrl = $this->isLive ? 'https://apis.usps.com/international-prices/v3' : 'https://apis-tem.usps.com/international-prices/v3'; return $this->post('/base-rates/search', $payload, $intlBaseUrl); } /** * Internal POST logic using Symfony HTTP Client */ private function post($endpoint, $payload, $overrideUrl = null) { $url = ($overrideUrl ? $overrideUrl : $this->baseUrl) . $endpoint; $client = HttpClient::create([ 'timeout' => 15, 'verify_peer' => false, 'verify_host' => false, ]); try { $response = $client->request('POST', $url, [ 'headers' => [ 'Authorization' => 'Bearer ' . $this->token, 'Content-Type' => 'application/json', 'Accept' => 'application/json' ], 'json' => $payload ]); // toArray(false) prevents exception on 4xx/5xx responses so we can parse the error body $data = $response->toArray(false); $statusCode = $response->getStatusCode(); // Handle API Errors (400 Bad Request, 401 Unauthorized, etc) if ($statusCode >= 400) { $msg = isset($data['error']['message']) ? $data['error']['message'] : 'Unknown Error'; // Try to extract deeper error detail (e.g., from 'errors' array) if (isset($data['error']['errors'][0]['detail'])) { $msg .= ' - ' . $data['error']['errors'][0]['detail']; } elseif (isset($data['error']['code'])) { $msg .= ' (' . $data['error']['code'] . ')'; } return ['error' => "API HTTP $statusCode: $msg"]; } return $data; } catch (TransportExceptionInterface $e) { return ['error' => 'Network/Transport Error: ' . $e->getMessage()]; } catch (\Exception $e) { return ['error' => 'Client Error: ' . $e->getMessage()]; } } } //- - - - - - - - - - END: usps_api_bridge/classes/UspsV3Client.php - - - - - - - - - -// //- - - - - - - - - - START: usps_api_bridge/README.md - - - - - - - - - -// # USPS API Bridge (OAuth2) for PrestaShop ## Overview This module acts as a "Sidecar" or "Bridge" for the legacy **ZH USPS Labels (`zh_uspslabels`)** module. **The Problem:** The legacy module relies on the deprecated USPS Web Tools API (XML/User+Pass), which is being retired in favor of the new USPS Connect API (JSON/OAuth2). The original developer is unresponsive. **The Solution:** This module installs a PrestaShop **Override** that intercepts rate calculation requests destined for the old module. It routes them through this bridge, authenticates via OAuth2, queries the new USPS v3 API, and returns the rates to the cart seamlessly. It leaves the complex EasyPost logic (Label generation, Manifests) untouched. ## Prerequisites * **PrestaShop Version:** 1.7.x or 8.x. * **Legacy Module:** `zh_uspslabels` must be **installed and active**. Do not uninstall it; this bridge relies on its database tables (`ps_uspsl_box`, `ps_uspsl_method`) and packing logic. * **USPS Account:** A registered account on the [USPS Developer Portal](https://developer.usps.com/). ## Installation 1. **Upload:** * Zip the `usps_api_bridge` folder. * Upload via **Module Manager** in the Back Office. * *Alternatively:* FTP the folder to `/modules/usps_api_bridge/`. 2. **Install:** * Click **Install**. * **CRITICAL:** Upon installation, the module attempts to copy a file to `/override/modules/zh_uspslabels/zh_uspslabels.php`. Ensure your file permissions allow this. 3. **Clear Cache:** * Go to **Advanced Parameters > Performance** and click **Clear Cache**. * *Manual Step:* If the bridge does not seem to work, manually delete `/var/cache/prod/class_index.php` (or `/cache/class_index.php` on older PS versions) to force PrestaShop to register the new override. ## Configuration Go to **Module Manager > USPS API Bridge > Configure**. ### 1. Credentials You cannot use your old Web Tools username (e.g., `123XY...`). You must generate new OAuth keys: 1. Log in to [developer.usps.com](https://developer.usps.com/). 2. Go to **Apps** -> **Add App**. 3. **Important:** In the API Products list, ensure you select: * **Prices** (Domestic) * **International Prices** * **OAuth 2.0** 4. Copy the **Consumer Key** (Client ID) and **Consumer Secret**. 5. Paste them into the module configuration. ### 2. Deployment Settings * **Live Mode:** * `No`: Uses `https://apis-tem.usps.com` (Test Environment). * `Yes`: Uses `https://apis.usps.com` (Production). * *Note:* Your USPS App must be approved for Production access for Live Mode to work. * **Debug Allowed IPs:** * **Highly Recommended for Testing:** Enter your IP address here (comma-separated for multiple). * If set, **ONLY** visitors from these IPs will use the new API logic. Real customers will continue using the old module logic (until the USPS cutoff date). * Leave empty to go live for everyone. * **Enable Logging:** Logs all API requests/responses to **Advanced Parameters > Logs**. ## Architecture & Logic flow 1. **The Interceptor:** * The module overrides `Zh_UspsLabels::getPackageShippingCost`. * It checks if `usps_api_bridge` is active and if the user's IP is allowed. * If matched, it stops the legacy `CarrierHelper` -> `RateService` execution chain (which uses the broken XML API). 2. **The Bridge:** * It retrieves the active **Boxes** and **Methods** from the old module's database tables. * It re-uses the old module's `BoxPacker` logic to determine how many packages are needed. * It maps the legacy Service Codes (e.g., `USA_1`) to new USPS Enums (e.g., `PRIORITY_MAIL`). * It authenticates via `/oauth2/v3/token` (caching the token for 1 hour). * It sends a JSON payload to `/rates/v3/calculate`. 3. **EasyPost:** * Logic regarding Label Printing, Tracking, and Manifests handled by EasyPost is **not touched**. This traffic flows through the original module files as normal. ## Troubleshooting ### "API Connection Failed" in Config If you click "Test Connection" in the legacy module and it fails: * Ensure the Bridge module is active. * Ensure your IP is in the "Debug Allowed IPs" list. * The Bridge intercepts the test click; check the PrestaShop Logs for the specific error message from the new API. ### Rates are $0.00 or Not Showing 1. Enable **Logging** in the bridge module. 2. Check **Advanced Parameters > Logs**. 3. Common Errors: * `401 Unauthorized`: Your Client ID/Secret is wrong, or you selected the wrong environment (Live vs Test). * `400 Bad Request`: Usually due to invalid Zip Codes (USPS v3 requires 5-digit Zips for domestic) or invalid Enum mappings. ### Uninstalling If you uninstall this module, the Override is removed, and the shop reverts entirely to the legacy XML API code. //- - - - - - - - - - END: usps_api_bridge/README.md - - - - - - - - - -//