HEX
Server: Microsoft-IIS/8.5
System: Windows NT YDAWBH120 6.3 build 9600 (Windows Server 2012 R2 Standard Edition) AMD64
User: tentjecom_web (0)
PHP: 7.4.14
Disabled: NONE
Upload Files
File: D:/HostingSpaces/SBogers10/shop.komma.nl/app/Shipments/ProviderAdapters/Sendcloud.php
<?php declare(strict_types=1);


namespace App\Shipments\ProviderAdapters;


use App\Orders\Models\Order;
use App\Orders\Product\OrderedProduct;
use App\Shipments\Enums\SendcloudParcelStatusEnum;
use App\Shipments\Shipment;
use App\Shipments\ShipmentStatus;
use App\Vat\VatService;
use DB;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\RequestException;
use GuzzleHttp\Psr7\Response as GuzzlePsrResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Komma\KMS\Globalization\RegionInfo;
use Psr\Http\Message\ResponseInterface;
use Symfony\Component\HttpFoundation\Response;
use function GuzzleHttp\Psr7\str as guzzleStr;

/**
 * Class Sendcloud
 *
 * @see https://docs.sendcloud.sc/api/v2/shipping
 * @package App\Shipments\ProviderAdapters
 */
class Sendcloud extends AbstractShippingProviderAdapter
{
    private ?string $publicApiKey;
    private ?string $secretApiKey;
    private string $adapterName = 'Sendcloud adapter service provider: ';
    private bool $testMode;
    private ?int $sendcloudPartnerId;
    private VatService $vatService;
    private ?string $fakeCreateResponse = null;
    private ?string $fakeCancelResponse = null;
//    private ?string $fakeResponse = '{"parcel":{"id":60238248,"address":"O\'Connell Prairie 78","address_2":"","address_divided":{"street":"O\'Connell Prairie","house_number":"78"},"city":"Jacobsonfort","company_name":"","country":{"iso_2":"UA","iso_3":"UKR","name":"Ukraine"},"data":{},"date_created":"28-09-2020 06:51:28","email":"alakin@example.com","name":"Gabrielle Op t Weissnat","postal_code":"29932-4345","reference":"0","shipment":{"id":8,"name":"Unstamped letter"},"status":{"id":1000,"message":"Ready to send"},"to_service_point":null,"telephone":"862.351.2796 x0280","tracking_number":"SCCWF243QHMV","weight":"1.000","label":{"normal_printer":["https://panel.sendcloud.sc/api/v2/labels/normal_printer/60238248?start_from=0","https://panel.sendcloud.sc/api/v2/labels/normal_printer/60238248?start_from=1","https://panel.sendcloud.sc/api/v2/labels/normal_printer/60238248?start_from=2","https://panel.sendcloud.sc/api/v2/labels/normal_printer/60238248?start_from=3"],"label_printer":"https://panel.sendcloud.sc/api/v2/labels/label_printer/60238248"},"customs_declaration":{"normal_printer":"https://panel.sendcloud.sc/api/v2/customs_declaration/normal_printer/60238248"},"order_number":"O202000014","insured_value":0,"total_insured_value":0,"to_state":null,"customs_invoice_nr":"","customs_shipment_type":null,"parcel_items":[{"description":"LED lamp","quantity":1,"weight":"1.000","value":"189.00","hs_code":"","origin_country":null,"product_id":"21","properties":"","sku":"EAN1000005","return_reason":null,"return_message":null}],"documents":[{"type":"label","size":"a6","link":"https://panel.sendcloud.sc/api/v2/parcels/60238248/documents/label"}],"type":"letter","shipment_uuid":null,"shipping_method":8,"external_order_id":"60238248","external_shipment_id":"O202000014","external_reference":"7","is_return":false,"note":"","to_post_number":"","total_order_value":"195.99","total_order_value_currency":null,"carrier":{"code":"sendcloud"},"tracking_url":"https://tracking.sendcloud.sc/forward?carrier=sendcloud&code=SCCWF243QHMV&destination=UA&lang=nl-nl&source=NL&type=letter&verification=29932-4345&created_at=2020-09-28"}}';

    public function __construct()
    {
        $this->publicApiKey = config('shipment.shipment_service_providers.sendcloud.public_api_key');
        $this->secretApiKey = config('shipment.shipment_service_providers.sendcloud.secret_api_key');
        $this->testMode = config('shipment.shipment_service_providers.sendcloud.test_mode');

        $partnerId = config('shipment.shipment_service_providers.sendcloud.partner_id');
        if($partnerId) $partnerId = intval($partnerId);
        $this->sendcloudPartnerId = $partnerId;
        $this->vatService = new VatService();
    }

    public function adaptShipmentProviderShipmentStatusToKmsShipmentStatus($status): string
    {
        switch ($status) {
            case SendcloudParcelStatusEnum::CANCELLED_UPSTREAM:
            case SendcloudParcelStatusEnum::CANCELLED:
                return ShipmentStatus::CANCELED;
            case SendcloudParcelStatusEnum::NOT_SORTED:
                return ShipmentStatus::NEW;
            case SendcloudParcelStatusEnum::ANNOUNCEMENT_FAILED:
            case SendcloudParcelStatusEnum::ANNOUNCED_NOT_COLLECTED:
            case SendcloudParcelStatusEnum::ERROR_COLLECTING:
            case SendcloudParcelStatusEnum::UNABLE_TO_DELIVER:
            case SendcloudParcelStatusEnum::UNKNOWN_STATUS_CHECK_CARRIER_TRACK_TRACE_PAGE_FOR_MORE_INSIGHTS:
            case SendcloudParcelStatusEnum::NO_LABEL:
            default:
                return ShipmentStatus::EXCEPTION;
            case SendcloudParcelStatusEnum::CANCELLATION_REQUESTED:
            case SendcloudParcelStatusEnum::BEING_ANNOUNCED:
            case SendcloudParcelStatusEnum::ANNOUNCED:
            case SendcloudParcelStatusEnum::SUBMITTING_CANCELLATION_REQUEST:
                return ShipmentStatus::CARRIER_NOTIFIED;
            case SendcloudParcelStatusEnum::READY_TO_SEND:
                return ShipmentStatus::READY_TO_SHIP;
            case SendcloudParcelStatusEnum::AWAITING_CUSTOMER_PICKUP:
            case SendcloudParcelStatusEnum::SHIPMENT_PICKED_UP_BY_DRIVER:
            case SendcloudParcelStatusEnum::BEING_SORTED:
            case SendcloudParcelStatusEnum::SORTED:
            case SendcloudParcelStatusEnum::DELIVERY_DELAYED:
                return ShipmentStatus::OUT_FOR_DELIVERY;
            case SendcloudParcelStatusEnum::SHIPMENT_COLLECTED_BY_CUSTOMER:
            case SendcloudParcelStatusEnum::DELIVERED:
                return ShipmentStatus::DELIVERED;
            case SendcloudParcelStatusEnum::PARCEL_EN_ROUTE:
            case SendcloudParcelStatusEnum::DRIVER_EN_ROUTE:
            case SendcloudParcelStatusEnum::EN_ROUTE_TO_SORTING_CENTER:
                return ShipmentStatus::IN_TRANSIT;
            case SendcloudParcelStatusEnum::DELIVERY_ATTEMPT_FAILED:
                return ShipmentStatus::FAILED_ATTEMPT;
        }
    }

    public function adaptKmsShipmentStatusToShipmentProviderShipmentStatus($status)
    {
        switch ($status) {
            case ShipmentStatus::NEW:
                return SendcloudParcelStatusEnum::NO_LABEL;
            case ShipmentStatus::READY_TO_SHIP:
                return SendcloudParcelStatusEnum::READY_TO_SEND;
            case ShipmentStatus::CARRIER_NOTIFIED:
                return SendcloudParcelStatusEnum::ANNOUNCED;
            case ShipmentStatus::IN_TRANSIT:
                return SendcloudParcelStatusEnum::PARCEL_EN_ROUTE;
            case ShipmentStatus::OUT_FOR_DELIVERY:
                return SendcloudParcelStatusEnum::DRIVER_EN_ROUTE;
            case ShipmentStatus::FAILED_ATTEMPT:
                return SendcloudParcelStatusEnum::DELIVERY_ATTEMPT_FAILED;
            case ShipmentStatus::CANCELED:
                return SendcloudParcelStatusEnum::CANCELLED;
            case ShipmentStatus::EXCEPTION:
            default:
                return SendcloudParcelStatusEnum::UNKNOWN_STATUS_CHECK_CARRIER_TRACK_TRACE_PAGE_FOR_MORE_INSIGHTS;
            case ShipmentStatus::DELIVERED:
                return SendcloudParcelStatusEnum::DELIVERED;
        }
    }

    /**
     * Handle a request from sendcloud. This is the webhook handler to which they can "talk" to about shipments and such.
     *
     * @see ShipmentProviderResponseController::processShipmentProviderResponse()
     * @return Response
     */
    public function processShipmentProviderResponse(): Response
    {
        //Validate Signature
        if(!$this->requestHasValidSignature()) {
            Log::error($this->adapterName . 'Sendcloud called the kms adapter (or a man in the middle), with an invalid signature. The call is not handled.');
            return response()->json(['error' => '`The "Sendcloud-Signature" could not be validated successfully`'], Response::HTTP_UNPROCESSABLE_ENTITY);
        }

        $actionMethodName = $this->validateAndGetActionMethod();
        if(!$actionMethodName) return response()->json(['error' => 'Please provide an action using the key "action"'], Response::HTTP_UNPROCESSABLE_ENTITY);

        //Call the method to handle the action. And let ir return a response
        return $actionMethodName();
    }

    public function createShipments(Collection $shipments): Collection
    {
        //TODO Use sendclouds batch functionality instead
        foreach($shipments as $shipment) {
            $this->createShipment($shipment);
        }
        return $shipments;
    }


    /**
     * @param Shipment $shipment
     *
     * @see https://docs.sendcloud.sc/api/v2/shipping/?shell#create-a-parcel
     * @return Shipment
     */
    public function createShipment(Shipment $shipment): Shipment
    {
        /** @var Order|null $order */
        $order = $shipment->order()->first();

        $shippingRegionInfos = RegionInfo::getWhere('ThreeLetterISORegionName', '=', $order->shipping_country_iso3);
        /** @var RegionInfo $shippingRegionInfo */
        $shippingRegionInfo = $shippingRegionInfos->first();
//        dd($order->shipping_country_iso3); //TODO FIX THE ISO3. Somehow it became iso 2
        if(!$shippingRegionInfo) throw new \RuntimeException(__('KMS::shipments.could_not_get_region_info_for_country', ['country' => $order->shipping_country_iso3]));

        if(!$this->getShippingMethodForShipment($order, $shipment, $shippingRegionInfo)) {
            throw new \RuntimeException(__('KMS::shipments.sendcloud.could_not_determine_shipping_method'));
        }

        $data = [
            'parcel' => $this->getParcelData($order, $shipment, $shippingRegionInfo)
        ];

        $headers = [];
        if($this->sendcloudPartnerId) {
//            $headers['Sendcloud-Partner-Id'] = $this->sendcloudPartnerId; //Does not work while they se it does. When you use it it returns "party: \"\"75\" is not a valid choice.\""
            $headers['Content-Type'] = 'application/json';
        }

        if(!$this->fakeCreateResponse) {
            $response = null;
            try {
                $client = new Client();
                $response = $client->post(
                    'https://panel.sendcloud.sc/api/v2/parcels', [
                        'headers' => $headers,
                        'auth' => [$this->publicApiKey, $this->secretApiKey], //Auth => [username, password]
                        'body' => json_encode($data),
                    ]
                );
            } catch (RequestException $e) {
                $sensibleMessage = $this->handleGuzzleException($e);
                if($sensibleMessage) abort(Response::HTTP_UNPROCESSABLE_ENTITY, $sensibleMessage);
            }

            //Validate Signature
            if($response) {
                if (!$this->responseHasValidSignature($response)) {
                    Log::error($this->adapterName . 'Sendcloud responded to a request with an invalid signature. The call is not handled.');
                    abort(Response::HTTP_UNPROCESSABLE_ENTITY, ['error' => '`The "Sendcloud-Signature" could not be validated successfully`']);
                }
            }
        } else {
            $response = new GuzzlePsrResponse(200, [], $this->fakeCreateResponse);
        }

        if($response) $this->updateShipmentFromSendCloudResponse($response, $shipment);
        return $shipment;
    }

    /**
     * @param Collection $shipments
     * @return Collection
     */
    public function cancelShipments(Collection $shipments): Collection
    {
        foreach($shipments as $shipment) {
            $this->cancelShipment($shipment);
        }
        return $shipments;
    }

    /**
     * @param Shipment $shipment
     * @return Shipment
     */
    public function cancelShipment(Shipment $shipment): Shipment
    {
        $headers = [];
        if($this->sendcloudPartnerId) {
//            $headers['Sendcloud-Partner-Id'] = $this->sendcloudPartnerId; //Does not work while they se it does. When you use it it returns "party: \"\"75\" is not a valid choice.\""
            $headers['Content-Type'] = 'application/json';
        }

        //Send the cancel request to sendcloud. If it fails, abort with a sensible message
        $response = null;
        if(!$this->fakeCancelResponse) {
            $response = null;
            try {
                $client = new Client();
                $response = $client->post(
                    Str::replaceArray('?', [$shipment->ssp_reference], 'https://panel.sendcloud.sc/api/v2/parcels/?/cancel'), [
                        'headers' => $headers,
                        'auth' => [$this->publicApiKey, $this->secretApiKey], //Auth => [username, password]
                        'body' => null
                    ]
                );
            } catch (RequestException $e) {
                //Try to get the response anyways, check if it still is a cancel response and act accordingly.
                //Else handle it as a generic exception
                $response = $e->getResponse();
                if(!$this->handleCancelResponseIfPossible($response, $shipment)) {
                    $sensibleMessage = $this->handleGuzzleException($e);
                    if($sensibleMessage) abort(Response::HTTP_UNPROCESSABLE_ENTITY, $sensibleMessage);
                } else {
                    $this->saveShipmentAndNotify($shipment);
                    return $shipment;
                }
            }
        } else {
            $response = new GuzzlePsrResponse(200, [], $this->fakeCreateResponse);
        }

        if($response) {
            $this->handleCancelResponseIfPossible($response, $shipment);
            $this->saveShipmentAndNotify($shipment);
        } else {
            Log::error('No response while response was expected.');
        }

        return $shipment;
    }

    private function handleCancelResponseIfPossible(ResponseInterface $response, Shipment $shipment) {
        //Decode the body and validate that it is a valid cancel response
        $data = json_decode((string) $response->getBody(), true);
        if(!$data || !$this->cancelStatusDataIsValid($data)) return false;

        //React to the response
        switch ($response->getStatusCode()) {
            case Response::HTTP_OK: //Canceled successfully
            case Response::HTTP_GONE: //Happens when the parcel announcement has failed (has status 1002) and we try to cancel it.
            case Response::HTTP_ACCEPTED: //The parcel having the status of ready to send (1000) will be watched for 14 days and if nothing nothing has changed to the parcel, it will be cancelled.
                $shipment->status = ShipmentStatus::CANCELED;
                break;
            case Response::HTTP_BAD_REQUEST:
                abort(Response::HTTP_BAD_REQUEST, $data['message']);
                break;
            default:
                break;
        }
        return $shipment;
    }

    private function updateShipmentFromSendCloudResponse(ResponseInterface $response, Shipment $shipment) {
        Log::debug('create parcel response: ');
        Log::debug((string) $response->getBody());

        $data = json_decode((string) $response->getBody(), true);
        if(
            !$data ||
            !array_key_exists('parcel', $data) ||
            !is_array($data['parcel'])
        ) {
            Log::error($this->adapterName . 'Expected a response to have a parcel string key that contains an array. But did not get that key or it did not contain an array.');
            return false;
        }

        return $this->updateShipmentsUsingParcelData($data, $shipment);
    }

    /**
     * Gets an array, that you can json encode to send to sendcloud, for creating a parcel at their end
     *
     * @param Order      $order
     * @param Shipment   $shipment
     * @param RegionInfo $shippingRegionInfo
     *
     * @return array
     */
    private function getParcelData(Order $order, Shipment $shipment, RegionInfo $shippingRegionInfo): array {
        return array_merge(
            $this->getRecipientAddressData($order, $shipment, $shippingRegionInfo),
            $this->getShippingData($order, $shipment, $shippingRegionInfo),
            $this->getParcelPropertyData($order, $shipment, $shippingRegionInfo),
            $this->getAnnouncementData($order, $shipment, $shippingRegionInfo)
        );
    }

    /**
     * @param Order      $order
     * @param Shipment   $shipment
     * @param RegionInfo $shippingRegionInfo
     *
     * @return array
     */
    private function getParcelReturnData(Order $order, Shipment $shipment, RegionInfo $shippingRegionInfo): array {
        $basicData = $this->getParcelData($order, $shipment, $shippingRegionInfo);
        $basicData['is_return'] = true;

        return array_merge(
            $basicData,
            $this->getReturnSenderAddressDetailsData($order, $shippingRegionInfo)
        );
    }

    /**
     * @param Order      $order
     * @param Shipment   $shipment
     * @param RegionInfo $shippingRegionInfo
     *
     * @return array
     */
    private function getRecipientAddressData(Order $order, Shipment $shipment, RegionInfo $shippingRegionInfo): array {
        $data = [];

        $data['name'] = $order->customerDisplayName(); //required, string
        $data['company_name'] = ''; //string
        $data['address'] = $order->shipping_street.' '.$order->shipping_house_number; //required, string
//        $data['address_2'] = ''; //string
        $data['house_number'] = $order->shipping_house_number; //required based on country
        $data['city'] = $order->shipping_city; //required, string
        $data['postal_code'] = $order->shipping_postal_code; //required, string
//        $data['to_post_number'] = ''; //string
        $data['country'] = $shippingRegionInfo->getTwoLetterISORegionName(); //string, assumed ISO 2 since manual does not state it //WARNING, could not be determined at the moment
//        $data['country_state'] = ''; //required when shipping outside of EU, string, //WARNING, could not be determined at the moment
        $data['telephone'] = $order->telephone; //Manual incorrect, says both integer and string //WARNING, could not be determined at the moment
        $data['email'] = $order->email; //string
//        $data['sender_address'] = 0; //integer
//        $data['customs_invoice_nr'] = ''; //required when shipping outside of EU, string //WARNING, could not be determined at the moment
//        $data['customs_shipment_type'] = ''; //required when shipping outside of EU, integer //WARNING, could not be determined at the moment. See SendcloudCustomsShipmentTypes enum for possible values
        $data['external_reference'] = $shipment->id; //WARNING, causes idempotence behaviour. See api manual

        return $data;
    }

    private function getShippingData(Order $order, Shipment $shipment, RegionInfo $shipmentRegionInfo): array {
        $data = [];

        //Shipping service data
//        $data['to_service_point'] = 0; //integer
//        $data['insured_value'] = 0; //integer
//        $data['total_insured_value'] = 0; //integer

        return $data;
    }

    private function getParcelPropertyData(Order $order, Shipment $shipment, RegionInfo $shipmentRegionInfo): array {
        $data = [];

        $data['order_number'] = $order->order_number; //string
        $data['parcel_items'] = $this->getParcelItemsData($order, $shipment, $shipmentRegionInfo);
//        $data['weight'] = ''; //string(decimal number) in kilograms
        $data['is_return'] = false; //boolean
        $data['total_order_value'] = (string) $order->total / 100;

        return $data;
    }

    private function getAnnouncementData(Order $order, Shipment $shipment, RegionInfo $shipmentRegionInfo): array
    {
        $data = [];

        $data['request_label'] = true; //boolean
        $data['request_label_async'] = false; //boolean
        $data['shipment'] = $this->getShipmentData($order, $shipment, $shipmentRegionInfo); //object (Remember: arrays will be json encoded as objects), required when request_label=true
        $data['apply_shipping_rules'] = false; //boolean, Only applied when request_label=true

        return $data;
    }

    /**
     * For returns
     *
     * @param Order      $order
     * @param RegionInfo $shippingRegionInfo
     *
     * @return array
     */
    private function getReturnSenderAddressDetailsData(Order $order, RegionInfo $shippingRegionInfo): array
    {
        $data = [];
        $data['from_name'] = $order->customerDisplayName(); //string
//        $data['from_company_name'] = ''; //string
        $data['from_address_1'] = $order->shipping_street.' '.$order->shipping_house_number; //string
//        $data['from_address_2'] = ''; //string
        $data['from_house_number'] = $order->shipping_house_number; //string
        $data['from_city'] = $order->shipping_city; //string
        $data['from_postal_code'] = $order->shipping_postal_code; //string
        $data['from_country'] = $shippingRegionInfo->getTwoLetterISORegionName(); //string
        $data['from_telephone'] = $order->telephone; //string
        $data['from_email'] = $order->email; //string

        return $data;
    }

    private function getShipmentData(Order $order, Shipment $shipment, RegionInfo $shippingRegionInfo)
    {
        $data = [];

        $data['id'] = $this->getShippingMethodForShipment($order, $shipment, $shippingRegionInfo)['id']; //required, integer
//        $data['name'] = ''; //string

        return $data;
    }

    private function getParcelItemsData(Order $order, Shipment $shipment, RegionInfo $shipmentRegionInfo) {
        $data = [];

        $orderedProducts = $shipment->orderedProducts()->get();
        /** @var OrderedProduct $orderedProduct */
        foreach($orderedProducts as $orderedProduct) {
            $orderedProduct = $this->vatService->calculateVatForModelWithVatScenarioEnum($orderedProduct);
            $singleItemExPrice = $orderedProduct->getPriceEx() / $orderedProduct->quantity;
            $singleItemIncPrice = $this->vatService->calculateIncVatRatePrice($singleItemExPrice, $this->vatService->getVatScenarioByEnumValue($orderedProduct->vat_scenario_enum));

            $parcelItemData = [];
            $parcelItemData['description'] = $orderedProduct->name; //required, string
            $parcelItemData['quantity'] = intval($orderedProduct->quantity); //required, integer
            $parcelItemData['weight'] = '1.00'; //required, string(decimal number) // TODO. It is required, but we dont have it implemented at the moment
            $parcelItemData['value'] = (string) $singleItemIncPrice / 100; //required, decimal 2 decimal places. Single item
//            $parcelItemData['hs_code'] = ''; //required when shipping outside eu, string
//            $parcelItemData['origin_country'] = ''; //required when shipping outside eu, string, iso 2
            $parcelItemData['sku'] = $orderedProduct->stock_keeping_unit;
            $parcelItemData['product_id'] = $orderedProduct->id;
            $parcelItemData['properties'] = ''; //json object.

            $data[] = $parcelItemData;
        }

        return $data;
    }

    /**
     * @see processShipmentProviderResponse actionMethodName variable
     * @return Response
     */
    private function handleParcelStatusChangedAction() {
        $data = request()->all();
        return $this->updateShipmentsUsingParcelData($data);
    }

    public function updateShipmentsUsingParcelData($data, Shipment $shipment = null): Response {
        //Normalize data so that it will be the same for both single parcel as multi parcel responses.
        if(array_key_exists('parcel', $data)) {
            $data['parcels'] = [$data['parcel']];
            unset($data['parcel']);
        }

        DB::transaction(function () use($data, $shipment) {
            foreach($data['parcels'] as $parcelData) {
                if(!$this->basicParcelDataIsValid($parcelData)) return response()->json(['error' => 'Invalid data received'], Response::HTTP_UNPROCESSABLE_ENTITY);

                //Use the shipment from the parcel data instead of the given one when the data contains info about more then one shipments.
                if(count($data['parcels']) > 1) $shipment = Shipment::whereSspReference($parcelData['id'])->first();

                if(!$shipment) {
                    Log::warning($this->adapterName . 'Needed to update a shipment for a parcel with id '.$parcelData['id']. ' but the shipment does not exist. Skipping the data.');
                    continue;
                }

                $shipment->ssp_reference = $parcelData['id'];
                $shipment->tracking_code = $parcelData['tracking_number'];
                $shipment->tracking_url = $parcelData['tracking_url'];
                $shipment->carrier_name = $parcelData['carrier']['code'];
                $shipment->status = $this->adaptShipmentProviderShipmentStatusToKmsShipmentStatus($parcelData['status']['id']);

                $this->storeDocuments($parcelData, $shipment);

                $this->saveShipmentAndNotify($shipment);
            }

            return true;
        });

        return response()->json(null, Response::HTTP_NO_CONTENT);
    }

    /**
     * @see processShipmentProviderResponse actionMethodName variable
     * @return Response
     */
    private function handleIntegrationConnectedAction(): Response {
        if(!request()->has('integration')) {
            Log::warning($this->adapterName . 'An integration was connected, but it was announced without an integration key');
            return response()->json(['error' => 'A key with the name of "integration" was expected.'], Response::HTTP_UNPROCESSABLE_ENTITY);
        }

        $logMessage = Str::replaceArray('?', [
            request()->get('id', 'unknown'),
            request()->get('shop_name', 'unknown'),
            request()->get('system', 'unknown'),
            request()->get('webhook_active', 'unknown'),
            request()->get('webhook_url', 'unknown'),
        ],
        $this->adapterName . 'An integration with an id of ? for a shop called ? was connected to SendCloud using the "?" system. The webhook is set to ? in the SendCloud panel. When active, sendcloud will POST to "?" when there are updates.');

        Log::info($logMessage);
        return response()->json('Thanks for notifying that an integration was connected!', Response::HTTP_OK);
    }

    /**
     * @see processShipmentProviderResponse actionMethodName variable
     * @return Response
     */
    private function handleIntegrationDeletedAction(): Response {
        Log::info($this->adapterName . 'an integration was deleted');
        return response()->json('Thanks for notifying that an integration was deleted!', Response::HTTP_OK);
    }

    /**
     * When SendCloud calls the webhook url, which should hit the processShipmentProviderResponse function,
     * they include a header called Sendcloud-Signature. Which is an encrypted form of the included data using
     * their secretApiKey.
     *
     * @param Request|null $request
     *
     * @return bool
     */
    private function requestHasValidSignature(Request $request = null) {
        if(!$request) $request = request();

        $sendCloudsSignature = $request->header('Sendcloud-Signature');
        $recalculatedSignature = hash_hmac("sha256", $request->json(), $this->secretApiKey);
        return $sendCloudsSignature === $recalculatedSignature;
    }

    /**
     * Same idea as the requestHasValidSignature method
     *
     * @param GuzzlePsrResponse|null $response
     * @return bool
     */
    private function responseHasValidSignature(GuzzlePsrResponse $response) {
        $sendCloudsSignature = $response->getHeader('Sendcloud-Signature');
        if(empty($sendCloudsSignature)) return true; //They did not give us a valid signature...we still allow it anyways
        $recalculatedSignature = hash_hmac("sha256", (string) $response->getBody(), $this->secretApiKey);
        return $sendCloudsSignature === $recalculatedSignature;
    }

    private function storeDocuments($parcelData, Shipment $shipment)
    {
        $shipment->load('order');

        //Build an array of document urls we want to retrieve
        $documentUrls = [
            $parcelData['label']['label_printer']
        ];

        foreach($documentUrls as $url) {
            //Retrieve the urls or abort with a sensible error response if possible
            $response = null;
            try {
                $client = new Client();
                $response = $client->get(
                    $url, [
                        'headers' => ['accept' => 'application/json'],
                        'auth' => [$this->publicApiKey, $this->secretApiKey] //Auth => [username, password]
                    ]
                );
            } catch (RequestException $e) {
                $sensibleMessage = $this->handleGuzzleException($e);
                if ($sensibleMessage) {
                    abort(Response::HTTP_UNPROCESSABLE_ENTITY, $sensibleMessage);
                }
            }

            //Store the document physically
            if (!$this->getDisk()->put($this->getShipmentLabelPath($shipment), $response->getBody())) {
                throw new \RuntimeException('Could not save parcel document for shipment with id: ' . $shipment->id);
            }
        }

        $response = null;
    }

    /**
     * Whether the adapter can tell you what the shipping costs will be
     *
     * @return bool
     */
    public function providesShippingCosts(): bool
    {
        //TODO Change it to something like, "provides shipping info". Because it must retrieve stuff like, available countries and the costs associated with them
    }

    /**
     * Auto select a shipping method for the shipment and return its id.
     *
     * @param Order      $order
     * @param Shipment   $shipment
     * @param RegionInfo $shippingRegionInfo
     *
     * @return array
     */
    private function getShippingMethodForShipment(Order $order, Shipment $shipment, RegionInfo $shippingRegionInfo): ?array {
        $shippingMethods = $this->shippingMethods();
        //See https://docs.sendcloud.sc/api/v2/shipping/#list-of-all-shipping-methods for the structure
        foreach($shippingMethods as $shippingMethod) {
            $minWeight = floatval($shipment['min_weight']);
            $maxWeight = floatval($shipment['min_weight']);

            $matchingCountry = null;
            foreach($shippingMethod['countries'] as $country) {
                if($country['iso_3'] === $shippingRegionInfo->getThreeLetterISORegionName()) {
                    $matchingCountry = $country;
                }
            }

            //If test mode is one, we use the shipping method called unstamped letter. Because its free, and we won't get charged
            if($this->testMode) {
                if(strtolower($shippingMethod['name']) !== strtolower('Unstamped Letter')) continue;
            }


            if(!$matchingCountry) continue; //TODO Take min and max weight into consideration when we can do so.

            return $shippingMethod;
        }
        return null;
    }

    /**
     * Returns a validated array of parcel statusses
     *
     * @see https://docs.sendcloud.sc/api/v2/shipping/#parcel-status
     * @return array
     */
    public function parcelStatuses(): array
    {
        return Cache::remember('sendcloud_parcel_statuses', 3600, function () {
            //Request data
            try {
                $client = new Client();
                $response = $client->get(
                    'https://panel.sendcloud.sc/api/v2/parcels/statuses', [
                        'headers' => ['accept' => 'application/json'],
                        'auth' => [$this->publicApiKey, $this->secretApiKey] //Auth => [username, password]
                    ]
                );
            } catch (RequestException $e) {
                $sensibleMessage = $this->handleGuzzleException($e);
                if($sensibleMessage) abort(Response::HTTP_UNPROCESSABLE_ENTITY, $sensibleMessage);
            }

            //Validate that we have shipping methods
            $data = json_decode((string) $response->getBody(), true);
            Log::debug($this->adapterName . 'Retrieved parcel statuses from sendcloud');
//            Log::debug($data);

            if($this->parcelStatusResponseDataIsValid($data)) return $data;
            return [];
        });
    }

    /**
     * Returns a validated array of shipping methods
     *
     * @see https://docs.sendcloud.sc/api/v2/shipping/#list-of-all-shipping-methods
     * @return array
     */
    public function shippingMethods(): array
    {
        return Cache::remember('sendcloud_shippingcosts', 3600, function () {
            //Request data
            try {
                $client = new Client();
                $response = $client->get(
                    'https://panel.sendcloud.sc/api/v2/shipping_methods', [
                        'headers' => ['accept' => 'application/json'],
                        'auth' => [$this->publicApiKey, $this->secretApiKey] //Auth => [username, password]
                    ]
                );
            } catch (RequestException $e) {
                $sensibleMessage = $this->handleGuzzleException($e);
                if($sensibleMessage) abort(Response::HTTP_UNPROCESSABLE_ENTITY, $sensibleMessage);
            }

            //Validate that we have shipping methods
            $data = json_decode((string) $response->getBody(), true);
//            Log::debug($this->adapterName . 'Retrieved shipping methods from sendcloud');
//            Log::debug($data);

            if($this->shippingMethodsResponseDataIsValid($data)) return $data['shipping_methods'];
            return [];
        });
    }

    /**
     * Returns true when the shipment provider sends mail related to the shipment and its status,
     * or false when the kms shop should send those kind of emails
     *
     * @return bool
     */
    public function shipmentServiceProviderSendsMails(): bool
    {
        return true;
    }


    /**
     * Handles a guzzle exception by logging it and trying to distill a
     * sensible message that we can display to a user.
     *
     * @param RequestException $exception
     */
    private function handleGuzzleException(RequestException $exception) {
        $this->logGuzzleException($exception);
        $sensibleMessage =  $this->getSensibleMessageFromSendCloud($exception);
        if($sensibleMessage) $sensibleMessage = __('KMS::shipments.sendcloud.reacted_with_error', ['error' => $sensibleMessage]);

        return $sensibleMessage;
    }

    /**
     * Tries to extract an error message from the exception or returns null if not successfull
     *
     * @param RequestException $exception
     * @return string|null
     */
    private function getSensibleMessageFromSendCloud(RequestException $exception): ?string {
        if(!$exception->hasResponse()) return null;

        $body = (string) $exception->getResponse()->getBody();
        if(!$this->isErrorResponse($body)) return null;
        $body = json_decode($body, true);

        return $body['error']['message'];
    }

    /**
     * Logs a guzzle exception for analysis
     *
     * @param RequestException $exception
     */
    private function logGuzzleException(RequestException $exception) {
        Log::error(' ');
        Log::error(' ');
        Log::error('A request exception occurred');
        Log::error('Request: ');
        Log::error(guzzleStr($exception->getRequest()));
        Log::error(' ');
        Log::error('Response: ');
        Log::error(' ');
        Log::error(' ');
        Log::error(guzzleStr($exception->getResponse()));
    }

    private function parcelStatusResponseDataIsValid($data): bool {
        if(!is_array($data)) {
            Log::error($this->adapterName . 'Expected parcel statuses to be an array of statuses');
            return false;
        }

        foreach($data as $parcelStatusData) {
            if(
                !is_array($parcelStatusData) ||
                !array_key_exists('id', $parcelStatusData) ||
                !is_int($parcelStatusData['id']) ||
                !array_key_exists('message', $parcelStatusData) ||
                !is_string($parcelStatusData['message'])
            ) {
                Log::error($this->adapterName . 'Expected parcel status to contain an id integer key and a message string key');
                return false;
            }
        }

        return true;
    }

    private function cancelStatusDataIsValid($data) {
        if(!is_array($data)) {
            Log::error($this->adapterName . 'Expected cancel status data to be an array.');
        }

        foreach(['status', 'message'] as $key) {
            if(!array_key_exists($key, $data) || !is_string($data[$key])) {
                Log::error($this->adapterName . 'Expected parcel data to have a ' . $key . ' string key.');
                return false;
            }
        }

        return true;
    }

    private function shippingMethodsResponseDataIsValid($data): bool {
        if(
            !is_array($data) ||
            !array_key_exists('shipping_methods', $data) ||
            !is_array($data['shipping_methods'])
        ) {
            Log::error($this->adapterName . 'Expected a key / property with name "shipping_methods" when retrieving shipping methods. Did not get that key.');
            return false;
        }

        foreach($data['shipping_methods'] as $shipping_method) {
            if(!$this->shippingMethodDataIsValid($shipping_method)) return false;
        }

        return true;
    }

    private function basicParcelDataIsValid($data): bool {
        if(!is_array($data)) {
            Log::error($this->adapterName . 'Expected parcel data to be an array.');
            return false;
        }

        if(!array_key_exists('id', $data) || !is_int($data['id'])) {
            Log::error($this->adapterName . 'Expected parcel data to have a id integer key.');
            return false;
        }

        foreach(['name', 'company_name', 'address', 'city', 'postal_code', 'telephone', 'email', 'date_created', 'tracking_number', 'tracking_url'] as $key) {
            if(!array_key_exists($key, $data) || !is_string($data[$key])) {
                Log::error($this->adapterName . 'Expected parcel data to have a ' . $key . ' string key.');
                return false;
            }
        }

        if(!array_key_exists('address_divided', $data) || !is_array($data['address_divided'])) {
            Log::error($this->adapterName . 'Expected parcel data to have a address_divided array key.');
            return false;
        } else {
            foreach(['street', 'house_number'] as $key) {
                if (!array_key_exists($key, $data['address_divided']) || !is_string($data['address_divided'][$key])) {
                    Log::error($this->adapterName . 'Expected parcel address_divided data to have a ' . $key . ' string key.');
                    return false;
                }
            }
        }

        if(!$this->labelDataIsValid($data)) return false;
        if(!$this->statusDataIsValid($data)) return false;
        if(!$this->carrierDataIsValid($data)) return false;

        return true;
    }

    private function statusDataIsValid($data): bool
    {
        if(!array_key_exists('status', $data) || !is_array($data['status'])) {
            Log::error($this->adapterName . 'Expected parcel data to have a status array key.');
            return false;
        } else {
            if (
                !array_key_exists('id', $data['status']) || !is_int($data['status']['id']) ||
                !array_key_exists('message', $data['status']) || !is_string($data['status']['message'])
            ) {
                Log::error($this->adapterName . 'Expected status data to have an integer key called id and an string key called message.');
                return false;
            }
        }

        return true;
    }

    private function carrierDataIsValid($data): bool
    {
        if(!array_key_exists('carrier', $data) || !is_array($data['carrier'])) {
            Log::error($this->adapterName . 'Expected parcel data to have a carrier array key.');
            return false;
        } else {
            if (
                !array_key_exists('code', $data['carrier']) || !is_string($data['carrier']['code'])
            ) {
                Log::error($this->adapterName . 'Expected carrier data to have an string key called code.');
                return false;
            }
        }

        return true;
    }

    private function labelDataIsValid($data): bool
    {
        if(!array_key_exists('label', $data) || !is_array($data['label'])) {
            Log::error($this->adapterName . 'Expected parcel data to have a label array key.');
            return false;
        } else {
            if (!array_key_exists('normal_printer', $data['label']) || !is_array($data['label']['normal_printer'])) {
                Log::error($this->adapterName . 'Expected parcel label data to have a normal_printer array key.');
                return false;
            } else {
                foreach($data['label']['normal_printer'] as $normalPrinterUrl) {
                    if(!is_string($normalPrinterUrl)) {
                        Log::error($this->adapterName . 'Expected parcel label normal_printer data to contain strings.');
                        return false;
                    }
                }
            }

            if (!array_key_exists('label_printer', $data['label']) || !is_string($data['label']['label_printer'])) {
                Log::error($this->adapterName . 'Expected parcel label data to have a label_printer string key.');
                return false;
            }
        }
        return true;
    }

    private function shippingMethodDataIsValid($data): bool
    {
        if(!is_array($data)) {
            Log::error($this->adapterName . 'Expected a shipping method to be an array. But was a '.gettype($data));
            return false;
        }

        if(!array_key_exists('id', $data) || !is_int($data['id'])) {
            Log::error($this->adapterName . 'Expected a shipping method item to have a id integer key.');
            return false;
        }

        if(!array_key_exists('price', $data) || !is_numeric($data['price'])) {
            Log::error($this->adapterName . 'Expected a shipping method item to have price float key.');
            return false;
        }

        foreach(['max_weight', 'min_weight'] as $key) {
            if(!array_key_exists($key, $data) || !is_numeric($data[$key])) {
                Log::error($this->adapterName . 'Expected a shipping method country to have a ' . $key . ' string key.');
                return false;
            }
        }

        foreach(['carrier', 'service_point_input'] as $key) {
            if(!array_key_exists($key, $data) || !is_string($data[$key])) {
                Log::error($this->adapterName . 'Expected a shipping method country to have a ' . $key . ' string key.');
                return false;
            }
        }

        if(!array_key_exists('countries', $data) || !is_array($data['countries'])) {
            Log::error($this->adapterName . 'Expected a shipping method item to have a countries array.');
            return false;
        }

        foreach($data['countries'] as $country) {
            if(!$this->shippingMethodCountryIsValid($country)) return false;
        }

        return true;
    }

    private function shippingMethodCountryIsValid($data) {
        if(!is_array($data)) {
            Log::error($this->adapterName . 'Expected a shipping method country to be an array. But was a '.gettype($data));
            return false;
        }

        foreach(['iso_2', 'iso_3', 'name'] as $key) {
            if(!array_key_exists($key, $data) || !is_string($data[$key])) {
                Log::error($this->adapterName . 'Expected a shipping method country to have a ' . $key . ' string key.');
                return false;
            }
        }

        foreach(['id', 'price'] as $key) {
            if(!array_key_exists($key, $data) || !is_numeric($data[$key])) {
                Log::error($this->adapterName . 'Expected a shipping method country to have a ' . $key . ' numeric key.');
                return false;
            }
        }

        return true;
    }

    /**
     * @return false
     */
    private function validateAndGetActionMethod() {
        //Check that action is present. Sendcloud always includes it
        $action = request()->get('action');
        if(!$action) {
            Log::error($this->adapterName . 'Sendcloud called the kms adapter without specifying an action.');
            return false;
        }

        //Check that there is a method that can handle the action. If not, let sendcloud know.
        $actionMethodName = 'handle'. Str::studly($action) . 'Action';
        if(!method_exists($this, $actionMethodName)) {
            Log::warning($this->adapterName . 'could not handle a sendcloud action called: ' . $action . '. Expected a method called '.$actionMethodName.' in class '. self::class.' to exist. Please create and implement it to handle the action.');
            return false;
        }

        return $actionMethodName;
    }

    private function isErrorResponse(string $errorResponse): bool {
        $decoded = json_decode($errorResponse, true);
        if(!$decoded) return false;

        if(!array_key_exists('error', $decoded)) return false;

        //Validate that
        return (count(array_intersect(['code', 'request', 'message'], array_keys($decoded['error']))) == count(array_keys($decoded['error'])));
    }
}