<?php declare(strict_types=1); namespace BobGroup\BobGo\Model\Carrier; use Magento\Catalog\Model\ResourceModel\Product\CollectionFactory; use Magento\CatalogInventory\Api\StockRegistryInterface; use Magento\Checkout\Api\Data\ShippingInformationInterface; use Magento\Directory\Helper\Data; use Magento\Directory\Model\CountryFactory; use Magento\Directory\Model\CurrencyFactory; use Magento\Directory\Model\RegionFactory; use Magento\Framework\App\Config\ScopeConfigInterface; use Magento\Framework\Controller\Result\JsonFactory; use Magento\Framework\DataObject; use Magento\Framework\Exception\LocalizedException; use Magento\Framework\HTTP\Client\CurlFactory; use Magento\Framework\Module\Dir\Reader; use Magento\Framework\Xml\Security; use Magento\Quote\Model\Quote\Address\RateRequest; use Magento\Quote\Model\Quote\Address\RateResult\ErrorFactory; use Magento\Quote\Model\Quote\Address\RateResult\MethodFactory; use Magento\Sales\Model\Order\Shipment; use Magento\Shipping\Model\Carrier\AbstractCarrier; use Magento\Shipping\Model\Carrier\AbstractCarrierOnline; use Magento\Shipping\Model\Rate\Result; use Magento\Shipping\Model\Rate\ResultFactory; use Magento\Shipping\Model\Simplexml\ElementFactory; use Magento\Shipping\Model\Tracking\Result\StatusFactory; use Magento\Store\Model\ScopeInterface; use Magento\Store\Model\StoreManagerInterface; use Psr\Log\LoggerInterface; use Magento\Framework\App\RequestInterface; use Magento\Framework\App\Request\Http as MagentoHttp; /** * Bob Go shipping implementation * @website https://www.bobgo.co.za * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) * @SuppressWarnings(PHPMD.CouplingBetweenObjects) * @SuppressWarnings(PHPMD.TooManyFields) */ class BobGo extends AbstractCarrierOnline implements \Magento\Shipping\Model\Carrier\CarrierInterface { /** * Code of the carrier * @var string */ public const CODE = 'bobgo'; /** * Units constant * @var int */ public const UNITS = 100; /** * Code of the carrier * * @var string */ protected $_code = self::CODE; /** * Rate request data * * @var RateRequest|null */ protected $_request = null; /** * Rate result data * * @var Result|null */ protected $_result = null; /** * Container types that could be customized for bobgo carrier * * @var string[] */ protected $_customizableContainerTypes = ['YOUR_PACKAGING']; /** * @var StoreManagerInterface */ protected StoreManagerInterface $_storeManager; /** * @var CollectionFactory */ protected CollectionFactory $_productCollectionFactory; /** * @var DataObject */ private DataObject $_rawTrackingRequest; /** * @var \Magento\Framework\HTTP\Client\Curl */ protected \Magento\Framework\HTTP\Client\Curl $curl; /** * @var ScopeConfigInterface */ protected ScopeConfigInterface $scopeConfig; /** * @var JsonFactory */ protected JsonFactory $jsonFactory; /** * @var AdditionalInfo */ public AdditionalInfo $additionalInfo; /** * @var MagentoHttp */ protected MagentoHttp $request; /** * BobGo constructor. * * @param ScopeConfigInterface $scopeConfig * @param ErrorFactory $rateErrorFactory * @param LoggerInterface $logger * @param Security $xmlSecurity * @param ElementFactory $xmlElFactory * @param ResultFactory $rateFactory * @param MethodFactory $rateMethodFactory * @param \Magento\Shipping\Model\Tracking\ResultFactory $trackFactory * @param \Magento\Shipping\Model\Tracking\Result\ErrorFactory $trackErrorFactory * @param StatusFactory $trackStatusFactory * @param RegionFactory $regionFactory * @param CountryFactory $countryFactory * @param CurrencyFactory $currencyFactory * @param Data $directoryData * @param StockRegistryInterface $stockRegistry * @param StoreManagerInterface $storeManager * @param CollectionFactory $productCollectionFactory * @param JsonFactory $jsonFactory * @param CurlFactory $curlFactory * @param MagentoHttp $request * @param array<string,mixed> $data */ public function __construct( ScopeConfigInterface $scopeConfig, ErrorFactory $rateErrorFactory, LoggerInterface $logger, Security $xmlSecurity, ElementFactory $xmlElFactory, ResultFactory $rateFactory, MethodFactory $rateMethodFactory, \Magento\Shipping\Model\Tracking\ResultFactory $trackFactory, \Magento\Shipping\Model\Tracking\Result\ErrorFactory $trackErrorFactory, StatusFactory $trackStatusFactory, RegionFactory $regionFactory, CountryFactory $countryFactory, CurrencyFactory $currencyFactory, Data $directoryData, StockRegistryInterface $stockRegistry, StoreManagerInterface $storeManager, CollectionFactory $productCollectionFactory, JsonFactory $jsonFactory, CurlFactory $curlFactory, MagentoHttp $request, array $data = [] ) { $this->request = $request; $this->_storeManager = $storeManager; $this->_productCollectionFactory = $productCollectionFactory; $this->scopeConfig = $scopeConfig; parent::__construct( $scopeConfig, $rateErrorFactory, $logger, $xmlSecurity, $xmlElFactory, $rateFactory, $rateMethodFactory, $trackFactory, $trackErrorFactory, $trackStatusFactory, $regionFactory, $countryFactory, $currencyFactory, $directoryData, $stockRegistry, $data ); $this->jsonFactory = $jsonFactory; $this->curl = $curlFactory->create(); $this->additionalInfo = new AdditionalInfo($countryFactory, $this->request); } /** * Gets the base URL of the store by stripping the http:// or https:// and www. from the URL. * * @return string */ public function getBaseUrl(): string { /** @var Store $store */ $store = $this->_storeManager->getStore(); $storeBase = $store->getBaseUrl(); // Remove protocol (http:// or https://) $host = preg_replace('#^https?://#', '', $storeBase); // Ensure $host is a string before using it in explode $host = $host ?? ''; // Remove everything after the host (e.g., paths, query strings) $host = explode('/', $host)[0]; // If the host starts with 'www.', remove it if (strpos($host, 'www.') === 0) { $host = substr($host, 4); } return $host; } /** * Makes a request to the Bob Go API to get shipping rates for the cart. * * @param array<string,mixed> $payload * @return array<int|string, mixed> */ public function getRates(array $payload): array { $rates = $this->uRates($payload); // Ensure the return value is always an array, even if uRates returns null return $rates ?? []; } /** * Processing additional validation to check if the carrier is applicable. * * @param DataObject $request * @return $this|bool|\Magento\Framework\DataObject */ public function processAdditionalValidation(DataObject $request) { /** @var RateRequest $rateRequest */ $rateRequest = $request; if (!count($this->getAllItems($rateRequest))) { return false; } $maxAllowedWeight = 500; $errorMsg = ''; $configErrorMsg = $this->getConfigData('specificerrmsg'); $defaultErrorMsg = __('The shipping module is not available.'); $showMethod = $this->getConfigData('showmethod'); /** @var Item $item */ foreach ($this->getAllItems($rateRequest) as $item) { $product = $item->getProduct(); if ($product && $product->getId()) { $weight = $product->getWeight(); $websiteId = (int) $item->getStore()->getWebsiteId(); // Ensure $websiteId is an integer $stockItemData = $this->stockRegistry->getStockItem($product->getId(), $websiteId); $doValidation = true; if ($stockItemData->getIsQtyDecimal() && $stockItemData->getIsDecimalDivided()) { if ($stockItemData->getEnableQtyIncrements() && $stockItemData->getQtyIncrements()) { $weight = $weight * $stockItemData->getQtyIncrements(); } else { $doValidation = false; } } elseif ($stockItemData->getIsQtyDecimal() && !$stockItemData->getIsDecimalDivided()) { $weight = $weight * $item->getQty(); } if ($doValidation && $weight > $maxAllowedWeight) { $errorMsg = $configErrorMsg ? $configErrorMsg : $defaultErrorMsg; break; } } } if (!$errorMsg && !$rateRequest->getDestPostcode() && $this->isZipCodeRequired($rateRequest->getDestCountryId())) { $errorMsg = __('This shipping method is not available. Please specify the zip code.'); } if ($rateRequest->getDestCountryId() == 'ZA') { $errorMsg = ''; } else { $errorMsg = $configErrorMsg ? $configErrorMsg : $defaultErrorMsg; } if ($errorMsg && $showMethod) { $error = $this->_rateErrorFactory->create(); $error->setCarrier($this->_code); $error->setCarrierTitle($this->getConfigData('title')); $error->setErrorMessage($errorMsg); return $error; } elseif ($errorMsg) { return false; } return $this; } /** * Collect and get rates for this shipping method based on information in $request. * * This is a default function that is called by Magento to get the shipping rates for the cart. * * @param RateRequest $request * @return Result|bool|null */ public function collectRates(RateRequest $request) { // Make sure that Shipping method is enabled if (!$this->isActive()) { return false; } /** * Gets the destination company name from Company Name field in the checkout page. * This method is used as the last resort to get the company name since the company name is * not available in _rateFactory. */ $destComp = $this->getDestComp(); $destSuburb = $this->getDestSuburb(); /** @var \Magento\Shipping\Model\Rate\Result $result */ $result = $this->_rateFactory->create(); $destination = $request->getDestPostcode(); $destCountry = $request->getDestCountryId(); $destRegion = $request->getDestRegionCode(); $destCity = $request->getDestCity(); $destStreet = $request->getDestStreet(); /** Destination Information */ [$destStreet1, $destStreet2, $destStreet3] = $this->destStreet($destStreet); /** Origin Information */ [ $originStreet, $originRegion, $originCountry, $originCity, $originStreet1, $originStreet2, $storeName, $baseIdentifier, $originSuburb, $weightUnit ] = $this->storeInformation(); // Ensure weightUnit is always a string $weightUnit = $weightUnit ?? ''; /** Get all items in cart */ $items = $request->getAllItems(); $itemsArray = []; $itemsArray = $this->getStoreItems($items, $weightUnit, $itemsArray); $payload = [ 'identifier' => $baseIdentifier, 'rate' => [ 'origin' => [ 'company' => $storeName, 'address1' => $originStreet1, 'address2' => $originStreet2, 'city' => $originCity, 'suburb' => $originSuburb, 'province' => $originRegion, 'country_code' => $originCountry, 'postal_code' => $originStreet, ], 'destination' => [ 'company' => $destComp, 'address1' => $destStreet1, 'address2' => $destStreet2, 'suburb' => $destSuburb, 'city' => $destCity, 'province' => $destRegion, 'country_code' => $destCountry, 'postal_code' => $destination, ], 'items' => $itemsArray, ] ]; $this->_getRates($payload, $result); return $result; } /** * Retrieves store information including origin details. * * @return array<int, string|null> */ public function storeInformation(): array { /** Store Origin details */ $originCountry = $this->getStringValue('general/store_information/country_id'); $originRegion = $this->getStringValue('general/store_information/region_id'); $originCity = $this->getStringValue('general/store_information/city'); $originStreet = $this->getStringValue('general/store_information/postcode'); $originStreet1 = $this->getStringValue('general/store_information/street_line1'); $originStreet2 = $this->getStringValue('general/store_information/street_line2'); $storeName = $this->getStringValue('general/store_information/name'); $originSuburb = $this->getStringValue('general/store_information/suburb'); $weightUnit = $this->getStringValue('general/locale/weight_unit'); $baseIdentifier = $this->getBaseUrl(); return [ $originStreet, $originRegion, $originCountry, $originCity, $originStreet1, $originStreet2, $storeName, $baseIdentifier, $originSuburb, $weightUnit, ]; } /** * Safely retrieve a configuration value as a string or null. * * @param string $path * @return string|null */ protected function getStringValue(string $path): ?string { $value = $this->_scopeConfig->getValue($path, ScopeInterface::SCOPE_STORE); return is_scalar($value) ? (string) $value : null; } /** * Get result of request * * @return Result|null */ public function getResult() { if (!$this->_result) { $this->_result = $this->_trackFactory->create(); } return $this->_result; } /** * Get final price for shipping method with handling fee per package * * @param float $cost * @param string $handlingType * @param float $handlingFee * @return float */ protected function _getPerpackagePrice($cost, $handlingType, $handlingFee) { if ($handlingType == AbstractCarrier::HANDLING_TYPE_PERCENT) { return $cost + $cost * $this->_numBoxes * $handlingFee / self::UNITS; } return $cost + $this->_numBoxes * $handlingFee; } /** * Get final price for shipping method with handling fee per order * * @param float $cost * @param string $handlingType * @param float $handlingFee * @return float */ protected function _getPerorderPrice($cost, $handlingType, $handlingFee) { if ($handlingType == self::HANDLING_TYPE_PERCENT) { return $cost + $cost * $handlingFee / self::UNITS; } return $cost + $handlingFee; } /** * Get configuration data of carrier * * @param string $type * @param string $code * @return array<string, \Magento\Framework\Phrase>|string|false * @SuppressWarnings(PHPMD.ExcessiveMethodLength) */ public function getCode($type, $code = '') { $codes = [ 'method' => [ 'bobGo' => __('BobGo'), ], 'unit_of_measure' => [ 'KGS' => __('Kilograms'), 'LBS' => __('Pounds'), ], ]; if (!isset($codes[$type])) { return false; } elseif ('' === $code) { return $codes[$type]; } if (!isset($codes[$type][$code])) { return false; } else { return (string) $codes[$type][$code]; // Convert \Magento\Framework\Phrase to string } } /** * Get tracking * * @param string|string[] $trackings * @return \Magento\Shipping\Model\Tracking\Result|null */ public function getTracking($trackings) { $this->setTrackingRequest(); // Ensure this method is correctly defined if (!is_array($trackings)) { $trackings = [$trackings]; } foreach ($trackings as $tracking) { $this->_getXMLTracking([$tracking]); // Ensure _getXMLTracking processes tracking correctly } return $this->_result; // Ensure _result is a \Magento\Shipping\Model\Tracking\Result } /** * Set tracking request * * @return void */ protected function setTrackingRequest() { $r = new \Magento\Framework\DataObject(); $account = $this->getConfigData('account'); $r->setData('account', $account); // Using setData with the key 'account' $this->_rawTrackingRequest = $r; } /** * Get tracking request * * @return \Magento\Framework\DataObject|null */ protected function getTrackingRequest(): ?\Magento\Framework\DataObject { return $this->_rawTrackingRequest; } /** * Send request for tracking * * @param string[] $tracking * @return void */ protected function _getXMLTracking($tracking) { $this->_parseTrackingResponse($tracking); } /** * Parse tracking response * * @param string|array<int,string> $trackingValue * @return void */ protected function _parseTrackingResponse($trackingValue) { $result = $this->getResult(); $carrierTitle = $this->getConfigData('title'); $counter = 0; if (!is_array($trackingValue)) { $trackingValue = [$trackingValue]; } foreach ($trackingValue as $trackingReference) { $tracking = $this->_trackStatusFactory->create(); $tracking->setCarrier(self::CODE); $tracking->setCarrierTitle($carrierTitle); // Adjust as needed based on the environment $tracking->setUrl(UData::TRACKING . $trackingReference); $tracking->setTracking($trackingReference); $tracking->addData($this->processTrackingDetails($trackingReference)); if ($result) { $result->append($tracking); $counter++; } } // Tracking Details Not Available if ($counter === 0) { $this->appendTrackingError( $trackingValue[0] ?? '', (string)__('For some reason we can\'t retrieve tracking info right now.') ); } } /** * Get tracking response * * @return string */ public function getResponse(): string { $statuses = ''; // If $_result is of type \Magento\Shipping\Model\Tracking\Result, handle it if ($this->_result instanceof \Magento\Shipping\Model\Tracking\Result) { if ($trackings = $this->_result->getAllTrackings()) { foreach ($trackings as $tracking) { if ($data = $tracking->getAllData()) { if (!empty($data['status'])) { $statuses .= __($data['status']) . "\n<br/>"; } else { $statuses .= __('Empty response') . "\n<br/>"; } } } } } // // Handle \Magento\Shipping\Model\Rate\Result if needed // if ($this->_result instanceof \Magento\Shipping\Model\Rate\Result) { // // Implement the logic for Rate\Result if applicable // } if (trim($statuses) === '') { $statuses = (string)__('Empty response'); } return $statuses; } /** * Get allowed shipping methods * * @return array<string, mixed> */ public function getAllowedMethods(): array { $allowedMethods = $this->getConfigData('allowed_methods'); if ($allowedMethods === false) { return []; // Return an empty array if no allowed methods are configured } $allowed = explode(',', $allowedMethods); $arr = []; foreach ($allowed as $k) { $arr[$k] = $this->getCode('method', $k); } return $arr; } /** * Do shipment request to carrier web service, obtain Print Shipping Labels, and process errors in response. * * Also another magic function that is required to be implemented by the carrier model. * * @param \Magento\Framework\DataObject $request * @return \Magento\Framework\DataObject|null */ protected function _doShipmentRequest(\Magento\Framework\DataObject $request) { return null; } /** * For multi-package shipments. Delete requested shipments if the current shipment request fails. * * @param mixed $data * @return bool */ public function rollBack($data): bool { // Return false if $data is not an array if (!is_array($data)) { return false; } return true; } /** * Return container types of carrier. * * @param \Magento\Framework\DataObject|null $params * @return array<string, mixed>|false * @SuppressWarnings(PHPMD.CyclomaticComplexity) */ public function getContainerTypes(\Magento\Framework\DataObject $params = null) { $result = []; $allowedContainers = $this->getConfigData('containers'); if ($allowedContainers) { $allowedContainers = explode(',', $allowedContainers); } if ($allowedContainers) { foreach ($allowedContainers as $container) { $result[$container] = $this->getCode('container_types', $container); } } return !empty($result) ? $result : false; } /** * Return delivery confirmation types of carrier. * * @param \Magento\Framework\DataObject|null $params * @return array<int|string, mixed> * @SuppressWarnings(PHPMD.UnusedFormalParameter) */ public function getDeliveryConfirmationTypes(\Magento\Framework\DataObject $params = null): array { $types = $this->getCode('delivery_confirmation_types'); // Ensure it returns an array, even if getCode returns false return is_array($types) ? $types : []; } /** * Recursive replace sensitive fields in debug data by the mask. * * @param mixed $data * @return mixed */ protected function filterDebugData($data) { if (!is_array($data)) { return $data; // Return early if $data is not an array. } foreach (array_keys($data) as $key) { if (is_array($data[$key])) { $data[$key] = $this->filterDebugData($data[$key]); } elseif (in_array($key, $this->_debugReplacePrivateDataKeys)) { $data[$key] = self::DEBUG_KEYS_MASK; } } return $data; } /** * Parse track details response from Bob Go. * * @param string $trackInfo * @return array<string, array<int, array<string, string>>> * @SuppressWarnings(PHPMD.CyclomaticComplexity) * @SuppressWarnings(PHPMD.NPathComplexity) */ private function processTrackingDetails(string $trackInfo): array { $result = [ 'shippeddate' => [], // Initializing as an array of arrays 'deliverydate' => [], // Initializing as an array of arrays 'deliverytime' => [], // Initializing as an array of arrays 'deliverylocation' => [], // Initializing as an array of arrays 'weight' => [], // Initializing as an array of arrays 'progressdetail' => [], // This will be populated with an array of arrays ]; $result = $this->_requestTracking($trackInfo, $result); return $result; } /** * Append error message to rate result instance. * * @param string $trackingValue * @param string $errorMessage * @return void */ private function appendTrackingError(string $trackingValue, string $errorMessage): void { $error = $this->_trackErrorFactory->create(); $error->setCarrier(self::CODE); $error->setCarrierTitle($this->getConfigData('title')); $error->setTracking($trackingValue); $error->setErrorMessage($errorMessage); $result = $this->getResult(); if ($result !== null) { $result->append($error); } else { // Handle the case where $result is null, such as logging an error $this->_logger->error('Failed to append tracking error: Result object is null.'); } } /** * Format a date to 'd M Y'. * * @param string $date * @return string */ public function formatDate(string $date): string { $timestamp = strtotime($date); if ($timestamp === false) { // Handle the error or return a default value, for example: return 'Invalid date'; } return date('d M Y', $timestamp); } /** * Format a time to 'H:i'. * * @param string $time * @return string */ public function formatTime(string $time): string { $timestamp = strtotime($time); if ($timestamp === false) { // Handle the error or return a default value, for example: return 'Invalid time'; } return date('H:i', $timestamp); } /** * Get the API URL for Bob Go. * * @return string */ private function getApiUrl(): string { return UData::RATES_ENDPOINT; } private function getWebhookUrl(): string { return UData::WEBHOOK_URL; } /** * Perform API Request to Bob Go API and return response. * * @param array<string,mixed> $payload The payload for the API request. * @param Result $result The result object to append the rates. * @return void */ protected function _getRates(array $payload, Result $result): void { $rates = $this->uRates($payload); // Ensure $rates is an array before passing it to _formatRates if (is_array($rates)) { $this->_formatRates($rates, $result); } else { $this->_logger->error('Bob Go API returned an invalid response'); } } /** * Perform API Request for Shipment Tracking to Bob Go API and return response. * * @param string $trackInfo The tracking information or tracking ID. * @param array<string,array<int,array<string,string>>> $result The result array to be * populated with tracking details. * @return array<string, array<int, array<string, string>>> The updated result array with tracking details. */ private function _requestTracking(string $trackInfo, array $result): array { $response = $this->trackBobGoShipment($trackInfo); // Validate that the response is an array and contains at least one element if (is_array($response) && isset($response[0]) && is_array($response[0])) { $result = $this->prepareActivity($response[0], $result); } return $result; } /** * Format rates from Bob Go API response and append to rate result instance of carrier. * * @param array<int|string,mixed> $rates The rates data from the API. * @param Result $result The result object to append the rates. * @return void */ protected function _formatRates(array $rates, Result $result): void { if (empty($rates['rates']) || !is_array($rates['rates'])) { // Validate that 'rates' exists and is an array $error = $this->_rateErrorFactory->create(); $error->setCarrierTitle($this->getConfigData('title')); $error->setErrorMessage($this->getConfigData('specificerrmsg')); $result->append($error); return; } foreach ($rates['rates'] as $rate) { if (!is_array($rate)) { continue; // Skip if the rate is not an array } $method = $this->_rateMethodFactory->create(); // Set the carrier code $method->setCarrier(self::CODE); // Strip out the redundant 'bobgo_' prefix if present $serviceCode = $rate['service_code'] ?? ''; if (is_string($serviceCode) && strpos($serviceCode, 'bobgo_') === 0) { $serviceCode = substr($serviceCode, strlen('bobgo_')); } // Set the method with the modified service code $method->setMethod($serviceCode); // Set additional info if required if ($this->getConfigData('additional_info') == 1) { $min_delivery_date = isset($rate['min_delivery_date']) && is_string($rate['min_delivery_date']) ? $this->getWorkingDays(date('Y-m-d'), $rate['min_delivery_date']) : null; $max_delivery_date = isset($rate['max_delivery_date']) && is_string($rate['max_delivery_date']) ? $this->getWorkingDays(date('Y-m-d'), $rate['max_delivery_date']) : null; $this->deliveryDays($min_delivery_date, $max_delivery_date, $method); } // Set the method title, price, and cost $service_name = $rate['service_name'] ?? ''; if (!is_string($service_name)) { $service_name = ''; } $method->setMethodTitle($service_name); $price = $rate['total_price'] ?? 0; if (!is_numeric($price)) { $price = 0; } $cost = $rate['total_price'] ?? 0; if (!is_numeric($cost)) { $cost = 0; } $method->setPrice((float)$price); $method->setCost((float)$cost); $result->append($method); } } /** * Prepare received checkpoints and activity from Bob Go Shipment Tracking API. * * @param array<string,mixed> $response The API response containing tracking checkpoints. * @param array<string,array<int,array<string,string>>> $result The result array to be * populated with activity details. * @return array<string, array<int, array<string, string>>> The updated result array with activity details. */ private function prepareActivity(array $response, array $result): array { if (isset($response['checkpoints']) && is_array($response['checkpoints'])) { foreach ($response['checkpoints'] as $checkpoint) { if (is_array($checkpoint) && isset($checkpoint['status'], $checkpoint['time']) && is_string($checkpoint['status']) && is_string($checkpoint['time']) ) { $result['progressdetail'][] = [ 'activity' => $checkpoint['status'], 'deliverydate' => $this->formatDate($checkpoint['time']), 'deliverytime' => $this->formatTime($checkpoint['time']), ]; } } } return $result; } /** * Get Working Days between time of checkout and delivery date (min and max). * * @param string $startDate * @param string $endDate * @return int */ public function getWorkingDays(string $startDate, string $endDate): int { $begin = strtotime($startDate); $end = strtotime($endDate); // Check if strtotime failed if ($begin === false || $end === false || $begin > $end) { return 0; // or throw an exception if preferred } $no_days = 0; $weekends = 0; while ($begin <= $end) { $no_days++; // number of days in the given interval $what_day = date("N", $begin); if ($what_day > 5) { // 6 and 7 are weekend days $weekends++; } $begin += 86400; // +1 day } return $no_days - $weekends; } /** * Curl request to Bob Go Shipment Tracking API. * * @param string $trackInfo The tracking information or tracking ID. * @return mixed The decoded API response. */ private function trackBobGoShipment(string $trackInfo): mixed { $this->curl->get(UData::TRACKING . $trackInfo); $response = $this->curl->getBody(); return json_decode($response, true); } /** * Build the payload for Bob Go API request and return the response. * * @param array<string,mixed> $payload The payload for the API request. * @return array<int|string, mixed>|null The decoded response, or null if the response could not be decoded * or is not an array. */ protected function uRates(array $payload): ?array { $this->curl->addHeader('Content-Type', 'application/json'); $payloadJson = json_encode($payload); if ($payloadJson === false) { // Handle JSON encoding failure if necessary return null; // or throw an exception } $this->curl->post($this->getApiUrl(), $payloadJson); $rates = $this->curl->getBody(); $rates = json_decode($rates, true); // Ensure that $rates is an array or return null if (is_array($rates)) { return $rates; } return null; } /** * Splits a destination street address into up to three lines if it contains newline characters. * * @param string $destStreet The full street address. * @return string[] An array containing up to three lines of the street address. */ protected function destStreet(string $destStreet): array { if (strpos($destStreet, "\n") !== false) { $destStreet = explode("\n", $destStreet); $destStreet1 = $destStreet[0]; $destStreet2 = $destStreet[1]; $destStreet3 = $destStreet[2] ?? ''; } else { $destStreet1 = $destStreet; $destStreet2 = ''; $destStreet3 = ''; } return [$destStreet1, $destStreet2, $destStreet3]; } /** * Sets the carrier title with the estimated delivery days range based on minimum and maximum delivery dates. * * @param int|null $min_delivery_date Minimum estimated delivery date in days. * @param int|null $max_delivery_date Maximum estimated delivery date in days. * @param \Magento\Quote\Model\Quote\Address\RateResult\Method $method The shipping method instance * to set the carrier title. * @return void */ protected function deliveryDays( ?int $min_delivery_date, ?int $max_delivery_date, \Magento\Quote\Model\Quote\Address\RateResult\Method $method ): void { if ($min_delivery_date === null || $max_delivery_date === null) { return; } if ($min_delivery_date !== $max_delivery_date) { $method->setCarrierTitle('Delivery in ' . $min_delivery_date . ' - ' . $max_delivery_date . ' days'); } else { if ($min_delivery_date && $max_delivery_date == 1) { $method->setCarrierTitle('Delivery in ' . $min_delivery_date . ' day'); } else { $method->setCarrierTitle('Delivery in ' . $min_delivery_date . ' days'); } } } /** * Retrieves the destination company name from the additional information. * * @return mixed|string The destination company name. */ public function getDestComp(): mixed { return $this->additionalInfo->getDestComp(); } /** * Retrieves the destination suburb from the additional information. * * @return mixed|string The destination suburb. */ public function getDestSuburb(): mixed { return $this->additionalInfo->getSuburb(); } /** * Calculates the item weight in grams based on the provided weight unit. * * @param string $weightUnit The unit of weight, either 'KGS' or another unit (assumed to be pounds). * @param \Magento\Quote\Model\Quote\Item $item The item whose weight is to be calculated. * @return float The weight of the item in grams. */ public function getItemWeight(string $weightUnit, \Magento\Quote\Model\Quote\Item $item): float { // 1 lb = 453.59237 g exact. 1 kg = 1000 g. 1 lb = 0.45359237 kg if ($weightUnit === 'KGS') { $mass = $item->getWeight() ? $item->getWeight() * 1000 : 0; } else { // Pound to Kilogram Conversion Formula $mass = $item->getWeight() ? $item->getWeight() * 0.45359237 * 1000 : 0; } return $mass; } /** * Processes the items in the cart, calculates their weights, and prepares an array of item details. * * @param \Magento\Quote\Model\Quote\Item[] $items The items in the cart. * @param string $weightUnit The unit of weight used for the items. * @param array<int,array<string,mixed>> $itemsArray The array to store the processed item details. * @return array<int, array<string, mixed>> The array containing details of each item, * including SKU, quantity, price, and weight. */ public function getStoreItems( array $items, string $weightUnit, array $itemsArray ): array { foreach ($items as $item) { $mass = $this->getItemWeight($weightUnit, $item); $itemsArray[] = [ 'sku' => $item->getSku(), 'quantity' => $item->getQty(), 'price' => $item->getPrice(), 'weight' => round($mass), ]; } return $itemsArray; } /** * Checks if the required data fields are present in the request. * * @param \Magento\Framework\DataObject $request The data object containing the request information. * @return bool True if all required fields are present, otherwise false. */ public function hasRequiredData(\Magento\Framework\DataObject $request): bool { $requiredFields = [ 'dest_country_id', 'dest_region_id', ]; foreach ($requiredFields as $field) { if (!$request->getData($field)) { return false; } } return true; } /** * Trigger a test for rates. * * @return array<int|string, mixed>|bool Returns an array of results or false on failure. */ public function triggerRatesTest(): array|bool { // Check if the 'Show rates for checkout' setting is enabled $isEnabled = $this->scopeConfig->getValue( 'carriers/bobgo/active', \Magento\Store\Model\ScopeInterface::SCOPE_STORE ); if ($isEnabled) { // Sample test payload, replace with actual structure $payload = [ 'identifier' => $this->getBaseUrl(), 'rate' => [ 'origin' => [ 'company' => 'Jamie Ds Emporium', 'address1' => '36 Marelu Street', 'address2' => 'Six Fountains Estate', 'city' => 'Pretoria', 'suburb' => 'Pretoria', 'province' => 'GT', 'country_code' => 'ZA', 'postal_code' => '0081', ], 'destination' => [ 'company' => 'Test Company', 'address1' => '456 Test Ave', 'address2' => '', 'suburb' => 'Test Suburb', 'city' => 'Test City', 'province' => 'Test Province', 'country_code' => 'ZA', 'postal_code' => '3000', ], 'items' => [ [ 'sku' => 'test-sku-1', 'quantity' => 1, 'price' => 100.00, 'weight' => 500, // in grams ] ], ] ]; try { // Perform the API request $payloadJson = json_encode($payload); if ($payloadJson === false) { throw new \RuntimeException('Failed to encode payload to JSON.'); } $this->curl->addHeader('Content-Type', 'application/json'); $this->curl->post($this->getApiUrl(), $payloadJson); $statusCode = $this->curl->getStatus(); $responseBody = $this->curl->getBody(); // Decode the response $response = json_decode($responseBody, true); if (!is_array($response)) { throw new LocalizedException(__('Invalid response format.')); } // Check if the response contains a 'message' (indicating an error) if (isset($response['message'])) { throw new LocalizedException(__('Error from BobGo: %1', $response['message'])); } // Check if the response contains rates with a valid id field if (isset($response['rates']) && is_array($response['rates']) && !empty($response['rates'])) { foreach ($response['rates'] as $rate) { if (isset($rate['id']) && $rate['id'] !== null) { return $response; // Successful response with a valid id } } throw new LocalizedException(__('Rates received but id field is empty or invalid.')); } else { throw new LocalizedException(__('Received response but no valid rates were found.')); } } catch (\Exception $e) { return false; } } return false; } public function isWebhookEnabled(): bool { $enabled = $this->scopeConfig->getValue( 'carriers/bobgo/enable_webhooks', \Magento\Store\Model\ScopeInterface::SCOPE_STORE ); // Cast the value to a boolean return filter_var($enabled, FILTER_VALIDATE_BOOLEAN); } public function triggerWebhookTest(): bool { $webhookKey = $this->scopeConfig->getValue( 'carriers/bobgo/webhook_key', \Magento\Store\Model\ScopeInterface::SCOPE_STORE ); // Convert the string to a boolean value $isEnabled = $this->isWebhookEnabled(); $storeId = strval($this->_storeManager->getStore()->getId()); $payload = [ 'event' => 'webhook_validation', 'channel_identifier' => $this->getBaseUrl(), 'store_id' => $storeId, 'webhooks_enabled' => $isEnabled, ]; try { $this->encodeWebhookAndPostRequest($this->getWebhookUrl(), $payload, $storeId, $webhookKey); $statusCode = $this->curl->getStatus(); $responseBody = $this->curl->getBody(); if ($statusCode != 200) { throw new LocalizedException(__('Status code from BobGo: %1', $statusCode)); } } catch (\Exception $e) { return false; } return true; } public function encodeWebhookAndPostRequest($url, $data, $storeId, $webhookKey) { // Generate the HMAC-SHA256 hash as raw binary data $rawSignature = hash_hmac('sha256', $storeId, $webhookKey, true); // Encode the binary data in Base64 $signature = base64_encode($rawSignature); // Set headers and post the data $this->curl->addHeader('Content-Type', 'application/json'); $this->curl->addHeader('x-m-webhook-signature', $signature); $payloadJson = json_encode($data); if ($payloadJson === false) { throw new \RuntimeException('Failed to encode payload to JSON.'); } $this->curl->addHeader('Content-Type', 'application/json'); $this->curl->post($url, $payloadJson); } }