credentials(); return filled($credentials['keystring']) && filled($credentials['shared_secret']); } public function authorizationUrl(string $state, string $verifier): string { if (! $this->configured()) { throw new RuntimeException('Etsy application credentials are not configured.'); } $challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '='); return 'https://www.etsy.com/oauth/connect?'.http_build_query([ 'response_type' => 'code', 'redirect_uri' => $this->redirectUri(), 'scope' => 'transactions_r listings_r', 'client_id' => $this->credentials()['keystring'], 'state' => $state, 'code_challenge' => $challenge, 'code_challenge_method' => 'S256', ], '', '&', PHP_QUERY_RFC3986); } public function connect(string $code, string $verifier): EtsyConnection { $tokens = Http::asForm()->acceptJson()->connectTimeout(3)->timeout(10) ->post(self::API_URL.'/public/oauth/token', [ 'grant_type' => 'authorization_code', 'client_id' => $this->credentials()['keystring'], 'redirect_uri' => $this->redirectUri(), 'code' => $code, 'code_verifier' => $verifier, ])->throw()->json(); $userId = explode('.', $tokens['access_token'] ?? '')[0]; if (! ctype_digit($userId) || empty($tokens['refresh_token'])) { throw new RuntimeException('Etsy returned an invalid OAuth response.'); } $shop = $this->get('application/users/'.$userId.'/shops', $tokens['access_token']); if (empty($shop['shop_id'])) { throw new RuntimeException('This Etsy account does not own a shop.'); } if (strcasecmp($shop['shop_name'] ?? '', $this->credentials()['shop_name']) !== 0) { throw new RuntimeException('The Etsy account does not own the configured HickLab3D shop.'); } return EtsyConnection::updateOrCreate(['shop_id' => $shop['shop_id']], [ 'shop_name' => $shop['shop_name'] ?? 'Etsy shop', 'access_token' => $tokens['access_token'], 'refresh_token' => $tokens['refresh_token'], 'expires_at' => now()->addSeconds(max(60, (int) ($tokens['expires_in'] ?? 3600) - 60)), ]); } /** Paid, unshipped orders that still need dispatching, excluding cancelled and fully refunded ones. */ public function receipts(EtsyConnection $connection, bool $fresh = false): array { return $this->cache->remember('etsy.receipts.'.$connection->shop_id, 300, function () use ($connection): array { $response = $this->get('application/shops/'.$connection->shop_id.'/receipts', $this->accessToken($connection), [ 'was_paid' => 'true', 'was_shipped' => 'false', 'limit' => 50, ]); $results = array_values(array_filter($response['results'] ?? [], fn ($receipt): bool => is_array($receipt) && ! $this->isCancelledOrRefunded($receipt))); return ['count' => count($results), 'results' => $results] + $response; }, $fresh); } /** @param array $receipt */ public function isCancelledOrRefunded(array $receipt): bool { if (in_array(strtolower((string) ($receipt['status'] ?? '')), ['canceled', 'cancelled'], true) || ! empty($receipt['was_canceled'])) { return true; } $total = (float) ($receipt['grandtotal']['amount'] ?? 0) / max(1, (int) ($receipt['grandtotal']['divisor'] ?? 1)); $refunded = collect($receipt['refunds'] ?? [])->sum(fn ($refund): float => (float) ($refund['amount']['amount'] ?? 0) / max(1, (int) ($refund['amount']['divisor'] ?? 1))); return $total > 0 && $refunded >= $total; } public function listings(EtsyConnection $connection): array { return $this->cache->remember('etsy.listings.'.$connection->shop_id, 600, fn (): array => $this->get( 'application/shops/'.$connection->shop_id.'/listings', $this->accessToken($connection), [ 'state' => 'active', 'limit' => 20, ])); } private function accessToken(EtsyConnection $connection): string { if ($connection->expires_at->isFuture()) { return $connection->access_token; } $tokens = Http::asForm()->acceptJson()->connectTimeout(3)->timeout(10) ->post(self::API_URL.'/public/oauth/token', [ 'grant_type' => 'refresh_token', 'client_id' => $this->credentials()['keystring'], 'refresh_token' => $connection->refresh_token, ])->throw()->json(); if (empty($tokens['access_token']) || empty($tokens['refresh_token'])) { throw new RuntimeException('Etsy did not refresh the shop connection.'); } $connection->update([ 'access_token' => $tokens['access_token'], 'refresh_token' => $tokens['refresh_token'], 'expires_at' => now()->addSeconds(max(60, (int) ($tokens['expires_in'] ?? 3600) - 60)), ]); return $tokens['access_token']; } private function get(string $path, string $accessToken, array $query = []): array { $credentials = $this->credentials(); return Http::withHeaders(['x-api-key' => $credentials['keystring'].':'.$credentials['shared_secret']]) ->withToken($accessToken)->acceptJson()->connectTimeout(3)->timeout(10) ->get(self::API_URL.'/'.$path, $query)->throw()->json() ?? []; } public function redirectUri(): string { return $this->credentials()['redirect_uri'] ?: route('etsy.callback'); } /** @return array{keystring: ?string, shared_secret: ?string, shop_name: string, redirect_uri: ?string} */ private function credentials(): array { if ($this->credentials !== null) { return $this->credentials; } $stored = EtsyAppCredential::find(1); return $this->credentials = [ 'keystring' => $stored?->keystring ?: config('services.etsy.keystring'), 'shared_secret' => $stored?->shared_secret ?: config('services.etsy.shared_secret'), 'shop_name' => $stored?->shop_name ?: config('services.etsy.shop_name', 'HickLab3D'), 'redirect_uri' => $stored?->redirect_uri ?: config('services.etsy.redirect_uri'), ]; } }