272 lines
12 KiB
PHP
272 lines
12 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\IntegrationSetting;
|
|
use Illuminate\Http\Client\Response;
|
|
use Illuminate\Support\Carbon;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Str;
|
|
use RuntimeException;
|
|
|
|
class Parcel2Go
|
|
{
|
|
private ?string $token = null;
|
|
|
|
private ?string $activeScope = null;
|
|
|
|
private bool $reducedScope = false;
|
|
|
|
private ?string $profileToken = null;
|
|
|
|
public function __construct(private IntegrationSettings $settings) {}
|
|
|
|
public function hasScope(string $scope): bool
|
|
{
|
|
return in_array($scope, explode(' ', $this->activeScope ?? (string) ($this->settings->for('parcel2go')['scope'] ?? 'public-api')), true);
|
|
}
|
|
|
|
public function scopeWasReduced(): bool
|
|
{
|
|
return $this->reducedScope;
|
|
}
|
|
|
|
public function verifyCredentials(): void
|
|
{
|
|
$this->accessToken();
|
|
}
|
|
|
|
public function profileConnected(): bool
|
|
{
|
|
$connection = IntegrationSetting::find('parcel2go_oauth')?->settings;
|
|
|
|
return filled($connection['access_token'] ?? null)
|
|
&& ($connection['client_id'] ?? null) === ($this->settings->for('parcel2go')['profile_client_id'] ?? null);
|
|
}
|
|
|
|
public function profileClientConfigured(): bool
|
|
{
|
|
return filled($this->settings->for('parcel2go')['profile_client_id'] ?? null);
|
|
}
|
|
|
|
public function authorizationUrl(string $state, string $verifier): string
|
|
{
|
|
$settings = $this->settings->for('parcel2go');
|
|
if (blank($settings['profile_client_id'] ?? null)) {
|
|
throw new RuntimeException('Parcel2Go Authorization Code Client ID is not configured.');
|
|
}
|
|
|
|
return $this->authorizationEndpoint().'?'.http_build_query([
|
|
'client_id' => $settings['profile_client_id'],
|
|
'redirect_uri' => route('parcel2go.callback'),
|
|
'response_type' => 'code',
|
|
'scope' => 'public-api my-profile',
|
|
'state' => $state,
|
|
'code_challenge' => rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '='),
|
|
'code_challenge_method' => 'S256',
|
|
], '', '&', PHP_QUERY_RFC3986);
|
|
}
|
|
|
|
public function connectProfile(string $code, string $verifier): void
|
|
{
|
|
$settings = $this->settings->for('parcel2go');
|
|
$tokens = Http::asForm()->acceptJson()->connectTimeout(3)->timeout(10)
|
|
->post($settings['auth_url'], [
|
|
'grant_type' => 'authorization_code',
|
|
'client_id' => $settings['profile_client_id'],
|
|
'redirect_uri' => route('parcel2go.callback'),
|
|
'code' => $code,
|
|
'code_verifier' => $verifier,
|
|
])->throw()->json();
|
|
if (empty($tokens['access_token'])) {
|
|
throw new RuntimeException('Parcel2Go did not return a profile access token.');
|
|
}
|
|
Http::withToken($tokens['access_token'])->acceptJson()->connectTimeout(3)->timeout(15)
|
|
->get($this->url('me/orders/detail'))->throw();
|
|
|
|
IntegrationSetting::updateOrCreate(['provider' => 'parcel2go_oauth'], ['settings' => [
|
|
'client_id' => $settings['profile_client_id'],
|
|
'access_token' => $tokens['access_token'],
|
|
'refresh_token' => $tokens['refresh_token'] ?? null,
|
|
'expires_at' => now()->addSeconds(max(60, (int) ($tokens['expires_in'] ?? 3600) - 60))->toIso8601String(),
|
|
]]);
|
|
$this->profileToken = $tokens['access_token'];
|
|
}
|
|
|
|
public function fetch(string $path): array
|
|
{
|
|
$token = str_starts_with(ltrim($path, '/'), 'me/') && $this->profileConnected()
|
|
? $this->profileAccessToken()
|
|
: $this->accessToken();
|
|
|
|
return Http::withToken($token)->acceptJson()->connectTimeout(3)->timeout(15)
|
|
->get($this->url($path))->throw()->json() ?? [];
|
|
}
|
|
|
|
public function quote(array $details): array
|
|
{
|
|
return Http::withToken($this->accessToken())->acceptJson()->connectTimeout(3)->timeout(15)
|
|
->post($this->url('quotes'), [
|
|
'CollectionAddress' => ['Country' => 'GBR', 'Postcode' => $details['collection_postcode']],
|
|
'DeliveryAddress' => ['Country' => 'GBR', 'Postcode' => $details['delivery_postcode']],
|
|
'Parcels' => [[
|
|
'Value' => $details['value'], 'Weight' => $details['weight'],
|
|
'Length' => $details['length'], 'Width' => $details['width'], 'Height' => $details['height'],
|
|
]],
|
|
])->throw()->json() ?? [];
|
|
}
|
|
|
|
public function collectionDates(string $service, string $street, string $town, string $postcode): array
|
|
{
|
|
return Http::withToken($this->accessToken())->acceptJson()->connectTimeout(3)->timeout(15)
|
|
->post($this->url('collectiondates'), [
|
|
'ServiceSlug' => $service, 'Address' => $street, 'City' => $town,
|
|
'Postcode' => $postcode, 'CountryISO3Code' => 'GBR',
|
|
])->throw()->json() ?? [];
|
|
}
|
|
|
|
public function bookCollection(array $details, string $service, array $booking): array
|
|
{
|
|
$collectionAddress = [
|
|
'ContactName' => $booking['sender_name'], 'Email' => $booking['sender_email'],
|
|
'Phone' => $booking['sender_phone'], 'Property' => $booking['sender_property'],
|
|
'Street' => $booking['sender_street'], 'Town' => $booking['sender_town'],
|
|
'Postcode' => $details['collection_postcode'], 'CountryIsoCode' => 'GBR',
|
|
];
|
|
$deliveryAddress = [
|
|
'ContactName' => $booking['recipient_name'], 'Email' => $booking['recipient_email'],
|
|
'Phone' => $booking['recipient_phone'], 'Property' => $booking['recipient_property'],
|
|
'Street' => $booking['recipient_street'], 'Town' => $booking['recipient_town'],
|
|
'Postcode' => $details['delivery_postcode'], 'CountryIsoCode' => 'GBR',
|
|
];
|
|
[$forename, $surname] = array_pad(explode(' ', trim($booking['sender_name']), 2), 2, '');
|
|
|
|
return Http::withToken($this->accessToken())->acceptJson()->connectTimeout(3)->timeout(20)
|
|
->post($this->url('orders'), [
|
|
'Items' => [[
|
|
'Id' => (string) Str::uuid(),
|
|
'CollectionDate' => $booking['collection_date'],
|
|
'OriginCountry' => 'GBR', 'Service' => $service,
|
|
'CollectionAddress' => $collectionAddress,
|
|
'Parcels' => [[
|
|
'Id' => (string) Str::uuid(),
|
|
'Height' => $details['height'], 'Length' => $details['length'],
|
|
'Width' => $details['width'], 'Weight' => $details['weight'],
|
|
'EstimatedValue' => $details['value'],
|
|
'ContentsSummary' => $booking['contents'],
|
|
'DeliveryAddress' => $deliveryAddress,
|
|
]],
|
|
]],
|
|
'CustomerDetails' => ['Email' => $booking['sender_email'], 'Forename' => $forename, 'Surname' => $surname ?: $forename],
|
|
])->throw()->json() ?? [];
|
|
}
|
|
|
|
public function labelPdf(string $reference, string $hash): string
|
|
{
|
|
$token = $this->profileConnected() ? $this->profileAccessToken() : $this->accessToken();
|
|
$data = Http::withToken($token)->acceptJson()->connectTimeout(3)->timeout(20)
|
|
->get($this->url('labels/'.$reference), [
|
|
'referenceType' => 'OrderLineId', 'detailLevel' => 'Labels',
|
|
'labelMedia' => 'A4', 'labelFormat' => 'PDF', 'hash' => $hash,
|
|
])->throw()->json();
|
|
$encoded = $data['Base64EncodedLabels'][0] ?? '';
|
|
$pdf = is_string($encoded) ? base64_decode($encoded, true) : false;
|
|
if ($pdf === false || ! str_starts_with($pdf, '%PDF-')) {
|
|
throw new RuntimeException('Parcel2Go did not return a valid PDF label.');
|
|
}
|
|
|
|
return $pdf;
|
|
}
|
|
|
|
private function url(string $path): string
|
|
{
|
|
return rtrim($this->settings->for('parcel2go')['base_url'], '/').'/'.ltrim($path, '/');
|
|
}
|
|
|
|
private function authorizationEndpoint(): string
|
|
{
|
|
$tokenUrl = $this->settings->for('parcel2go')['auth_url'] ?? '';
|
|
if (! str_ends_with($tokenUrl, '/token')) {
|
|
throw new RuntimeException('Parcel2Go token URL must end in /token.');
|
|
}
|
|
|
|
return substr($tokenUrl, 0, -strlen('/token')).'/authorize';
|
|
}
|
|
|
|
private function profileAccessToken(): string
|
|
{
|
|
if ($this->profileToken !== null) {
|
|
return $this->profileToken;
|
|
}
|
|
$record = IntegrationSetting::find('parcel2go_oauth');
|
|
$connection = $record?->settings ?? [];
|
|
if (empty($connection['access_token']) || ($connection['client_id'] ?? null) !== ($this->settings->for('parcel2go')['profile_client_id'] ?? null)) {
|
|
throw new RuntimeException('Parcel2Go account connection is missing or belongs to another API client.');
|
|
}
|
|
if (Carbon::parse($connection['expires_at'] ?? null)->isFuture()) {
|
|
return $this->profileToken = $connection['access_token'];
|
|
}
|
|
if (empty($connection['refresh_token'])) {
|
|
throw new RuntimeException('Parcel2Go account access expired. Reconnect the account.');
|
|
}
|
|
|
|
$tokens = Http::asForm()->acceptJson()->connectTimeout(3)->timeout(10)
|
|
->post($this->settings->for('parcel2go')['auth_url'], [
|
|
'grant_type' => 'refresh_token',
|
|
'client_id' => $connection['client_id'],
|
|
'refresh_token' => $connection['refresh_token'],
|
|
])->throw()->json();
|
|
if (empty($tokens['access_token'])) {
|
|
throw new RuntimeException('Parcel2Go did not refresh account access.');
|
|
}
|
|
$record->settings = array_replace($connection, [
|
|
'access_token' => $tokens['access_token'],
|
|
'refresh_token' => $tokens['refresh_token'] ?? $connection['refresh_token'],
|
|
'expires_at' => now()->addSeconds(max(60, (int) ($tokens['expires_in'] ?? 3600) - 60))->toIso8601String(),
|
|
]);
|
|
$record->save();
|
|
|
|
return $this->profileToken = $tokens['access_token'];
|
|
}
|
|
|
|
private function accessToken(): string
|
|
{
|
|
if ($this->token !== null) {
|
|
return $this->token;
|
|
}
|
|
|
|
$settings = $this->settings->for('parcel2go');
|
|
if (! $settings['client_id'] || ! $settings['client_secret'] || ! $settings['auth_url']) {
|
|
throw new RuntimeException('Parcel2Go credentials are not configured.');
|
|
}
|
|
|
|
$scope = (string) ($settings['scope'] ?? 'public-api');
|
|
$response = $this->tokenResponse($settings, $scope);
|
|
if ($scope !== 'public-api' && $response->status() === 400 && $response->json('error') === 'invalid_scope') {
|
|
$scope = 'public-api';
|
|
$response = $this->tokenResponse($settings, $scope);
|
|
$this->reducedScope = true;
|
|
}
|
|
|
|
$token = $response->throw()->json('access_token');
|
|
if (! $token) {
|
|
throw new RuntimeException('Parcel2Go did not return an access token.');
|
|
}
|
|
|
|
$this->activeScope = $scope;
|
|
|
|
return $this->token = $token;
|
|
}
|
|
|
|
/** @param array{auth_url: string, client_id: string, client_secret: string} $settings */
|
|
private function tokenResponse(array $settings, string $scope): Response
|
|
{
|
|
return Http::asForm()->timeout(10)->post($settings['auth_url'], [
|
|
'grant_type' => 'client_credentials',
|
|
'scope' => $scope,
|
|
'client_id' => $settings['client_id'],
|
|
'client_secret' => $settings['client_secret'],
|
|
]);
|
|
}
|
|
}
|