commit c78ec341e67c008782864ea458c53d0bea4a993c Author: O K Date: Sun Dec 7 12:42:55 2025 +0200 first commit diff --git a/classes/UspsV3Client.php b/classes/UspsV3Client.php new file mode 100644 index 0000000..ca13a78 --- /dev/null +++ b/classes/UspsV3Client.php @@ -0,0 +1,81 @@ +token = $token; + $this->isLive = $isLive; + // URLs from the OpenAPI spec + $this->baseUrl = $this->isLive + ? 'https://apis.usps.com/prices/v3' + : 'https://apis-tem.usps.com/prices/v3'; + } + + /** + * Call Domestic Prices v3 API + * Endpoint: /base-rates/search + */ + public function getDomesticRate($payload) + { + return $this->post('/base-rates/search', $payload); + } + + /** + * Call International Prices v3 API + * Endpoint: /base-rates/search (Under International Base path) + * Note: The spec shows a different base URL for International + */ + public function getInternationalRate($payload) + { + // International uses a different base URL structure in the 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); + } + + private function post($endpoint, $payload, $overrideUrl = null) + { + $url = ($overrideUrl ? $overrideUrl : $this->baseUrl) . $endpoint; + + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $url); + curl_setopt($ch, CURLOPT_POST, 1); + curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload)); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_HTTPHEADER, [ + 'Authorization: Bearer ' . $this->token, + 'Content-Type: application/json', + 'Accept: application/json' + ]); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + + if (curl_errno($ch)) { + $error = curl_error($ch); + + return ['error' => 'CURL Error: ' . $error]; + } + + + $data = json_decode($response, true); + + // Check for HTTP errors (400, 401, 403, etc) + if ($httpCode >= 400) { + $msg = isset($data['error']['message']) ? $data['error']['message'] : 'Unknown Error'; + if (isset($data['error']['errors'][0]['detail'])) { + $msg .= ' - ' . $data['error']['errors'][0]['detail']; + } + return ['error' => "HTTP $httpCode: $msg"]; + } + + return $data; + } +} diff --git a/override/modules/zh_uspslabels/zh_uspslabels.php b/override/modules/zh_uspslabels/zh_uspslabels.php new file mode 100644 index 0000000..d450fea --- /dev/null +++ b/override/modules/zh_uspslabels/zh_uspslabels.php @@ -0,0 +1,61 @@ +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->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(); + } +} diff --git a/usps_api_bridge.php b/usps_api_bridge.php new file mode 100644 index 0000000..750fcfa --- /dev/null +++ b/usps_api_bridge.php @@ -0,0 +1,438 @@ +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]); + } + + /** + * THE CORE BRIDGE LOGIC + */ + 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 which Service (Method) PrestaShop is asking for + // The old module links PS Carrier ID -> Internal Method Code (e.g. "USA_1") + $carrierId = $params->id_carrier; + + // We query the OLD module's table directly to find the code for this carrier + $sql = 'SELECT code FROM `' . _DB_PREFIX_ . 'uspsl_method` WHERE id_carrier = ' . (int)$carrierId; + $methodCode = Db::getInstance()->getValue($sql); + + if (!$methodCode) { + // This carrier isn't a USPS carrier controlled by the module + return false; + } + + // 3. Map Old Code to New API Enum + $newApiClass = $this->mapServiceCodeToApiClass($methodCode); + if (!$newApiClass) { + $this->log("Mapping failed for code: " . $methodCode); + return false; + } + + // 4. Pack the Products (Using Old Module's Logic) + // This calculates how many boxes and their dimensions/weights + $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. Get Origin/Dest addresses + $originZip = $this->getOriginZip($originalModule); + $destAddress = new Address($params->id_address_delivery); + + // Clean zip codes (USPS V3 expects 5 digits for domestic) + $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 every box and get a rate + foreach ($packedBoxes as $packedBox) { + + // Convert Weight: Old module uses grams internally usually, spec needs Pounds/Ounces + // We assume packedBox->getWeight() is in grams based on typical PS behavior + // The old module has a UnitConverter class we can borrow + $weightInLbs = $originalModule->getHelper()->getUnitConverter()->convertUnitFromTo( + $packedBox->getWeight(), + 'g', + 'lbs', + 3 + ); + + // Get Dimensions (in Inches) + $box = $packedBox->getBox(); // This returns the Box object + // The Box object from BoxPacker library stores dims in mm usually, + // we need to convert to Inches. + $length = $originalModule->getHelper()->getUnitConverter()->convertUnitFromTo($box->getOuterLength(), 'mm', 'in', 2); + $width = $originalModule->getHelper()->getUnitConverter()->convertUnitFromTo($box->getOuterWidth(), 'mm', 'in', 2); + $height = $originalModule->getHelper()->getUnitConverter()->convertUnitFromTo($box->getOuterDepth(), 'mm', 'in', 2); + + // Build Payload + $payload = [ + 'originZIPCode' => $originZip, + 'weight' => $weightInLbs, + 'length' => $length, + 'width' => $width, + 'height' => $height, + 'mailClass' => $newApiClass, + 'priceType' => 'COMMERCIAL', // Defaulting to Commercial + 'mailingDate' => date('Y-m-d', strtotime('+1 day')), // Future date is safer + 'processingCategory' => 'MACHINABLE', // Defaulting to simple logic + 'rateIndicator' => 'SP' // Single Piece (Standard) + ]; + + // -- HANDLE FLAT RATES -- + // If the old box name contains "Flat Rate", we must map it to correct rateIndicator + // (e.g. "USPS Medium Flat Rate Box" -> "FB") + $rateIndicator = $this->mapBoxToRateIndicator($box->getReference()); // Assuming reference holds name + if ($rateIndicator) { + $payload['rateIndicator'] = $rateIndicator; + // Dimensions technically don't matter for Flat Rate, but API might require them anyway + } + + if ($isInternational) { + $payload['destinationCountryCode'] = Country::getIsoById($destAddress->id_country); + $payload['originZIPCode'] = $originZip; + // International API needs extra fields? Spec says originZIPCode is required. + + $response = $client->getInternationalRate($payload); + } else { + $payload['destinationZIPCode'] = $destZip; + $payload['destinationEntryFacilityType'] = 'NONE'; // Required by V3 + + $response = $client->getDomesticRate($payload); + } + + // Process Response + if (isset($response['error'])) { + $this->log("API Error: " . $response['error']); + return false; + } + + if (isset($response['totalBasePrice'])) { + $totalPrice += (float)$response['totalBasePrice']; + } elseif (isset($response['rateOptions'][0]['totalBasePrice'])) { + // Sometimes it returns an array of options + $totalPrice += (float)$response['rateOptions'][0]['totalBasePrice']; + } else { + $this->log("API Response missing price: " . print_r($response, true)); + return false; + } + } + + // Add handling fees if any from original module logic + return $totalPrice + $shipping_cost; + } + + /** + * 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'); + + // URLs based on documentation (Verification pending next step) + $url = $isLive + ? 'https://api.usps.com/oauth2/v3/token' + : 'https://api-cat.usps.com/oauth2/v3/token'; + + $this->log("Requesting New Token from: " . $url); + + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $url); + curl_setopt($ch, CURLOPT_POST, 1); + curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query([ + 'client_id' => $clientId, + 'client_secret' => $clientSecret, + 'grant_type' => 'client_credentials' + ])); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + + if (curl_errno($ch)) { + $this->log("CURL Error: " . curl_error($ch)); + + return false; + } + + + $data = json_decode($response, true); + + if ($httpCode == 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']; + } + + $this->log("Token Request Failed: " . print_r($response, true)); + 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 + ); + } + } +}