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'); } 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')); // 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')], ], ], ], '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'), ], ]; 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; } // 2. Identify Service $carrierId = $params->id_carrier; $sql = 'SELECT code FROM `' . _DB_PREFIX_ . 'uspsl_method` WHERE id_carrier = ' . (int)$carrierId; $methodCode = Db::getInstance()->getValue($sql); if (!$methodCode) { return false; } // 3. Map Old Code to New API Enum $newApiClass = $this->mapServiceCodeToApiClass($methodCode); if (!$newApiClass) { $this->log("Mapping failed for legacy code: " . $methodCode); return false; } // 4. Pack Products $packedBoxes = $originalModule->getHelper()->getCarrierHelper()->packProducts($products, $params->id); if (empty($packedBoxes)) { $this->log("Box packer returned empty."); return false; } // 5. Initialize API Client $client = new UspsV3Client($token, (bool)Configuration::get('USPS_BRIDGE_LIVE_MODE')); $totalPrice = 0; // 6. Address Data $originZip = $this->getOriginZip($originalModule); $destAddress = new Address($params->id_address_delivery); $originZip = substr(preg_replace('/[^0-9]/', '', $originZip), 0, 5); $destZip = substr(preg_replace('/[^0-9]/', '', $destAddress->postcode), 0, 5); $isInternational = ($destAddress->id_country != Country::getByIso('US')); // 7. Loop through boxes foreach ($packedBoxes as $packedBox) { // Weight Conversion (Grams -> Pounds) $weightInLbs = $this->convertUnit($packedBox->getWeight(), 'g', 'lbs', 3); // USPS requires minimum 0.001 lbs. 0 causes errors. if ($weightInLbs <= 0) $weightInLbs = 0.1; // Dimensions (mm -> Inches) $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); // Determine Processing Category (Crucial for V3 API) // Machinable: Length <= 22", Width <= 18", Height <= 15", Weight >= 6oz (0.375lbs) and <= 25lbs $category = 'MACHINABLE'; if ($length > 22 || $width > 18 || $height > 15 || $weightInLbs > 25 || $weightInLbs < 0.375) { $category = 'NONSTANDARD'; } // Build Payload $payload = [ 'originZIPCode' => $originZip, 'weight' => $weightInLbs, 'length' => $length, 'width' => $width, 'height' => $height, 'mailClass' => $newApiClass, 'priceType' => 'COMMERCIAL', 'mailingDate' => date('Y-m-d', strtotime('+1 day')), 'processingCategory' => $category, 'rateIndicator' => 'SP' // Single Piece ]; // Flat Rate Override $flatRateIndicator = $this->mapBoxToRateIndicator($box->getReference()); if ($flatRateIndicator) { $payload['rateIndicator'] = $flatRateIndicator; // Dimensions technically ignored for Flat Rate, but required by API schema // Processing category usually irrelevant for Flat Rate but must be valid Enum } if ($isInternational) { $payload['destinationCountryCode'] = Country::getIsoById($destAddress->id_country); $payload['originZIPCode'] = $originZip; // Remove domestic specific fields unset($payload['destinationEntryFacilityType']); unset($payload['destinationZIPCode']); $response = $client->getInternationalRate($payload); } else { $payload['destinationZIPCode'] = $destZip; $payload['destinationEntryFacilityType'] = 'NONE'; $response = $client->getDomesticRate($payload); } // Log Payload on Error for Debugging if (isset($response['error'])) { $this->log("API Error: " . $response['error']); $this->log("Payload causing error: " . json_encode($payload)); return false; } // Parse Price if (isset($response['totalBasePrice'])) { $totalPrice += (float)$response['totalBasePrice']; } elseif (isset($response['rateOptions'][0]['totalBasePrice'])) { $totalPrice += (float)$response['rateOptions'][0]['totalBasePrice']; } else { $this->log("API Response missing price. Full response: " . json_encode($response)); return false; } } return $totalPrice + $shipping_cost; } /** * 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($oldCode) { // Mappings based on your provided file classes/Model/Method.php // and the New API Spec Enums $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', // INTERNATIONAL 'INT_1' => 'PRIORITY_MAIL_EXPRESS_INTERNATIONAL', 'INT_2' => 'PRIORITY_MAIL_INTERNATIONAL', 'INT_15' => 'FIRST-CLASS_PACKAGE_INTERNATIONAL_SERVICE', 'INT_4' => 'GLOBAL_EXPRESS_GUARANTEED' ]; return isset($map[$oldCode]) ? $map[$oldCode] : false; } /** * 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 } // ... (rest of the class from Step 1: OAuth logic, etc) /** * 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 ); } } }