Files
usps_api_bridge/usps_api_bridge.php
2026-01-21 09:55:35 +02:00

688 lines
26 KiB
PHP

<?php
if (!defined('_PS_VERSION_')) {
exit;
}
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
class Usps_Api_Bridge extends Module
{
// Runtime cache to prevent duplicate API calls during a single page load
private $apiRuntimeCache = [];
public function __construct()
{
$this->name = 'usps_api_bridge';
$this->tab = 'shipping_logistics';
$this->version = '1.0.1';
$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 = '';
$context = Context::getContext();
$isEstimator = (Tools::getValue('module') === 'zh_uspslabels' && Tools::getValue('controller') === 'carrier');
if ($isEstimator) {
// Prioritize POST data from the widget
$this->externalLog(['Estimator' => [
'postcode' => Tools::getValue('postcode'),
'id_country' => Tools::getValue('id_country'),
]]);
if (Tools::getIsset('postcode')) {
$destZip = Tools::getValue('postcode');
} elseif (isset($context->cookie->postcode)) {
$destZip = $context->cookie->postcode;
}
if (Tools::getIsset('id_country')) {
$destCountryIso = Country::getIsoById((int)Tools::getValue('id_country'));
} elseif (isset($context->cookie->id_country)) {
$destCountryIso = Country::getIsoById((int)$context->cookie->id_country);
}
}
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) && 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)) {
$this->externalLog([
'destCountryIso' => 'set to default',
]);
$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) {
$this->externalLog([
'mapServiceCodeToApiClass()' => 'failed',
'methodCode' => $methodCode,
'isInternational' => $isInternational,
]);
return false;
}
// 6. Pack Products
$packedBoxes = $originalModule->getHelper()->getCarrierHelper()->packProducts($products, $params->id);
if (empty($packedBoxes)) {
$this->externalLog([
'packProducts()' => 'failed',
'products' => $products,
'params->id' => $params->id,
]);
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(UspsV3Client $client, array $payload, bool $isInternational, string $destCountryIso, string $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->externalLog([
'refreshAccessToken()' => 'Token Request Failed [HTTP $statusCode]',
'data' => $data,
]);
$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;
}
$message['serverInfo'] = [
'controller' => Tools::getValue('controller'),
'domain' => Tools::getShopDomainSsl(),
'remoteAddr' => Tools::getRemoteAddr(),
'Usps_Api_Bridge_version' => $this->version
];
$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) {
$this->log($t->getMessage());
}
}
}