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 { $usesProfile = str_starts_with(ltrim($path, '/'), 'me/') && $this->profileConnected(); $settings = $this->settings->for('parcel2go'); $key = 'parcel2go.'.($usesProfile ? 'profile' : 'client').'.'.md5(implode('|', [$this->url($path), $settings['client_id'] ?? '', $settings['profile_client_id'] ?? ''])); return $this->cache->remember($key, 300, fn (): array => Http::withToken($usesProfile ? $this->profileAccessToken() : $this->accessToken()) ->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.'); } $tokenKey = 'parcel2go.token.'.md5(implode('|', [$settings['auth_url'], $settings['client_id'], $settings['client_secret'], $settings['scope'] ?? ''])); $cached = Cache::get($tokenKey); if (is_array($cached) && filled($cached['token'] ?? null)) { $this->activeScope = $cached['scope']; return $this->token = $cached['token']; } $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; Cache::put($tokenKey, ['token' => $token, 'scope' => $scope], max(60, (int) $response->json('expires_in', 3600) - 120)); 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'], ]); } }