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'])));
}
}